Skip to content
Closed
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
15 changes: 8 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ jobs:
- name: Unit Test
run: pnpm run test:unit

# ======== E2E: Windows + macOS, no Linux ========
# These are the platforms the extension is actually used on, and the only
# ones upstream rstest runs its ported VS Code suites on. Linux is excluded
# deliberately: the Extension Host needs xvfb there, and inotify reports a
# non-atomic file rewrite as separate truncate/write events, so fixture edits
# in the watch-mode suites race the watcher in a way no user hits. Do not add
# a Linux E2E job back without also making every fixture edit atomic.
# ======== E2E: Windows + macOS ========
# Windows and macOS are the platforms users run the extension on. Linux E2E
# is deliberately not run here: rstack-ecosystem-ci runs the suites on
# ubuntu-latest (rstackjs/rstack-editor#40), and for that `e2e/run.mjs`
# re-execs under `xvfb-run` when DISPLAY is unset. The old concern that
# non-atomic fixture writes race inotify on Linux was tested and did not
# reproduce (rstackjs/rstack-editor#67), so the exclusion is about CI cost,
# not correctness.
e2e:
name: E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ packages/vscode/e2e/fixtures/*/pnpm-lock.yaml
packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml
packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml
packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml
packages/vscode/e2e/**/.toolchain/

# Build-time copy of the workspace root LICENSE (see rslib.config.mts)
packages/vscode/LICENSE
Expand Down
2 changes: 2 additions & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
- E2E fixtures install exact versions of published npm packages (not workspace links): the extension must work against what users actually install. Renovate updates the exact toolchain pins; generated fixture lockfiles and `node_modules` remain disposable and uncommitted.
- 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.
- Run E2E locally as `VSCODE_CLI=1 pnpm test:e2e <slice ...>`. Without it, the launched VS Code overwrites the extension host's `PATH` with a login-shell snapshot; on a machine whose login-shell `node` is below the runtime floor, the User Node preflight (correctly) refuses and every fmt test times out waiting for a server. CI is unaffected — its PATH `node` is new enough either way. The heavier alternative, `--force-disable-user-env` in `e2e/runTest.ts`, was deliberately not taken: it would change env fidelity for every slice.
- Linux E2E runs only in rstack-ecosystem-ci, which relies on `e2e/run.mjs` re-execing itself under `xvfb-run -a` when `DISPLAY` is unset (the whole chain runs against one X server, so `pnpm test:e2e` is the same command everywhere; `xvfb` is a prerequisite on Linux). This repo's CI runs E2E on Windows and macOS only, so the Linux path is not exercised here.
- `RSTACK_E2E_TOOLCHAIN_OVERRIDES="<name>=<package dir>,…"` packs each package into `<fixture>/.toolchain/` and adds a `file:` override to every fixture's `pnpm-workspace.yaml`, so rstack-ecosystem-ci can run the suites against unreleased builds; setup fails if the lockfile still holds a registry version. The yaml is not restored — the env is for disposable checkouts; locally, `git checkout -- packages/vscode/e2e`. Rationale in the `setupFixtures.mjs` header.
27 changes: 27 additions & 0 deletions packages/vscode/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
// up-to-date fixture, and unknown names throw there), then the entries run
// sequentially — each `compile`d entry launches its own VS Code via
// `@vscode/test-electron`.
//
// On Linux that VS Code needs an X display, so this file re-execs itself under
// `xvfb-run` when there is none — running the whole chain, not just the
// entries, under one server.
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -65,6 +69,29 @@ const packageRoot = path.resolve(
'..',
);

// The Extension Host needs an X display. `xvfb-run` exports DISPLAY into the
// child, so the re-exec'd process fails this check — it cannot recurse.
if (process.platform === 'linux' && !process.env.DISPLAY) {
const result = spawnSync(
'xvfb-run',
[
'-a',
process.execPath,
fileURLToPath(import.meta.url),
...process.argv.slice(2),
],
{ cwd: packageRoot, stdio: 'inherit' },
);
if (result.error) {
if (/** @type {NodeJS.ErrnoException} */ (result.error).code === 'ENOENT') {
console.error('[e2e] xvfb-run not found; install the xvfb package');
process.exit(1);
}
throw result.error;
}
process.exit(result.status ?? 1);
}

