From f8e65bb48fd37f69d7bb96823e3e97f581e2e32a Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 5 Nov 2026 15:09:08 +0800 Subject: [PATCH] fix(vscode): accept rstest 0.13 reporter statuses alongside pre-0.13 rstest#1875 renames the reporter status literals (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. Route every status read through one compat module, keep consumers on the 0.13 vocabulary, and tag the call sites COMPAT(rstest<0.13) so dropping pre-0.13 support is a delete plus a grep. --- packages/vscode/AGENTS.md | 5 + .../src/stacks/test/compat/rstestPre013.ts | 93 +++++++++++ .../vscode/src/stacks/test/testRunReporter.ts | 25 ++- .../vscode/src/stacks/test/worker/index.ts | 12 +- .../vscode/tests/stacks/test/master.test.ts | 61 +++---- .../tests/stacks/test/rstestPre013.test.ts | 49 ++++++ .../tests/stacks/test/testRunReporter.test.ts | 152 ++++++++++++++++++ 7 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 packages/vscode/src/stacks/test/compat/rstestPre013.ts create mode 100644 packages/vscode/tests/stacks/test/rstestPre013.test.ts create mode 100644 packages/vscode/tests/stacks/test/testRunReporter.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 5d4321b2..23f7c6ee 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 00000000..a470b27d --- /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 5427f657..d2a54d02 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 45f9bcd7..f397fb7b 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 26334fbf..84871c90 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 00000000..18d0a443 --- /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 00000000..1eb7701a --- /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(); + }); +});