Skip to content

Commit e37916d

Browse files
authored
test(vscode): cover the rstack config bridge end to end (#9)
* 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. * fix(vscode): confine the E2E runner shell to the pnpm spawn With shell: true Node concatenates command and args unescaped, so spawning process.execPath or an entry path containing spaces through the shared option would break argument boundaries on Windows. Only the pnpm .cmd shim needs a shell (CVE-2024-27980 hardening); node spawns now run shell-less. Raised by PR review.
1 parent 4f520e0 commit e37916d

11 files changed

Lines changed: 370 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+
}

0 commit comments

Comments
 (0)