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
9 changes: 9 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev
- **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.
- **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.

## Tools and configs

- **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.
- **Native config** — the config file an atomic tool reads by itself (`rstest.config.*`, `rslint.config.*`). _Avoid_: tool config, own config.
- **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.
- **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.
- **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.
- **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.

## fmt

- **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.
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- 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.
- 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.
- Prefer running the E2E slice that covers the change (`test:e2e:*` scripts; `RSTACK_LINT_E2E_SUITES=<name,...>` filters lint suites) over the full chain.
- 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.
9 changes: 0 additions & 9 deletions packages/vscode/e2e/fixtures/rstack/.npmrc

This file was deleted.

1 change: 1 addition & 0 deletions packages/vscode/e2e/fixtures/rstack/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26
2 changes: 1 addition & 1 deletion packages/vscode/e2e/fixtures/rstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights the Rstest and rs fmt stacks.",
"dependencies": {
"rstack": "^0.3.5"
"rstack": "0.5.0-alpha.1"
},
"devDependencies": {
"jiti": "^2.0.0"
Expand Down
20 changes: 14 additions & 6 deletions packages/vscode/e2e/rstest/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { runTests } from '@vscode/test-electron';

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

async function main() {
// `__dirname` is `<repo>/tests-dist/e2e/rstest` (see tsconfig.e2e.json).
Expand All @@ -41,12 +51,10 @@ async function main() {
'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e:rstest`.',
);
}
// workspace-2 has no node_modules of its own per project; the root install
// serves both nested projects, so the root is what the guard probes.
for (const name of FIXTURE_DIRS) {
if (!existsSync(path.join(fixturesRoot, name, 'node_modules'))) {
for (const dir of FIXTURE_DIRS) {
if (!existsSync(path.join(extensionDevelopmentPath, dir, 'node_modules'))) {
throw new Error(
`the rstest/${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`,
`the ${dir} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`,
);
}
}
Expand Down
157 changes: 157 additions & 0 deletions packages/vscode/e2e/rstest/suite/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// NOT ported from upstream — upstream's extension predates the rstack bridge.
// This suite covers the bridged-project path end to end: a folder whose only
// test signal is `rstack.config.ts` (the `e2e/fixtures/rstack` fixture, shared
// with the `vscode` slice) must get a synthesized project driven
// through rstack's shipped shim, show the same node-less tree a native root
// config gets, and actually run its tests through the worker.
//
// The fixture folder is added as a second workspace folder and removed again
// in teardown: `suite/index.ts` collects `*.test.js` sorted, so this suite runs
// *first*, and every suite after it (`index`, `progress`, ...) asserts on the
// unwrapped single-folder tree the run starts with.
//
// Adding a folder also flips the tree into its wrapped layout, so the probes
// below re-resolve the live controller through `currentRstestExports()` — a
// detection change can deregister and re-register the stack, which publishes a
// fresh `TestController` (same reason as `workspace.test.ts`).
import assert from 'node:assert';
import path from 'node:path';
import vscode from 'vscode';
import {
createCollectingMockRun,
currentRstestExports,
FIXTURES_ROOT,
getRstestExports,
getTestItemByLabels,
toLabelTree,
waitFor,
} from './helpers';

/** `<repo>/e2e/fixtures/rstack` — the rstack-cli fixture, no tool-native config. */
const RSTACK_FIXTURE = path.resolve(FIXTURES_ROOT, '../../fixtures/rstack');
const RSTACK_FIXTURE_URI = vscode.Uri.file(RSTACK_FIXTURE);

const WORKSPACE_1_FILES = [
{ label: 'each.test.ts' },
{ label: 'foo.test.ts' },
{ label: 'index.test.ts' },
{ label: 'jsFile.spec.js' },
{ label: 'jsxFile.test.jsx' },
{ label: 'progress.test.ts' },
{ label: 'tsxFile.test.tsx' },
];

suite('Rstack bridge suite', () => {
suiteSetup(async () => {
await getRstestExports();
const added = vscode.workspace.updateWorkspaceFolders(
vscode.workspace.workspaceFolders?.length || 0,
0,
{ uri: RSTACK_FIXTURE_URI },
);
assert.ok(added, 'adding the rstack fixture folder should be accepted');
});

suiteTeardown(async () => {
// Compare `uri.toString()`, not `fsPath`: `fsPath` lower-cases the Windows
// drive letter while `path.resolve` keeps it as-is, so a raw string
// compare can miss on Windows — and a missed removal here would leak the
// folder into every later suite.
const index = vscode.workspace.workspaceFolders?.findIndex(
(folder) => folder.uri.toString() === RSTACK_FIXTURE_URI.toString(),
);
assert.ok(index !== undefined && index >= 0);
const removed = vscode.workspace.updateWorkspaceFolders(index, 1);
assert.ok(removed, 'removing the rstack fixture folder should be accepted');
// Later suites assert on the unwrapped single-folder tree; leave only
// after the controller has actually settled back into it.
await waitFor(() => {
const testController = currentRstestExports().testController;
assert.deepStrictEqual(toLabelTree(testController.items, true), [
{ label: 'test', children: WORKSPACE_1_FILES },
]);
});
});

test('discovers a bridged project from rstack.config.ts alone', async () => {
// Two detected folders → both wrapped in workspace nodes. The rstack
// folder holds a single bridged project whose source config sits at the
// folder root under a default name, so it gets the node-less layout —
// structurally identical to workspace-1's native root config. This is the
// first suite this slice runs in a cold Electron, so the probe pays
// workspace-1's discovery AND the bridged project's first worker spawn
// (User Node, shim + `loadRstackConfig()`, Rstest/Rspack init) — hence
// the extended budget (the mocha timeout is 120s).
await waitFor(
() => {
const testController = currentRstestExports().testController;
assert.deepStrictEqual(toLabelTree(testController.items, true), [
{
label: 'rstack',
children: [
{
label: 'tests',
children: [{ label: 'basic.test.ts' }],
},
],
},
{
label: 'workspace-1',
children: [{ label: 'test', children: WORKSPACE_1_FILES }],
},
]);
},
{ timeoutMs: 60_000 },
);

// Test-case level (AST collection) inside the bridged project.
await waitFor(() => {
const testController = currentRstestExports().testController;
const file = getTestItemByLabels(testController.items, [
'rstack',
'tests',
'basic.test.ts',
]);
assert.deepStrictEqual(toLabelTree(file.children), [
{ label: 'trims a string' },
]);
});
});

test('runs bridged tests through the rstack config shim', async () => {
const collecting = createCollectingMockRun();

// Resolve the exports and the item together: holding an instance from
// before the poll would keep a controller a re-registration had replaced.
const { rstestInstance, item } = await waitFor(() => {
const rstestInstance = currentRstestExports();
return {
rstestInstance,
item: getTestItemByLabels(rstestInstance.testController.items, [
'rstack',
'tests',
'basic.test.ts',
]),
};
});

rstestInstance.startTestRun(
new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile),
new vscode.CancellationTokenSource().token,
false,
collecting.createMockRun,
);
await collecting.ended;

assert.equal(collecting.failedMessages.length, 0);
// A file requested as a whole reports twice: the case itself
// (`onTestCaseResult`) and the file item, which only goes green when the
// whole file passed (`onTestFileResult`). `progress.test.ts` never sees the
// second one — its file always has failures.
assert.deepStrictEqual(
collecting.passedItems.map((passed) => passed.label).sort(),
['basic.test.ts', 'trims a string'],
);
assert.match(collecting.output, /1 passed/);
});
});
53 changes: 53 additions & 0 deletions packages/vscode/e2e/rstest/suite/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,56 @@ export function toLabelTree(
nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
return nodes;
}

/**
* A collecting `vscode.TestRun` double for suites that only need "the run
* ended — what passed, what failed, what was printed". Repo-local, not
* ported: `progress.test.ts` keeps its own hand-rolled copy because it
* resets the captures per `createMockRun` call and counts invocations,
* which this deliberately does not do.
*/
export function createCollectingMockRun() {
const deferred = Promise.withResolvers<null>();
let output = '';
const failedMessages: vscode.TestMessage[] = [];
const passedItems: vscode.TestItem[] = [];
const skippedItems: vscode.TestItem[] = [];

const createMockRun = (): vscode.TestRun => ({
isPersisted: true,
name: '',
token: new vscode.CancellationTokenSource().token,
onDidDispose: new vscode.EventEmitter<void>().event,
addCoverage: () => {},
appendOutput: (message) => {
output += message;
},
end: () => {
deferred.resolve(null);
},
enqueued: () => {},
errored: () => {},
failed: (_test, message = []) => {
failedMessages.push(...(message as vscode.TestMessage[]));
},
passed: (test) => {
passedItems.push(test);
},
skipped: (test) => {
skippedItems.push(test);
},
started: () => {},
});

return {
createMockRun,
/** Resolves when the run calls `end()`. */
ended: deferred.promise,
get output() {
return output;
},
failedMessages,
passedItems,
skippedItems,
};
}
Loading