Skip to content

Commit b00aaf1

Browse files
committed
test(vscode): cover the rstack config bridge end to end
- Add e2e/rstest/suite/bridge.test.ts: a folder whose only test signal is rstack.config.ts gets a bridged project - explorer tree (node-less layout + AST test cases) and a real run through rstack's shipped shim. - Bump the rstack fixture to rstack@0.5.0-alpha.1 (newest published); move its public-hoist settings from the fixture .npmrc (no longer read by pnpm 11) to shared install flags in setupFixtures.mjs. - Replace the enumerated test:e2e:* script chain with e2e/run.mjs: a SLICES table declares each slice's fixtures and entry, package.json scripts become thin forwards, and tsc compiles first to fail fast. - Record the tools-and-configs vocabulary (atomic tool, Rstack config, shim, bridged project, ownership) in CONTEXT.md.
1 parent 4f520e0 commit b00aaf1

11 files changed

Lines changed: 362 additions & 26 deletions

File tree

CONTEXT.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev
1717
- **Preflight** — the check that picks a User Node runtime, run once per extension host before any worker is spawned. Its failure is a status, never a crash.
1818
- **Runtime floor** — the version range a User Node runtime must satisfy (`NODE_RUNTIME_RANGE` in `shared/versionCheck.ts`). A declared support contract, not a probed capability.
1919

20+
## Tools and configs
21+
22+
- **Atomic tool** — a single Rstack tool used standalone (Rstest, Rslint). Each atomic tool's CLI reads only its own native config and has no knowledge of the Rstack config. _Avoid_: standalone tool, raw tool.
23+
- **Native config** — the config file an atomic tool reads by itself (`rstest.config.*`, `rslint.config.*`). _Avoid_: tool config, own config.
24+
- **Rstack config** — the unified `rstack.config.*` file consumed by rstack-cli (`rs`), holding per-tool sections. Tools never read it themselves; `rs` hands each tool its section through a shim.
25+
- **Shim** — the module rstack-cli ships per tool that loads the Rstack config and exposes that tool's section through the tool's ordinary explicit-config channel. The extension points upstream machinery at the shim rather than re-implementing Rstack config semantics.
26+
- **Bridged project** — a test project the extension synthesizes for a directory whose test signal is a Rstack config, wired to the shim. _Avoid_: virtual project, rstack project.
27+
- **Ownership** — the editor-side rule assigning a directory to one tool when both a native config and a Rstack config are present there: the atomic tool's native config wins and the bridge yields. This rule exists only in the editor; upstream CLIs never face the choice, since each reads only its own config.
28+
2029
## fmt
2130

2231
- **Cold format** — a format request served by spawning a fresh `rs fmt` process at request time; the request pays the full process start-up cost.

packages/vscode/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
4343

4444
- E2E suites ported from upstream keep upstream's assertion semantics; every intentional deviation is documented in a comment in the test itself. A failing ported test is a regression, not a test to adjust.
4545
- E2E fixtures install published npm packages (not workspace links): the extension must work against what users actually install. Fixture `node_modules` are disposable and never committed.
46-
- Prefer running the E2E slice that covers the change (`test:e2e:*` scripts; `RSTACK_LINT_E2E_SUITES=<name,...>` filters lint suites) over the full chain.
46+
- Prefer running the E2E slice that covers the change over the full chain: `pnpm test:e2e <slice ...>` (or the `test:e2e:<slice>` aliases). Slices are declared in the `SLICES` table in `e2e/run.mjs` (name, fixtures, entry) — the package.json scripts are thin forwards and carry no slice knowledge. `RSTACK_LINT_E2E_SUITES=<name,...>` filters lint suites.

packages/vscode/e2e/fixtures/rstack/.npmrc

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
26

