diff --git a/CONTEXT.md b/CONTEXT.md index 98a7b55..ad75d43 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,6 +27,10 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev - **Config root** — the directory a tool's config is loaded from, which is also the directory the tool's process stands in. For the fmt server the editor anchors it at the workspace folder root, so it loads the config a terminal opened on that folder would, and a subproject that needs its own config becomes its own workspace folder. The test stack does not share this anchor: a project's cwd is set per project (for native configs, upstream's config-file-directory rule). _Avoid_: config directory, project root. - **Ownership** — the editor-side rule choosing one config source for a tool's unit of work when both a native config and a Rstack config are present: the atomic tool's native config wins and the bridge yields. The unit is the tool's own — a project for test (one per config directory), a workspace folder for lint (one config choice per folder, locked for each lint runtime's lifetime). This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config. +## test + +- **Test file owner** — for one run request, a project whose root is deepest among the published items sharing a file URI, with ties sharing ownership. This per-request choice routes single-file/case selections across projects; explicit single-project selections and project-wide runs retain CLI scope. + ## lint - **Rslint core** — one `@rslint/core` package directory, identified by its real path (two copies of the same version are two cores; a symlink to one copy is that copy). Everything a lint runtime runs — the Go binary, config host, protocol version, plugin host — derives from one Rslint core. _Avoid_: core (bare), installation, binary. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index f5f74b2..a8f081e 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -5,13 +5,13 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The copies are intentional - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. -- The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list. +- The copies diverge from upstream in exactly ten ways (the "adaptations" below). When syncing upstream, preserve them. An eleventh divergence is either a bug or must be added to this list. - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`). - **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry failed projects, including real config errors, while preserving single-flight loading and worker cleanup. -## The nine adaptations +## The ten adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. 2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). @@ -23,6 +23,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. 9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does. +10. **Test file ownership** (test) — publication mirrors the CLI per project. When a request carries the same file or the same case from several projects (merged gutter, run-at-cursor, run-current-file; all three profiles reach `startTestRun`), only items of the deepest project root run and the others are reported skipped to clear stale merged gutter state — implemented in `runRouting.ts`, `index.ts` (`startTestRun`), and `master.ts` (`RstestApi.project` made public). Explicit single-project selections, project/folder/Run All, and `rstack.rstest.runInTerminal` (VS Code supplies one explicitly picked item) keep CLI scope. + ## Rules - **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version. @@ -41,6 +43,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't +- **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules. - fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/.nvmrc b/packages/vscode/e2e/fixtures/rstest-ownership/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/host/rstest.config.ts b/packages/vscode/e2e/fixtures/rstest-ownership/host/rstest.config.ts new file mode 100644 index 0000000..5d70f68 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/host/rstest.config.ts @@ -0,0 +1,10 @@ +import path from 'node:path'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + resolve: { + alias: { + '@host-value': path.resolve(__dirname, 'value.ts'), + }, + }, +}); diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/host/tests/ownership.test.ts b/packages/vscode/e2e/fixtures/rstest-ownership/host/tests/ownership.test.ts new file mode 100644 index 0000000..68cf83b --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/host/tests/ownership.test.ts @@ -0,0 +1,8 @@ +import path from 'node:path'; +import { expect, test } from '@rstest/core'; +import { value } from '@host-value'; + +test('uses the nested config and cwd', () => { + expect(value).toBe('nested-config'); + expect(process.cwd()).toBe(path.resolve(__dirname, '..')); +}); diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/host/value.ts b/packages/vscode/e2e/fixtures/rstest-ownership/host/value.ts new file mode 100644 index 0000000..83e7f47 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/host/value.ts @@ -0,0 +1 @@ +export const value = 'nested-config'; diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/package.json b/packages/vscode/e2e/fixtures/rstest-ownership/package.json new file mode 100644 index 0000000..c87839d --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-rstest-ownership", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@rstest/core": "0.11.12", + "rstack": "0.7.4" + } +} diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/pnpm-workspace.yaml b/packages/vscode/e2e/fixtures/rstest-ownership/pnpm-workspace.yaml new file mode 100644 index 0000000..e5660b3 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# Independent fixture install; the root serves the nested native project too. +minimumReleaseAgeExclude: + - rstack + - '@rslint/core' + - '@rstest/core' + - '@rsbuild/core' + - '@rslib/core' + - '@rstackjs/*' + - rsbuild-plugin-dts + - '@rspack/*' diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/rstack.config.ts b/packages/vscode/e2e/fixtures/rstest-ownership/rstack.config.ts new file mode 100644 index 0000000..7c142f5 --- /dev/null +++ b/packages/vscode/e2e/fixtures/rstest-ownership/rstack.config.ts @@ -0,0 +1,5 @@ +import { define } from 'rstack'; + +// No define.test(): the published shim still supplies default test includes. +define.lint([]); +define.fmt({}); diff --git a/packages/vscode/e2e/rstest/suite/bridge.test.ts b/packages/vscode/e2e/rstest/suite/bridge.test.ts index b81605d..f21fe6f 100644 --- a/packages/vscode/e2e/rstest/suite/bridge.test.ts +++ b/packages/vscode/e2e/rstest/suite/bridge.test.ts @@ -24,6 +24,7 @@ import { FIXTURES_ROOT, getRstestExports, getTestItemByLabels, + getTestItemsRecursive, toLabelTree, waitFor, } from './helpers'; @@ -32,6 +33,24 @@ import { const RSTACK_FIXTURE = path.resolve(FIXTURES_ROOT, '../../fixtures/rstack'); const RSTACK_FIXTURE_URI = vscode.Uri.file(RSTACK_FIXTURE); +const addFixtureFolder = (uri: vscode.Uri) => { + assert.ok( + vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders?.length || 0, + 0, + { uri }, + ), + ); +}; +const removeFixtureFolder = (uri: vscode.Uri) => { + // URI comparison also handles Windows drive-letter casing. + const index = vscode.workspace.workspaceFolders?.findIndex( + (folder) => folder.uri.toString() === uri.toString(), + ); + assert.ok(index !== undefined && index >= 0); + assert.ok(vscode.workspace.updateWorkspaceFolders(index, 1)); +}; + const WORKSPACE_1_FILES = [ { label: 'each.test.ts' }, { label: 'foo.test.ts' }, @@ -45,25 +64,11 @@ const WORKSPACE_1_FILES = [ suite('Rstack bridge suite', () => { suiteSetup(async () => { await getRstestExports(); - const added = vscode.workspace.updateWorkspaceFolders( - vscode.workspace.workspaceFolders?.length || 0, - 0, - { uri: RSTACK_FIXTURE_URI }, - ); - assert.ok(added, 'adding the rstack fixture folder should be accepted'); + addFixtureFolder(RSTACK_FIXTURE_URI); }); suiteTeardown(async () => { - // Compare `uri.toString()`, not `fsPath`: `fsPath` lower-cases the Windows - // drive letter while `path.resolve` keeps it as-is, so a raw string - // compare can miss on Windows — and a missed removal here would leak the - // folder into every later suite. - const index = vscode.workspace.workspaceFolders?.findIndex( - (folder) => folder.uri.toString() === RSTACK_FIXTURE_URI.toString(), - ); - assert.ok(index !== undefined && index >= 0); - const removed = vscode.workspace.updateWorkspaceFolders(index, 1); - assert.ok(removed, 'removing the rstack fixture folder should be accepted'); + removeFixtureFolder(RSTACK_FIXTURE_URI); // Later suites assert on the unwrapped single-folder tree; leave only // after the controller has actually settled back into it. await waitFor(() => { @@ -168,4 +173,148 @@ suite('Rstack bridge suite', () => { ); assert.match(collecting.output, /1 passed/); }); + + test('publishes both projects but routes merged file and case runs to the owner', async () => { + const fixture = path.resolve( + FIXTURES_ROOT, + '../../fixtures/rstest-ownership', + ); + const fixtureUri = vscode.Uri.file(fixture); + const testUri = vscode.Uri.file( + path.join(fixture, 'host/tests/ownership.test.ts'), + ); + addFixtureFolder(fixtureUri); + const assertOwnership = () => { + const exports = currentRstestExports(); + const folder = getTestItemByLabels(exports.testController.items, [ + 'rstest-ownership', + ]); + const root = getTestItemByLabels(folder.children, ['rstack.config.ts']); + const host = getTestItemByLabels(folder.children, ['host']); + // Both configs publish their CLI scope, even where the files overlap. + assert.ok(exports.getResolvedRstestPath(root.id)); + assert.ok(exports.getResolvedRstestPath(host.id)); + assert.equal(root.busy, false); + assert.equal(host.busy, false); + const files = getTestItemsRecursive(folder.children).filter( + (item) => + item.uri?.toString() === testUri.toString() && + item.label === 'ownership.test.ts', + ); + const matches = files.map((file) => { + const ancestors: vscode.TestItem[] = []; + for (let parent = file.parent; parent; parent = parent.parent) { + ancestors.unshift(parent); + } + return { file, ancestors }; + }); + assert.equal( + files.length, + 2, + 'both projects must publish their own copy of the same URI', + ); + const rootFiles = matches.filter(({ ancestors }) => + ancestors.includes(root), + ); + const hostFiles = matches.filter(({ ancestors }) => + ancestors.includes(host), + ); + assert.equal(rootFiles.length, 1, 'expected one file under root'); + assert.equal(hostFiles.length, 1, 'expected one file under host'); + const rootFile = rootFiles[0].file; + const hostFile = hostFiles[0].file; + assert.equal(rootFile.children.size, 1); + assert.equal(hostFile.children.size, 1); + const rootCase = getTestItemByLabels(rootFile.children, [ + 'uses the nested config and cwd', + ]); + const hostCase = getTestItemByLabels(hostFile.children, [ + 'uses the nested config and cwd', + ]); + return { exports, root, rootFile, hostFile, rootCase, hostCase }; + }; + const cancellation = new vscode.CancellationTokenSource(); + try { + const { exports, root, rootFile, hostFile } = await waitFor( + assertOwnership, + { + timeoutMs: 60_000, + }, + ); + for (const kind of ['file', 'case']) { + const { rootCase, hostCase } = assertOwnership(); + const include = + kind === 'file' ? [rootFile, hostFile] : [rootCase, hostCase]; + const collecting = createCollectingMockRun(); + await exports.startTestRun( + new vscode.TestRunRequest(include, undefined, exports.runProfile), + cancellation.token, + false, + collecting.createMockRun, + ); + await collecting.ended; + assert.ok( + collecting.passedItems.some( + (item) => + item.parent === hostFile && + item.label === 'uses the nested config and cwd', + ), + ); + assert.equal(collecting.failedItems.length, 0); + assert.deepStrictEqual( + collecting.skippedItems, + kind === 'file' ? [rootFile, rootCase] : [rootCase], + ); + for (const records of [ + collecting.enqueuedItems, + collecting.passedItems, + collecting.failedItems, + ]) { + assert.ok( + !records.some( + (item) => item === rootFile || item.parent === rootFile, + ), + ); + } + } + // Project Run All keeps the root CLI scope, which lacks the host alias. + const rootRun = createCollectingMockRun(); + await exports.startTestRun( + new vscode.TestRunRequest([root], undefined, exports.runProfile), + cancellation.token, + false, + rootRun.createMockRun, + ); + await rootRun.ended; + assert.ok(rootRun.failedItems.includes(rootFile)); + assert.match( + rootRun.failedMessages + .map((message) => String(message.message)) + .join('\n'), + /@host-value/, + ); + for (const records of [ + rootRun.enqueuedItems, + rootRun.passedItems, + rootRun.failedItems, + ]) { + assert.ok( + !records.some( + (item) => item === hostFile || item.parent === hostFile, + ), + ); + } + } finally { + cancellation.dispose(); + removeFixtureFolder(fixtureUri); + await waitFor(() => { + assert.equal(currentRstestExports().testController.items.size, 2); + assert.ok( + !vscode.workspace.workspaceFolders?.some( + (folder) => folder.uri.toString() === fixtureUri.toString(), + ), + ); + }); + } + }); }); diff --git a/packages/vscode/e2e/rstest/suite/helpers.ts b/packages/vscode/e2e/rstest/suite/helpers.ts index d2467cb..6c41c09 100644 --- a/packages/vscode/e2e/rstest/suite/helpers.ts +++ b/packages/vscode/e2e/rstest/suite/helpers.ts @@ -98,6 +98,15 @@ export function getTestItems(collection: vscode.TestItemCollection) { return items; } +export function getTestItemsRecursive( + collection: vscode.TestItemCollection, +): vscode.TestItem[] { + return getTestItems(collection).flatMap((item) => [ + item, + ...getTestItemsRecursive(item.children), + ]); +} + export function getProjectItems(testController: vscode.TestController) { const folders = getTestItems(testController.items); assert.equal(folders.length, 1); @@ -158,8 +167,10 @@ export function createCollectingMockRun() { const deferred = Promise.withResolvers(); let output = ''; const failedMessages: vscode.TestMessage[] = []; + const failedItems: vscode.TestItem[] = []; const passedItems: vscode.TestItem[] = []; const skippedItems: vscode.TestItem[] = []; + const enqueuedItems: vscode.TestItem[] = []; const createMockRun = (): vscode.TestRun => ({ isPersisted: true, @@ -173,9 +184,12 @@ export function createCollectingMockRun() { end: () => { deferred.resolve(null); }, - enqueued: () => {}, + enqueued: (test) => { + enqueuedItems.push(test); + }, errored: () => {}, - failed: (_test, message = []) => { + failed: (test, message = []) => { + failedItems.push(test); failedMessages.push(...(message as vscode.TestMessage[])); }, passed: (test) => { @@ -195,7 +209,9 @@ export function createCollectingMockRun() { return output; }, failedMessages, + failedItems, passedItems, skippedItems, + enqueuedItems, }; } diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 4b69b6f..d525b56 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -34,7 +34,12 @@ const SLICES = [ // The ported Rstest suites, plus `suite/bridge.test.ts`, which adds the // shared `rstack` fixture as a second workspace folder. name: 'rstest', - fixtures: ['rstest-workspace-1', 'rstest-workspace-2', 'rstack'], + fixtures: [ + 'rstest-workspace-1', + 'rstest-workspace-2', + 'rstack', + 'rstest-ownership', + ], entry: 'tests-dist/e2e/rstest/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 5bca7dd..c135a44 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -43,6 +43,7 @@ export const FIXTURES = { rslint: path.join(FIXTURES_DIR, 'rslint'), rstest: path.join(FIXTURES_DIR, 'rstest'), rstack: path.join(FIXTURES_DIR, 'rstack'), + 'rstest-ownership': path.join(FIXTURES_DIR, 'rstest-ownership'), 'fmt-missing-config-dependency': path.join( FIXTURES_DIR, 'fmt-missing-config-dependency', diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 10f36ef..c8abe8b 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -11,6 +11,7 @@ import { logger } from './logger'; import { runningWorkers, warmWorkerNodePreflight } from './master'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { Project, WorkspaceManager } from './project'; +import { routeToOwners } from './runRouting'; import { status } from './status'; import { disposeTerminal } from './terminal'; import { RstestFileCoverage } from './testRunReporter'; @@ -400,21 +401,45 @@ class Rstest implements vscode.Disposable { // used by e2e tests createTestRun = this.ctrl.createTestRun.bind(this.ctrl), ) => { + const routed = + request.include && + routeToOwners(request.include, (item) => { + const data = testData.get(item); + if (data instanceof TestFile || data instanceof TestCase) { + return { + key: + data instanceof TestCase + ? `${data.uri.toString()}#${data.type}:${JSON.stringify([...data.parentNames, item.label])}` + : data.uri.toString(), + root: data.api.project.root.fsPath, + }; + } + return undefined; + }); + const include = routed?.kept; + // Keep the original request's scope so the first run can report dropped items skipped. const run = createTestRun(request); - const enqueuedTests = (tests: readonly vscode.TestItem[]) => { - for (const test of tests) { + const forEachRunnable = ( + items: readonly vscode.TestItem[], + fn: (item: vscode.TestItem) => void, + ) => { + for (const test of items) { if (request.exclude?.includes(test)) { continue; } const data = testData.get(test); if (data instanceof TestFile || data instanceof TestCase) { - run.enqueued(test); + fn(test); } - enqueuedTests(gatherTestItems(test.children, false)); + forEachRunnable(gatherTestItems(test.children, false), fn); } }; - enqueuedTests(request.include ?? gatherTestItems(this.ctrl.items, false)); + forEachRunnable(routed?.dropped ?? [], (item) => run.skipped(item)); + forEachRunnable( + include ?? gatherTestItems(this.ctrl.items, false), + (item) => run.enqueued(item), + ); const commonOptions = { run, @@ -427,7 +452,7 @@ class Rstest implements vscode.Disposable { createTestRun: () => createTestRun( new vscode.TestRunRequest( - request.include, + include, request.exclude, request.profile, request.continuous, @@ -481,7 +506,7 @@ class Rstest implements vscode.Disposable { }; try { - if (!request.include?.length) { + if (!include?.length) { if (this.workspaces.size === 1) { const workspace = this.workspaces.values().next().value!; if (workspace.activeProjects.size === 1) { @@ -493,9 +518,7 @@ class Rstest implements vscode.Disposable { } } } - await discoverTests( - request.include ?? gatherTestItems(this.ctrl.items, false), - ); + await discoverTests(include ?? gatherTestItems(this.ctrl.items, false)); } catch (error) { logUnlessReported('Error running tests:', error); } finally { diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 00a98af..3a2ea01 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -153,7 +153,7 @@ export class RstestApi { */ private cwd: string, private configFilePath: string, - private project: Project, + public readonly project: Project, /** * Where the default `@rstest/core` (and CLI bin) walk-up starts. Chosen * by `Project` — see `ProjectSource.rstestResolutionDir`; an explicit diff --git a/packages/vscode/src/stacks/test/runRouting.ts b/packages/vscode/src/stacks/test/runRouting.ts new file mode 100644 index 0000000..f523d41 --- /dev/null +++ b/packages/vscode/src/stacks/test/runRouting.ts @@ -0,0 +1,27 @@ +/** Prefer the deepest published project for the same file or case selection. */ +export function routeToOwners( + items: readonly T[], + resolve: (item: T) => { key: string; root: string } | undefined, +): { kept: T[]; dropped: T[] } { + const deepestRoots = new Map(); + const entries = items.map((item) => { + const target = resolve(item); + if (target) { + deepestRoots.set( + target.key, + Math.max(deepestRoots.get(target.key) ?? 0, target.root.length), + ); + } + return { item, target }; + }); + const kept: T[] = []; + const dropped: T[] = []; + for (const { item, target } of entries) { + if (!target || target.root.length === deepestRoots.get(target.key)) { + kept.push(item); + } else { + dropped.push(item); + } + } + return { kept, dropped }; +} diff --git a/packages/vscode/tests/stacks/test/runRouting.test.ts b/packages/vscode/tests/stacks/test/runRouting.test.ts new file mode 100644 index 0000000..a53657e --- /dev/null +++ b/packages/vscode/tests/stacks/test/runRouting.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@rstest/core'; +import { routeToOwners } from '../../../src/stacks/test/runRouting'; + +type Item = { + kind: 'file' | 'case' | 'project' | 'folder'; + uri: string; + root: string; + namePath?: string[]; + type?: 'suite' | 'case'; +}; +const resolve = (item: Item) => + item.kind === 'file' || item.kind === 'case' + ? { + key: + item.kind === 'case' + ? `${item.uri}#${item.type}:${JSON.stringify(item.namePath)}` + : item.uri, + root: item.root, + } + : undefined; +const rootFile: Item = { + kind: 'file', + uri: 'file:///repo/host/a.test.ts', + root: '/repo', +}; +const hostFile: Item = { ...rootFile, root: '/repo/host' }; +const rootCase: Item = { + ...rootFile, + kind: 'case', + type: 'case', + namePath: ['suite', 'A'], +}; +const hostCase: Item = { + ...hostFile, + kind: 'case', + type: 'case', + namePath: ['suite', 'A'], +}; + +describe('routeToOwners', () => { + it('keeps distinct cases from different projects but routes the same case to its owner', () => { + const hostCaseB: Item = { ...hostCase, namePath: ['suite', 'B'] }; + expect(routeToOwners([rootCase, hostCaseB], resolve)).toEqual({ + kept: [rootCase, hostCaseB], + dropped: [], + }); + expect(routeToOwners([rootCase, hostCase], resolve)).toEqual({ + kept: [hostCase], + dropped: [rootCase], + }); + const hostSuite: Item = { ...hostCase, type: 'suite' }; + expect(routeToOwners([rootCase, hostSuite], resolve)).toEqual({ + kept: [rootCase, hostSuite], + dropped: [], + }); + }); + + it('keeps an explicit non-owner file or case selection', () => { + expect(routeToOwners([rootFile, rootCase], resolve)).toEqual({ + kept: [rootFile, rootCase], + dropped: [], + }); + }); + + it('preserves unrelated items and order while routing mixed file and case items', () => { + const project: Item = { ...rootFile, kind: 'project' }; + const folder: Item = { ...hostFile, kind: 'folder' }; + const unrelated: Item = { ...rootFile, uri: 'file:///repo/other.test.ts' }; + const tied = { ...hostFile }; + const items = [ + project, + rootCase, + unrelated, + hostFile, + folder, + rootFile, + tied, + hostCase, + ]; + expect(routeToOwners(items, resolve)).toEqual({ + kept: [project, unrelated, hostFile, folder, tied, hostCase], + dropped: [rootCase, rootFile], + }); + }); +});