From 6cf439b39217600b9ef463c8a8b7f960c20648db Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sat, 10 Oct 2026 16:46:09 +0800 Subject: [PATCH 1/2] ci: support running the E2E suites under xvfb on Linux On Linux without DISPLAY, e2e/run.mjs re-execs itself under `xvfb-run -a`, so `pnpm test:e2e` works unchanged on rstack-ecosystem-ci's ubuntu-latest runners (#40). This repo's CI stays Windows + macOS. --- .github/workflows/ci.yml | 15 ++++++++------- packages/vscode/AGENTS.md | 1 + packages/vscode/e2e/run.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5b4f0..85429d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index d40b19f..c711000 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -69,3 +69,4 @@ 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 ` (or the `test:e2e:` 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=` filters lint suites. - Run E2E locally as `VSCODE_CLI=1 pnpm test:e2e `. 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. diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 781eda1..0b60efd 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -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'; @@ -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 From 4d223938f2ff3ff5dd7a0256a158fc1497363ed0 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sat, 10 Oct 2026 17:30:07 +0800 Subject: [PATCH 2/2] test(vscode): inject an unreleased toolchain into the E2E fixtures RSTACK_E2E_TOOLCHAIN_OVERRIDES="name=" packs each package into /.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 (disposable checkouts). --- .gitignore | 1 + packages/vscode/AGENTS.md | 1 + packages/vscode/e2e/setupFixtures.mjs | 129 ++++++++++++++++++++++---- 3 files changed, 113 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index b2f67e3..a09bf93 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index c711000..840706a 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -70,3 +70,4 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - Prefer running the E2E slice that covers the change over the full chain: `pnpm test:e2e ` (or the `test:e2e:` 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=` filters lint suites. - Run E2E locally as `VSCODE_CLI=1 pnpm test:e2e `. 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="=,…"` packs each package into `/.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. diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 05a6845..d18c400 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -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. @@ -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 `/.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'; @@ -69,19 +82,86 @@ export const FIXTURE_NAMES = Object.keys(FIXTURES); const pnpmCommand = 'pnpm'; +/** `name=` 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=, 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; @@ -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(`(? { } 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();