/**
* @param {string} command
* @param {string[]} args
Expand Down
129 changes: 111 additions & 18 deletions packages/vscode/e2e/setupFixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// The fixtures install **exact published npm versions** of
// `@rslint/core` / `@rstest/core` / `rstack` — the extension resolves all three
// from the project, so a fixture that linked this repo's own node_modules would
// test nothing. `rstack@0.7.2` itself pins `@rslint/core@0.9.0` exactly, so the
// test nothing. `rstack@0.8.2` itself pins `@rslint/core@0.9.5` exactly, so the
// Rstack fixture pins its lint core transitively. Each fixture is its own
// independent install. Exact toolchain pins make installs reproducible
// without committed lockfiles, and Renovate bumps those pins.
Expand All @@ -22,8 +22,21 @@
//
// Idempotent: pnpm is a no-op when the fixture is already up to date, so
// `test:e2e` can always run it.
//
// `RSTACK_E2E_TOOLCHAIN_OVERRIDES="@rstest/core=/abs/rstest/packages/core,…"`
// installs unreleased builds instead, for rstack-ecosystem-ci (#40). Its root
// `pnpm-workspace.yaml` override cannot reach the fixtures, which are their own
// workspace roots, so each package is packed into `<fixture>/.toolchain/` and
// the fixture yaml gets a relative `file:` override: the dependency-recovery
// suites copy a fixture to tmpdir and reinstall with `--frozen-lockfile`, and
// pnpm records `file:` relative to the lockfile. After install the lockfile
// must resolve each overridden package through the tarball, never the
// registry: build and pin can share a version number, which proves nothing.
// The yaml is changed in place and never restored: the env is meant for
// disposable checkouts (ecosystem CI); locally,
// `git checkout -- packages/vscode/e2e` undoes it.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

Expand Down Expand Up @@ -69,19 +82,86 @@ export const FIXTURE_NAMES = Object.keys(FIXTURES);

const pnpmCommand = 'pnpm';

/** `name=<absolute package dir>` entries, comma-separated; unset → none. */
const overrides = (process.env.RSTACK_E2E_TOOLCHAIN_OVERRIDES ?? '')
.split(',')
.filter((entry) => entry.trim())
.map((entry) => {
const [pkg = '', dir = ''] = entry
.split(/=(.*)/)
.map((part) => part.trim());
// Without this, a missing `=` packs `packages/vscode` itself.
if (
!pkg ||
!path.isAbsolute(dir) ||
!existsSync(path.join(dir, 'package.json'))
) {
throw new Error(
`RSTACK_E2E_TOOLCHAIN_OVERRIDES: expected name=<absolute package dir>, got ${entry}`,
);
}
return { pkg, dir, file: `${pkg.replace(/^@/, '').replace('/', '-')}.tgz` };
});

/**
* @param {string[]} args
* @param {string} cwd
*/
const pnpm = (args, cwd) => {
const result = spawnSync(pnpmCommand, args, {
cwd,
stdio: 'inherit',
env: process.env,
// On Windows, pnpm is a .cmd shim, and Node refuses to spawn batch
// files without a shell (CVE-2024-27980 hardening) — EINVAL otherwise.
shell: process.platform === 'win32',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`pnpm ${args[0]} failed in ${cwd} (exit code ${String(result.status)})`,
);
}
};

/** @param {string} cwd */
const injectOverrides = (cwd) => {
const yamlPath = path.join(cwd, 'pnpm-workspace.yaml');
// A second run in the same checkout must not append a duplicate key.
if (readFileSync(yamlPath, 'utf8').includes('.toolchain/')) {
return;
}
mkdirSync(path.join(cwd, '.toolchain'), { recursive: true });
const lines = ['# RSTACK_E2E_TOOLCHAIN_OVERRIDES: .toolchain/', 'overrides:'];
for (const { pkg, dir, file } of overrides) {
pnpm(['pack', '--out', path.join(cwd, '.toolchain', file)], dir);
lines.push(` '${pkg}': 'file:.toolchain/${file}'`);
}
lines.push('');
appendFileSync(yamlPath, `\n${lines.join('\n')}`);
};

// Fixtures that do not depend on an overridden package are legitimate no-ops;
// only a run where no fixture used any override is an error.
let resolvedThroughTarball = 0;

/** @param {string} name */
const install = (name) => {
const cwd = FIXTURES[name];
if (!existsSync(path.join(cwd, 'package.json'))) {
throw new Error(`E2E fixture ${name} has no package.json at ${cwd}`);
}
if (overrides.length > 0) {
injectOverrides(cwd);
}
console.log(`[e2e] installing fixture: ${name}`);
// Keep pnpm's default isolated layout. In the rstack fixture the tool cores
// are transitive dependencies beside rstack in the virtual store, matching
// the layout users get rather than masking resolution bugs with public
// hoisting.
const result = spawnSync(
pnpmCommand,
pnpm(
[
'install',
// Fixtures pin exact toolchain versions rather than committing lockfiles;
Expand All @@ -98,21 +178,29 @@ const install = (name) => {
// build scripts as-is.
'--config.dangerouslyAllowAllBuilds=true',
],
{
cwd,
stdio: 'inherit',
env: process.env,
// On Windows, pnpm is a .cmd shim, and Node refuses to spawn batch
// files without a shell (CVE-2024-27980 hardening) — EINVAL otherwise.
shell: process.platform === 'win32',
},
cwd,
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`pnpm install failed for the ${name} fixture (exit code ${String(result.status)})`,
// A registry resolution is `name@1.2.3`, the tarball `name@file:…`; the
// lockfile also covers transitive ones (the rstack fixture's @rstest/core).
const lockfile =
overrides.length > 0
? readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8')
: '';
for (const { pkg } of overrides) {
const escaped = pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
// The lookbehind keeps `rstack` from matching `create-rstack@1.0.0`.
if (new RegExp(`(?<![\\w@/.-])${escaped}@\\d`).test(lockfile)) {
throw new Error(
`\`${pkg}\` still resolves to a registry version in the ${name} fixture`,
);
}
if (!lockfile.includes(`${pkg}@file:`)) {
console.log(`[e2e] ${name}: ${pkg} not in graph, override unused`);
continue;
}
resolvedThroughTarball += 1;
console.log(
`[e2e] ${name}: ${pkg} resolved through .toolchain (no registry version in pnpm-lock.yaml)`,
);
}
};
Expand All @@ -128,6 +216,11 @@ const main = () => {
}
install(name);
}
if (overrides.length > 0 && resolvedThroughTarball === 0) {
throw new Error(
`RSTACK_E2E_TOOLCHAIN_OVERRIDES: no fixture in this run resolved ${overrides.map(({ pkg }) => pkg).join(', ')} through a tarball; the override did nothing`,
);
}
};

main();
Loading