diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 5d4321b..23f7c6e 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -29,6 +29,11 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 13. **SWC Next parser** (test) — static AST collection uses `@swc-next/parser` instead of upstream's `yuku-parser`. Preserve the parser migration and target-specific native binding packaging when syncing the test stack. +## Active compatibility shims + +- **rstest <0.13 status vocabulary** — `stacks/test/compat/rstestPre013.ts`. rstest 0.13 (`web-infra-dev/rstest#1875`, commit `ffc4eb29`) renamed the reporter statuses (`pass|fail|skip` → `passed|failed|skipped`; run status `pass|fail` → `passed|failed`). The extension resolves `@rstest/core` from the user's project, so both lines run against one build. That module is the only place that knows the legacy names; consumers speak only the 0.13 vocabulary and every call site carries the exact tag `COMPAT(rstest<0.13)`. + - **To drop pre-0.13 support**: delete the module and `tests/stacks/test/rstestPre013.test.ts`; remove every `COMPAT(rstest<0.13)` site (`grep -rn "COMPAT(rstest<0.13)" packages/vscode`); bump the `@rstest/core` devDependency and the E2E fixture pins to 0.13; raise `SUPPORT_MATRIX['@rstest/core']` in `src/shared/versionCheck.ts` and its test. + ## 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. diff --git a/packages/vscode/src/stacks/test/compat/rstestPre013.ts b/packages/vscode/src/stacks/test/compat/rstestPre013.ts new file mode 100644 index 0000000..a470b27 --- /dev/null +++ b/packages/vscode/src/stacks/test/compat/rstestPre013.ts @@ -0,0 +1,93 @@ +/** + * COMPAT(rstest<0.13): the only module that knows the pre-0.13 reporter status + * vocabulary. + * + * rstest changed the reporter status literals in the 0.13 line + * (web-infra-dev/rstest#1875, commit `ffc4eb29`): + * + * TestResultStatus pass | fail | skip -> passed | failed | skipped + * TestRunStatus pass | fail -> passed | failed + * + * `todo` and `error` are unchanged. This extension resolves `@rstest/core` + * from the user's project (AGENTS.md, resolve-from-project), so projects on + * either line run against the same bundled code. Every read of a status + * literal goes through the normalizers below; every call site carries a + * `COMPAT(rstest<0.13)` tag. + * + * The canonical (0.13) shapes are declared here rather than in a separate + * module on purpose: no consumer imports them — consumers only call the + * normalizers — so deleting this file changes zero imports. + * + * Unknown values normalize to `undefined` instead of falling back to a guess. + * Each consumer's `default:` branch reports them, so a future vocabulary change + * surfaces as one logged "unhandled status" instead of a half-working switch. + * + * ## Removing this shim (when the pre-0.13 line is dropped) + * + * 1. Delete this file and `tests/stacks/test/rstestPre013.test.ts`. + * 2. Remove every call site tagged `COMPAT(rstest<0.13)`: + * `grep -rn "COMPAT(rstest<0.13)" packages/vscode` + * In `testRunReporter.ts` the two `switch`es then read `test.status` / + * `result.status` directly. In `worker/index.ts` drop `runStatus` and the + * `undefined`-warn branch, and compare `result.status` directly against + * `'error'` / `'failed'`. + * 3. Delete the legacy-vocabulary fixtures and the typed-fixture cast tagged + * `COMPAT(rstest<0.13)` in `tests/stacks/test/testRunReporter.test.ts` and + * `tests/stacks/test/master.test.ts`. + * 4. Bump the `@rstest/core` devDependency and the E2E fixture pins to the + * 0.13 line, then raise `SUPPORT_MATRIX['@rstest/core']` in + * `src/shared/versionCheck.ts` and its test. + */ + +/** The 0.13 (latest) `TestResultStatus`. */ +export type TestResultStatus = 'passed' | 'failed' | 'skipped' | 'todo'; + +/** The 0.13 (latest) `TestRunStatus`. */ +export type TestRunStatus = 'passed' | 'failed' | 'error'; + +type CanonicalStatus = TestResultStatus | TestRunStatus; + +/** The one mapping both vocabularies share; the wrappers below narrow it. */ +const canonicalStatus = (status: string): CanonicalStatus | undefined => { + switch (status) { + case 'passed': + case 'pass': + return 'passed'; + case 'failed': + case 'fail': + return 'failed'; + case 'skipped': + case 'skip': + return 'skipped'; + case 'todo': + return 'todo'; + case 'error': + return 'error'; + default: + return undefined; + } +}; + +/** + * Maps a reported test/suite/file status onto the latest vocabulary. + * `undefined` means the status is from neither vocabulary. + */ +export const normalizeTestResultStatus = ( + status: string, +): TestResultStatus | undefined => { + const canonical = canonicalStatus(status); + return canonical === 'error' ? undefined : canonical; +}; + +/** + * Maps a `run()` / `onTestRunEnd` status onto the latest vocabulary. + * `undefined` means the status is from neither vocabulary. + */ +export const normalizeTestRunStatus = ( + status: string, +): TestRunStatus | undefined => { + const canonical = canonicalStatus(status); + return canonical === 'skipped' || canonical === 'todo' + ? undefined + : canonical; +}; diff --git a/packages/vscode/src/stacks/test/testRunReporter.ts b/packages/vscode/src/stacks/test/testRunReporter.ts index 5427f65..d2a54d0 100644 --- a/packages/vscode/src/stacks/test/testRunReporter.ts +++ b/packages/vscode/src/stacks/test/testRunReporter.ts @@ -7,6 +7,7 @@ import type { TestSuiteInfo, } from '@rstest/core'; import vscode from 'vscode'; +import { normalizeTestResultStatus } from './compat/rstestPre013'; import type { DiagnosticEntry, RstestDiagnostics } from './diagnostics'; import type { TestErrorStore } from './errorStore'; import { logger } from './logger'; @@ -125,17 +126,18 @@ export class TestRunReporter implements Reporter { )?.testItem; if (!fileItem) return; - switch (test.status) { + // COMPAT(rstest<0.13): remove normalizer when dropping pre-0.13 support + switch (normalizeTestResultStatus(test.status)) { case 'todo': - case 'skip': + case 'skipped': this.run?.skipped(fileItem); this.errorStore?.clear(fileItem); break; - case 'pass': + case 'passed': this.run?.passed(fileItem, test.duration); this.errorStore?.clear(fileItem); break; - case 'fail': { + case 'failed': { // When a file fails before any test case runs (a syntax/collection // error or a worker crash during collection), the errors live only on // the file result and no onTestCaseResult fires to surface or store @@ -155,6 +157,9 @@ export class TestRunReporter implements Reporter { } break; } + default: + // Neither vocabulary: surface it instead of reporting a wrong outcome. + logger.error('Unhandled test file status', test.status); } } @@ -189,21 +194,22 @@ export class TestRunReporter implements Reporter { return; } - switch (result.status) { - case 'pass': { + // COMPAT(rstest<0.13): remove normalizer when dropping pre-0.13 support + switch (normalizeTestResultStatus(result.status)) { + case 'passed': { this.run?.passed(testItem, result.duration); this.diagnostics?.clearForTest(this.projectKey, testItem); this.errorStore?.clear(testItem); break; } - case 'skip': + case 'skipped': case 'todo': { this.run?.skipped(testItem); this.diagnostics?.clearForTest(this.projectKey, testItem); this.errorStore?.clear(testItem); break; } - case 'fail': { + case 'failed': { const errors = await this.createErrors(result.errors, result.testPath); this.run?.failed(testItem, errors, result.duration); this.diagnostics?.setForTest( @@ -214,6 +220,9 @@ export class TestRunReporter implements Reporter { this.errorStore?.set(testItem, errors); break; } + default: + // Neither vocabulary: surface it instead of reporting a wrong outcome. + logger.error('Unhandled test status', result.status); } } diff --git a/packages/vscode/src/stacks/test/worker/index.ts b/packages/vscode/src/stacks/test/worker/index.ts index 45f9bcd..f397fb7 100644 --- a/packages/vscode/src/stacks/test/worker/index.ts +++ b/packages/vscode/src/stacks/test/worker/index.ts @@ -3,6 +3,7 @@ import { connect } from 'node:net'; import { createBirpc } from 'birpc'; import { missingDependencyCauseOf } from '../../../shared/missingDependency'; import { SUPPORT_MATRIX } from '../../../shared/versionCheck'; +import { normalizeTestRunStatus } from '../compat/rstestPre013'; import type { TestRunReporter } from '../testRunReporter'; import type { NormalizedConfigResult, WorkerInitOptions } from '../types'; import { retractForceColorIfDisabled } from '../shared/colorEnv'; @@ -151,13 +152,16 @@ export class Worker { } } const result = await rstest.run(runOptions); - if (result.status === 'error') { + // COMPAT(rstest<0.13): remove normalizer when dropping pre-0.13 support + // Normalize once, so every branch below reads the latest vocabulary. + const runStatus = normalizeTestRunStatus(result.status); + if (runStatus === 'error') { throw new Error( result.unhandledErrors.map((error) => error.message).join('\n\n'), ); } if ( - result.status === 'fail' && + runStatus === 'failed' && result.summary.tests.failed === 0 && result.summary.files.failed === 0 ) { @@ -165,6 +169,10 @@ export class Worker { 'Rstest run failed without test-level failures. Check for operation-level failures such as coverage report errors or unmet coverage thresholds.', ); } + // COMPAT(rstest<0.13): remove with the normalizer + if (runStatus === undefined) { + logger.warn('Unrecognized Rstest run status', result.status); + } logger.debug('Test run completed', { result }); } catch (error) { logger.error('Test run failed', error); diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 26334fb..84871c9 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -874,7 +874,7 @@ const createInFlightOneShotWorker = (shouldReject = false) => { await runFinished.promise; order.push('teardown'); if (shouldReject) throw new Error('test run failed'); - return { status: 'pass', unhandledErrors: [] }; + return { status: 'passed', unhandledErrors: [] }; }, }, }); @@ -1023,7 +1023,7 @@ describe('Rstest public API', () => { describe('Rstest public run lifecycle', () => { it('passes a selected test name to the run operation', async () => { const engineRun = rs.fn(async () => ({ - status: 'pass', + status: 'passed', unhandledErrors: [], })); const worker = new Worker(); @@ -1049,7 +1049,7 @@ describe('Rstest public API', () => { ])('forwards the $kind path without a filter mode', async ({ filter }) => { const api = createApi(); const engineRun = rs.fn(async () => ({ - status: 'pass', + status: 'passed', unhandledErrors: [], })); const coreWorker = new Worker(); @@ -1109,7 +1109,7 @@ describe('Rstest public API', () => { fileFilters: undefined, rstest: { run: async () => ({ - status: 'fail', + status: 'failed', summary: { tests: { failed: 1 }, files: { failed: 1 } }, unhandledErrors: [], }), @@ -1149,30 +1149,35 @@ describe('Rstest public API', () => { expect(order).toEqual(['teardown', 'kill']); }); - it('surfaces coverage failures without test-level failures', async () => { - const api = createApi(); - const coreWorker = new Worker(); - rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ - command: 'run', - fileFilters: undefined, - rstest: { - run: async () => ({ - status: 'fail', - summary: { tests: { failed: 0 }, files: { failed: 0 } }, - unhandledErrors: [], - }), - }, - }); - mockWorker(api, (data) => coreWorker.runTest(data)); - const { output, run, token } = createRunContext(); - await api.runTest({ run, token }); - expect(output.join('')).toContain( - 'Rstest run failed without test-level failures', - ); - expect(shownMessages[0]).toContain( - 'coverage report errors or unmet coverage thresholds', - ); - }); + // COMPAT(rstest<0.13): the `fail` row is the legacy vocabulary; delete it + // with the compat shim. + it.each(['failed', 'fail'])( + 'surfaces coverage failures without test-level failures (status: %s)', + async (status) => { + const api = createApi(); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: undefined, + rstest: { + run: async () => ({ + status, + summary: { tests: { failed: 0 }, files: { failed: 0 } }, + unhandledErrors: [], + }), + }, + }); + mockWorker(api, (data) => coreWorker.runTest(data)); + const { output, run, token } = createRunContext(); + await api.runTest({ run, token }); + expect(output.join('')).toContain( + 'Rstest run failed without test-level failures', + ); + expect(shownMessages[0]).toContain( + 'coverage report errors or unmet coverage thresholds', + ); + }, + ); it('finishes a rejected continuous run and surfaces its error', async () => { const api = createApi(); diff --git a/packages/vscode/tests/stacks/test/rstestPre013.test.ts b/packages/vscode/tests/stacks/test/rstestPre013.test.ts new file mode 100644 index 0000000..18d0a44 --- /dev/null +++ b/packages/vscode/tests/stacks/test/rstestPre013.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from '@rstest/core'; +import { + normalizeTestResultStatus, + normalizeTestRunStatus, +} from '../../../src/stacks/test/compat/rstestPre013'; + +// COMPAT(rstest<0.13): this file is deleted with the compat module. + +describe('normalizeTestResultStatus', () => { + it.each([ + // Latest vocabulary (rstest >= 0.13) passes through. + ['passed', 'passed'], + ['failed', 'failed'], + ['skipped', 'skipped'], + ['todo', 'todo'], + // Legacy vocabulary (rstest < 0.13). + ['pass', 'passed'], + ['fail', 'failed'], + ['skip', 'skipped'], + ])('maps %s to %s', (status, expected) => { + expect(normalizeTestResultStatus(status)).toBe(expected); + }); + + it.each(['pending', '', 'PASSED', 'unknown'])( + 'returns undefined for the unknown status %s', + (status) => { + expect(normalizeTestResultStatus(status)).toBeUndefined(); + }, + ); +}); + +describe('normalizeTestRunStatus', () => { + it.each([ + ['passed', 'passed'], + ['failed', 'failed'], + ['error', 'error'], + ['pass', 'passed'], + ['fail', 'failed'], + ])('maps %s to %s', (status, expected) => { + expect(normalizeTestRunStatus(status)).toBe(expected); + }); + + it.each(['skip', 'todo', 'skipped', 'unknown'])( + 'returns undefined for the non-run status %s', + (status) => { + expect(normalizeTestRunStatus(status)).toBeUndefined(); + }, + ); +}); diff --git a/packages/vscode/tests/stacks/test/testRunReporter.test.ts b/packages/vscode/tests/stacks/test/testRunReporter.test.ts new file mode 100644 index 0000000..1eb7701 --- /dev/null +++ b/packages/vscode/tests/stacks/test/testRunReporter.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import type { TestFileResult, TestResult } from '@rstest/core'; +import type vscode from 'vscode'; +import type { TestResultStatus } from '../../../src/stacks/test/compat/rstestPre013'; +import type { Project } from '../../../src/stacks/test/project'; +import { TestRunReporter } from '../../../src/stacks/test/testRunReporter'; + +rs.mock('vscode', () => { + const vscode = { + Uri: { + file: (fsPath: string) => ({ + fsPath, + toString: () => `file://${fsPath}`, + }), + }, + FileCoverage: class {}, + Location: class {}, + Position: class {}, + Range: class {}, + TestMessage: class {}, + }; + return { ...vscode, default: vscode }; +}); + +const TEST_PATH = '/workspace/adds.test.ts'; +const TEST_URI = `file://${TEST_PATH}`; + +const createRun = () => ({ + passed: rs.fn(), + failed: rs.fn(), + skipped: rs.fn(), +}); +type RunSpies = ReturnType; +type Outcome = Exclude; + +// Every row a reporting handler must route: the 0.13 vocabulary plus the +// pre-0.13 aliases that normalize onto it. +const STATUS_CASES = [ + ['passed', 'passed'], + ['failed', 'failed'], + ['skipped', 'skipped'], + ['todo', 'skipped'], + // COMPAT(rstest<0.13): legacy vocabulary rows, deleted with the shim. + ['pass', 'passed'], + ['fail', 'failed'], + ['skip', 'skipped'], +] as const; + +// COMPAT(rstest<0.13): the installed devDependency still types reporter +// statuses as pass/fail/skip, while these fixtures use the 0.13 vocabulary. +// One cast here instead of `as any` at every fixture; deleted with the shim. +const payload = (value: object): T => value as T; + +const fileResult = (status: string): TestFileResult => + payload({ + testId: `file:${TEST_PATH}`, + testPath: TEST_PATH, + name: 'adds.test.ts', + project: 'default', + status, + duration: 5, + results: [], + }); + +const caseResult = (status: string): TestResult => + payload({ + testId: 'case:adds', + testPath: TEST_PATH, + name: 'adds', + parentNames: [], + project: 'default', + status, + duration: 2, + }); + +const setup = () => { + const caseItem = { label: 'adds', children: new Map() }; + const fileItem = { + label: 'adds.test.ts', + children: new Map([['adds', caseItem]]), + }; + const project = payload({ + testFiles: new Map([[TEST_URI, { testItem: fileItem }]]), + }); + const run = createRun(); + const reporter = new TestRunReporter( + payload(run), + project, + [], + false, + ); + return { reporter, run, fileItem, caseItem }; +}; + +const expectOnly = (run: RunSpies, outcome: Outcome) => { + expect(run[outcome]).toHaveBeenCalledTimes(1); + for (const other of ['passed', 'failed', 'skipped'] as const) { + if (other !== outcome) expect(run[other]).not.toHaveBeenCalled(); + } +}; + +describe('TestRunReporter file results', () => { + it.each(STATUS_CASES)( + 'reports a %s file result as %s', + async (status, outcome) => { + const { reporter, run, fileItem } = setup(); + + await reporter.onTestFileResult(fileResult(status)); + + expectOnly(run, outcome); + if (outcome === 'failed') { + expect(run.failed).toHaveBeenCalledWith(fileItem, [], 5); + } else if (outcome === 'passed') { + expect(run.passed).toHaveBeenCalledWith(fileItem, 5); + } else { + expect(run.skipped).toHaveBeenCalledWith(fileItem); + } + }, + ); +}); + +describe('TestRunReporter case results', () => { + it.each(STATUS_CASES)( + 'reports a %s case result as %s', + async (status, outcome) => { + const { reporter, run, caseItem } = setup(); + + await reporter.onTestCaseResult(caseResult(status)); + + expectOnly(run, outcome); + if (outcome === 'skipped') { + expect(run.skipped).toHaveBeenCalledWith(caseItem); + } else if (outcome === 'failed') { + expect(run.failed).toHaveBeenCalledWith(caseItem, [], 2); + } else { + expect(run.passed).toHaveBeenCalledWith(caseItem, 2); + } + }, + ); +}); + +describe('TestRunReporter unknown status', () => { + it('does not report an outcome for a status from neither vocabulary', async () => { + const { reporter, run } = setup(); + + await reporter.onTestCaseResult(caseResult('pending')); + + expect(run.passed).not.toHaveBeenCalled(); + expect(run.failed).not.toHaveBeenCalled(); + expect(run.skipped).not.toHaveBeenCalled(); + }); +});