packages/vscode/e2e/fixtures/rstack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights the Rstest and rs fmt stacks.",
77
"dependencies": {
8-
"rstack": "^0.3.5"
8+
"rstack": "0.5.0-alpha.1"
99
},
1010
"devDependencies": {
1111
"jiti": "^2.0.0"

packages/vscode/e2e/rstest/runTest.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@ import { tmpdir } from 'node:os';
2323
import path from 'node:path';
2424
import { runTests } from '@vscode/test-electron';
2525

26-
const FIXTURE_DIRS = ['workspace-1', 'workspace-2'] as const;
26+
// Repo-relative fixture dirs whose installs this slice needs. workspace-2 has
27+
// no node_modules of its own per project; the root install serves both nested
28+
// projects, so the root is what the guard probes. `e2e/fixtures/rstack` is the
29+
// shared fixture `suite/bridge.test.ts` adds as a second workspace folder (the
30+
// same folder the `vscode` slice opens); the bridge resolves the rstack shim
31+
// from its install.
32+
const FIXTURE_DIRS = [
33+
'e2e/rstest/fixtures/workspace-1',
34+
'e2e/rstest/fixtures/workspace-2',
35+
'e2e/fixtures/rstack',
36+
] as const;
2737

2838
async function main() {
2939
// `__dirname` is `<repo>/tests-dist/e2e/rstest` (see tsconfig.e2e.json).
@@ -41,12 +51,10 @@ async function main() {
4151
'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e:rstest`.',
4252
);
4353
}
44-
// workspace-2 has no node_modules of its own per project; the root install
45-
// serves both nested projects, so the root is what the guard probes.
46-
for (const name of FIXTURE_DIRS) {
47-
if (!existsSync(path.join(fixturesRoot, name, 'node_modules'))) {
54+
for (const dir of FIXTURE_DIRS) {
55+
if (!existsSync(path.join(extensionDevelopmentPath, dir, 'node_modules'))) {
4856
throw new Error(
49-
`the rstest/${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`,
57+
`the ${dir} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`,
5058
);
5159
}
5260
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// NOT ported from upstream — upstream's extension predates the rstack bridge.
2+
// This suite covers the bridged-project path end to end: a folder whose only
3+
// test signal is `rstack.config.ts` (the `e2e/fixtures/rstack` fixture, shared
4+
// with the `vscode` slice) must get a synthesized project driven
5+
// through rstack's shipped shim, show the same node-less tree a native root
6+
// config gets, and actually run its tests through the worker.
7+
//
8+
// The fixture folder is added as a second workspace folder and removed again
9+
// in teardown: `suite/index.ts` collects `*.test.js` sorted, so this suite runs
10+
// *first*, and every suite after it (`index`, `progress`, ...) asserts on the
11+
// unwrapped single-folder tree the run starts with.
12+
//
13+
// Adding a folder also flips the tree into its wrapped layout, so the probes
14+
// below re-resolve the live controller through `currentRstestExports()` — a
15+
// detection change can deregister and re-register the stack, which publishes a
16+
// fresh `TestController` (same reason as `workspace.test.ts`).
17+
import assert from 'node:assert';
18+
import path from 'node:path';
19+
import vscode from 'vscode';
20+
import {
21+
createCollectingMockRun,
22+
currentRstestExports,
23+
FIXTURES_ROOT,
24+
getRstestExports,
25+
getTestItemByLabels,
26+
toLabelTree,
27+
waitFor,
28+
} from './helpers';
29+
30+
/** `<repo>/e2e/fixtures/rstack` — the rstack-cli fixture, no tool-native config. */
31+
const RSTACK_FIXTURE = path.resolve(FIXTURES_ROOT, '../../fixtures/rstack');
32+
const RSTACK_FIXTURE_URI = vscode.Uri.file(RSTACK_FIXTURE);
33+
34+
const WORKSPACE_1_FILES = [
35+
{ label: 'each.test.ts' },
36+
{ label: 'foo.test.ts' },
37+
{ label: 'index.test.ts' },
38+
{ label: 'jsFile.spec.js' },
39+
{ label: 'jsxFile.test.jsx' },
40+
{ label: 'progress.test.ts' },
41+
{ label: 'tsxFile.test.tsx' },
42+
];
43+
44+
suite('Rstack bridge suite', () => {
45+
suiteSetup(async () => {
46+
await getRstestExports();
47+
const added = vscode.workspace.updateWorkspaceFolders(
48+
vscode.workspace.workspaceFolders?.length || 0,
49+
0,
50+
{ uri: RSTACK_FIXTURE_URI },
51+
);
52+
assert.ok(added, 'adding the rstack fixture folder should be accepted');
53+
});
54+
55+
suiteTeardown(async () => {
56+
// Compare `uri.toString()`, not `fsPath`: `fsPath` lower-cases the Windows
57+
// drive letter while `path.resolve` keeps it as-is, so a raw string
58+
// compare can miss on Windows — and a missed removal here would leak the
59+
// folder into every later suite.
60+
const index = vscode.workspace.workspaceFolders?.findIndex(
61+
(folder) => folder.uri.toString() === RSTACK_FIXTURE_URI.toString(),
62+
);
63+
assert.ok(index !== undefined && index >= 0);
64+
const removed = vscode.workspace.updateWorkspaceFolders(index, 1);
65+
assert.ok(removed, 'removing the rstack fixture folder should be accepted');
66+
// Later suites assert on the unwrapped single-folder tree; leave only
67+
// after the controller has actually settled back into it.
68+
await waitFor(() => {
69+
const testController = currentRstestExports().testController;
70+
assert.deepStrictEqual(toLabelTree(testController.items, true), [
71+
{ label: 'test', children: WORKSPACE_1_FILES },
72+
]);
73+
});
74+
});
75+
76+
test('discovers a bridged project from rstack.config.ts alone', async () => {
77+
// Two detected folders → both wrapped in workspace nodes. The rstack
78+
// folder holds a single bridged project whose source config sits at the
79+
// folder root under a default name, so it gets the node-less layout —
80+
// structurally identical to workspace-1's native root config. This is the
81+
// first suite this slice runs in a cold Electron, so the probe pays
82+
// workspace-1's discovery AND the bridged project's first worker spawn
83+
// (User Node, shim + `loadRstackConfig()`, Rstest/Rspack init) — hence
84+
// the extended budget (the mocha timeout is 120s).
85+
await waitFor(
86+
() => {
87+
const testController = currentRstestExports().testController;
88+
assert.deepStrictEqual(toLabelTree(testController.items, true), [
89+
{
90+
label: 'rstack',
91+
children: [
92+
{
93+
label: 'tests',
94+
children: [{ label: 'basic.test.ts' }],
95+
},
96+
],
97+
},
98+
{
99+
label: 'workspace-1',
100+
children: [{ label: 'test', children: WORKSPACE_1_FILES }],
101+
},
102+
]);
103+
},
104+
{ timeoutMs: 60_000 },
105+
);
106+
107+
// Test-case level (AST collection) inside the bridged project.
108+
await waitFor(() => {
109+
const testController = currentRstestExports().testController;
110+
const file = getTestItemByLabels(testController.items, [
111+
'rstack',
112+
'tests',
113+
'basic.test.ts',
114+
]);
115+
assert.deepStrictEqual(toLabelTree(file.children), [
116+
{ label: 'trims a string' },
117+
]);
118+
});
119+
});
120+
121+
test('runs bridged tests through the rstack config shim', async () => {
122+
const collecting = createCollectingMockRun();
123+
124+
// Resolve the exports and the item together: holding an instance from
125+
// before the poll would keep a controller a re-registration had replaced.
126+
const { rstestInstance, item } = await waitFor(() => {
127+
const rstestInstance = currentRstestExports();
128+
return {
129+
rstestInstance,
130+
item: getTestItemByLabels(rstestInstance.testController.items, [
131+
'rstack',
132+
'tests',
133+
'basic.test.ts',
134+
]),
135+
};
136+
});
137+
138+
rstestInstance.startTestRun(
139+
new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile),
140+
new vscode.CancellationTokenSource().token,
141+
false,
142+
collecting.createMockRun,
143+
);
144+
await collecting.ended;
145+
146+
assert.equal(collecting.failedMessages.length, 0);
147+
// A file requested as a whole reports twice: the case itself
148+
// (`onTestCaseResult`) and the file item, which only goes green when the
149+
// whole file passed (`onTestFileResult`). `progress.test.ts` never sees the
150+
// second one — its file always has failures.
151+
assert.deepStrictEqual(
152+
collecting.passedItems.map((passed) => passed.label).sort(),
153+
['basic.test.ts', 'trims a string'],
154+
);
155+
assert.match(collecting.output, /1 passed/);
156+
});
157+
});

packages/vscode/e2e/rstest/suite/helpers.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,56 @@ export function toLabelTree(
145145
nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
146146
return nodes;
147147
}
148+
149+
/**
150+
* A collecting `vscode.TestRun` double for suites that only need "the run
151+
* ended — what passed, what failed, what was printed". Repo-local, not
152+
* ported: `progress.test.ts` keeps its own hand-rolled copy because it
153+
* resets the captures per `createMockRun` call and counts invocations,
154+
* which this deliberately does not do.
155+
*/
156+
export function createCollectingMockRun() {
157+
const deferred = Promise.withResolvers<null>();
158+
let output = '';
159+
const failedMessages: vscode.TestMessage[] = [];
160+
const passedItems: vscode.TestItem[] = [];
161+
const skippedItems: vscode.TestItem[] = [];
162+
163+
const createMockRun = (): vscode.TestRun => ({
164+
isPersisted: true,
165+
name: '',
166+
token: new vscode.CancellationTokenSource().token,
167+
onDidDispose: new vscode.EventEmitter<void>().event,
168+
addCoverage: () => {},
169+
appendOutput: (message) => {
170+
output += message;
171+
},
172+
end: () => {
173+
deferred.resolve(null);
174+
},
175+
enqueued: () => {},
176+
errored: () => {},
177+
failed: (_test, message = []) => {
178+
failedMessages.push(...(message as vscode.TestMessage[]));
179+
},
180+
passed: (test) => {
181+
passedItems.push(test);
182+
},
183+
skipped: (test) => {
184+
skippedItems.push(test);
185+
},
186+
started: () => {},
187+
});
188+
189+
return {
190+
createMockRun,
191+
/** Resolves when the run calls `end()`. */
192+
ended: deferred.promise,
193+
get output() {
194+
return output;
195+
},
196+
failedMessages,
197+
passedItems,
198+
skippedItems,
199+
};
200+
}

packages/vscode/e2e/run.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// The single E2E entry point: `node ./e2e/run.mjs [slice ...]` (via
2+
// `pnpm test:e2e [slice ...]`). No arguments runs every slice in table order.
3+
//
4+
// SLICES is the single source of truth for what E2E slices exist: which
5+
// fixtures each needs installed and which script runs it. The package.json
6+
// `test:e2e*` scripts are thin forwards to this file and carry none of that
7+
// knowledge. The shared `tsc -p tsconfig.e2e.json` pass runs first (the
8+
// cheap, likely-to-fail step), then the selected slices' fixtures install in
9+
// one `setupFixtures.mjs` invocation (idempotent — pnpm no-ops on an
10+
// up-to-date fixture, and unknown names throw there), then the entries run
11+
// sequentially — each `compile`d entry launches its own VS Code via
12+
// `@vscode/test-electron`.
13+
import { spawnSync } from 'node:child_process';
14+
import path from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
17+
/** @type {{name: string, fixtures: string[], entry: string, compile?: boolean}[]} */
18+
const SLICES = [
19+
{
20+
// A plain Node script — no VS Code, no TypeScript compile.
21+
name: 'smoke',
22+
fixtures: ['rslint'],
23+
entry: 'e2e/smoke/rslintPluginHost.mjs',
24+
},
25+
{
26+
// The shell/detection/fmt suites (`e2e/suite/`) over the multi-root
27+
// workspace of the three shared fixtures.
28+
name: 'vscode',
29+
fixtures: ['rslint', 'rstest', 'rstack'],
30+
entry: 'tests-dist/e2e/runTest.js',
31+
compile: true,
32+
},
33+
{
34+
// The ported Rstest suites, plus `suite/bridge.test.ts`, which adds the
35+
// shared `rstack` fixture as a second workspace folder.
36+
name: 'rstest',
37+
fixtures: ['rstest-workspace-1', 'rstest-workspace-2', 'rstack'],
38+
entry: 'tests-dist/e2e/rstest/runTest.js',
39+
compile: true,
40+
},
41+
{
42+
// The ported Rslint suites; `RSTACK_LINT_E2E_SUITES=<name,...>` filters
43+
// which of them run.
44+
name: 'lint',
45+
fixtures: ['lint'],
46+
entry: 'tests-dist/e2e/lint/runTest.js',
47+
compile: true,
48+
},
49+
];
50+
51+
const packageRoot = path.resolve(
52+
path.dirname(fileURLToPath(import.meta.url)),
53+
'..',
54+
);
55+
56+
/**
57+
* @param {string} command
58+
* @param {string[]} args
59+
*/
60+
const run = (command, args) => {
61+
const result = spawnSync(command, args, {
62+
cwd: packageRoot,
63+
stdio: 'inherit',
64+
env: process.env,
65+
// On Windows, pnpm is a .cmd shim, and Node refuses to spawn batch files
66+
// without a shell (CVE-2024-27980 hardening) — EINVAL otherwise.
67+
shell: process.platform === 'win32',
68+
});
69+
if (result.error) {
70+
throw result.error;
71+
}
72+
if (result.status !== 0) {
73+
process.exit(result.status ?? 1);
74+
}
75+
};
76+
77+
const known = SLICES.map((slice) => slice.name);
78+
const requested = process.argv.slice(2);
79+
for (const name of requested) {
80+
if (!known.includes(name)) {
81+
throw new Error(`unknown E2E slice: ${name} (known: ${known.join(', ')})`);
82+
}
83+
}
84+
const selected =
85+
requested.length > 0
86+
? SLICES.filter((slice) => requested.includes(slice.name))
87+
: SLICES;
88+
89+
if (selected.some((slice) => slice.compile)) {
90+
console.log('[e2e] compiling slices (tsc -p tsconfig.e2e.json)');
91+
run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.e2e.json']);
92+
}
93+
94+
run(process.execPath, [
95+
'e2e/setupFixtures.mjs',
96+
...new Set(selected.flatMap((slice) => slice.fixtures)),
97+
]);
98+
99+
for (const slice of selected) {
100+
console.log(`[e2e] running slice: ${slice.name}`);
101+
run(process.execPath, [path.join(packageRoot, slice.entry)]);
102+
}

0 commit comments

Comments
 (0)