Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions packages/vscode/src/stacks/test/compat/rstestPre013.ts
Original file line number Diff line number Diff line change
@@ -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;
};
25 changes: 17 additions & 8 deletions packages/vscode/src/stacks/test/testRunReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
}

Expand Down
12 changes: 10 additions & 2 deletions packages/vscode/src/stacks/test/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -151,20 +152,27 @@ 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
) {
throw new Error(
'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);
Expand Down
61 changes: 33 additions & 28 deletions packages/vscode/tests/stacks/test/master.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
},
},
});
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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: [],
}),
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions packages/vscode/tests/stacks/test/rstestPre013.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
);
});
Loading
Loading