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
41 changes: 33 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,24 @@ 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 + Linux ========
# Windows and macOS are the platforms the extension is actually used on.
# Linux is here for a different reason: rstack-ecosystem-ci runs every suite
# on ubuntu-latest, so a Linux E2E run is the prerequisite for joining it
# (rstackjs/rstack-editor#40). Linux needs two things the other two do not —
# an X server for the Extension Host (`xvfb-run`) and the GTK/NSS/GBM/ALSA
# libraries Electron links against. It also needs every fixture rewrite to
# be atomic: inotify reports a truncate-then-write as two events, so a
# watcher reading on the first one sees an empty file. All fixture writes go
# through `e2e/shared/atomicWrite.ts`; route new ones through it too.
e2e:
name: E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
os: [windows-latest, macos-latest, ubuntu-latest]

steps:
- name: Checkout
Expand All @@ -94,6 +97,21 @@ jobs:
- name: Build
run: pnpm run build

# Electron links against these at process start. The current
# ubuntu-latest (24.04) image already carries all five — this step
# installs nothing today — but the image's package set is not a contract,
# and the runner images this job exists to mirror (rstack-ecosystem-ci)
# need not carry them either. Naming them keeps the dependency explicit
# and the job portable. The `t64` names are the Ubuntu 24.04 time_t
# transition spellings — `libasound2` / `libgtk-3-0` no longer exist
# there.
- name: Install Extension Host Dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2t64 libgbm1 libgtk-3-0t64 libnss3 xvfb

# `@vscode/test-electron` downloads a full VS Code into
# `packages/vscode/.vscode-test`. Cache only the immutable distribution
# directories, keyed on the extension manifest (which carries
Expand All @@ -111,4 +129,11 @@ jobs:
# (e2e/setupFixtures.mjs, invoked by test:e2e), so this step needs
# network access.
- name: E2E Test
if: runner.os != 'Linux'
run: pnpm run test:e2e

# One `xvfb-run` around the whole chain: every slice launches its own VS
# Code, and they must all land on the same display.
- name: E2E Test (xvfb)
if: runner.os == 'Linux'
run: xvfb-run -a pnpm run test:e2e
1 change: 1 addition & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,6 @@ 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 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.
- **Every fixture rewrite goes through `await writeFileAtomic(...)`** (`e2e/shared/atomicWrite.ts`), never `fs.writeFileSync`. `writeFileSync` truncates and then writes; on Linux inotify reports those as two events, so a watcher reading on the first one sees an empty config or an empty source file and the suite races a state no user produces. The helper writes a sibling temp file and renames it over the target, which is one event everywhere. It is async and has one code path on every OS — no sync variant, no platform branch, no retry: if `rename` fails, the test fails with that error rather than silently falling back to the write this helper exists to avoid. Keep the write ordered where it was: when a suite builds a `waitForDiagnostics(...)` promise first, `await writeFileAtomic(...)` goes between that and the `await` of the promise. Exempt by design: writes into an OS tmpdir that no watcher covers (`suite-jsconfig/core-resolver.test.ts`), `vscode.workspace.fs.writeFile` (the editor's own save path, which is the behavior under test), `appendFileSync` (a single write, used to touch a config), and the sandbox setup in the `runTest.ts` entries, which runs before VS Code launches.
- 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.
11 changes: 6 additions & 5 deletions packages/vscode/e2e/lint/suite-bridge/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
waitForRslintDiagnostics,
waitForRslintDiagnosticsCount,
} from '../utils/diagnostics';
import { writeFileAtomic } from '../../shared/atomicWrite';

const nativeConfigName = 'rslint.config.mjs';

Expand Down Expand Up @@ -91,7 +92,7 @@ suite('Rstack lint bridge', function () {
}

teardown(async () => {
fs.writeFileSync(rstackConfigPath, originalConfig, 'utf8');
await writeFileAtomic(rstackConfigPath, originalConfig, 'utf8');
fs.rmSync(nativeConfigPath, { force: true });
fs.rmSync(nativeNodeModulesPath, { recursive: true, force: true });
fs.rmSync(markerPath, { force: true });
Expand Down Expand Up @@ -132,7 +133,7 @@ suite('Rstack lint bridge', function () {
'rs lint should report the same configured rule as the editor',
);

fs.writeFileSync(
await writeFileAtomic(
rstackConfigPath,
configSource('error', markerPath),
'utf8',
Expand All @@ -158,11 +159,11 @@ suite('Rstack lint bridge', function () {
const document = await openLintTarget();
await waitForRslintDiagnostics(document, hasNoDebugger);

fs.writeFileSync(rstackConfigPath, configSource('off'), 'utf8');
await writeFileAtomic(rstackConfigPath, configSource('off'), 'utf8');
const diagnostics = await waitForRslintDiagnosticsCount(document, 0);
assert.deepStrictEqual(diagnostics, []);

fs.writeFileSync(rstackConfigPath, originalConfig, 'utf8');
await writeFileAtomic(rstackConfigPath, originalConfig, 'utf8');
await waitForRslintDiagnostics(document, hasNoDebugger);
});

Expand All @@ -171,7 +172,7 @@ suite('Rstack lint bridge', function () {
await waitForRslintDiagnostics(document, hasNoDebugger);

installNativeCore();
fs.writeFileSync(
await writeFileAtomic(
nativeConfigPath,
`export default [{ rules: { 'no-debugger': 'off' } }];\n`,
'utf8',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// `packages/vscode-extension/__tests__/suite-eslint-plugins/eslint-plugins.test.ts` (origin/main).
import * as assert from 'assert';
import * as vscode from 'vscode';
import fs from 'node:fs';
import path from 'node:path';
import { waitForContentChange } from '../suite/fixall-helpers';
import { saveDocumentOnce } from '../utils/codeActionRegistry';
Expand All @@ -15,6 +14,7 @@ import {
closeAndDeleteTemporaryDocument,
temporaryFilePath,
} from '../utils/documents';
import { writeFileAtomic } from '../../shared/atomicWrite';

// End-to-end VS Code coverage for the object-form `plugins` reverse-dispatch
// path: the LSP server lints natively but dispatches rules mounted via a
Expand Down Expand Up @@ -45,7 +45,7 @@ suite('rslint object-form plugins integration', function () {
path.join(workspaceRoot(), 'src'),
'_fixall_plugin_',
);
fs.writeFileSync(tmpFile, '// placeholder\n', 'utf-8');
await writeFileAtomic(tmpFile, '// placeholder\n', 'utf-8');

let doc: vscode.TextDocument | undefined;
let testError: unknown;
Expand Down
57 changes: 31 additions & 26 deletions packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../utils/diagnostics';
import { closeTextEditor, revertTextDocument } from '../utils/documents';
import { waitForLintStackRegistration } from '../utils/extension';
import { writeFileAtomic } from '../../shared/atomicWrite';

suite('rslint JS config support', function () {
this.timeout(120_000);
Expand Down Expand Up @@ -155,7 +156,7 @@ suite('rslint JS config support', function () {
diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'),
),
);
fs.writeFileSync(configPath, newConfig, 'utf8');
await writeFileAtomic(configPath, newConfig, 'utf8');
const updatedDiags = await reloaded;
assert.ok(
updatedDiags.some((d) =>
Expand All @@ -176,7 +177,7 @@ suite('rslint JS config support', function () {
diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'),
),
);
fs.writeFileSync(configPath, originalConfig, 'utf8');
await writeFileAtomic(configPath, originalConfig, 'utf8');
await restored;
},
'Config hot-reload test',
Expand Down Expand Up @@ -220,7 +221,7 @@ export default [{
),
);
fs.rmSync(markerPath, { force: true });
fs.writeFileSync(configPath, countedConfig, 'utf8');
await writeFileAtomic(configPath, countedConfig, 'utf8');
await reloaded;

// A duplicate didChangeWatchedFiles transaction used to race the
Expand All @@ -241,7 +242,7 @@ export default [{
diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'),
),
);
fs.writeFileSync(configPath, originalConfig, 'utf8');
await writeFileAtomic(configPath, originalConfig, 'utf8');
await restored;
},
async () => fs.rmSync(markerPath, { force: true }),
Expand Down Expand Up @@ -285,7 +286,7 @@ export default [{
async () => {
// Restoring the config re-detects the folder and re-registers the
// stack without a window reload.
fs.writeFileSync(configPath, originalConfig, 'utf8');
await writeFileAtomic(configPath, originalConfig, 'utf8');
await waitForLintStackRegistration(true);
await waitForDiagnostics(doc, (diags) =>
diags.some((d) =>
Expand Down Expand Up @@ -340,7 +341,7 @@ export default [{
const created = waitForDiagnostics(doc, (diags) =>
diags.some((d) => diagnosticRuleIdIncludes(d, 'no-explicit-any')),
);
fs.writeFileSync(configPath, newConfig, 'utf8');
await writeFileAtomic(configPath, newConfig, 'utf8');
const afterCreateDiags = await created;
assert.ok(
afterCreateDiags.some((d) =>
Expand All @@ -355,7 +356,7 @@ export default [{
diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'),
),
);
fs.writeFileSync(configPath, originalConfig, 'utf8');
await writeFileAtomic(configPath, originalConfig, 'utf8');
await restored;
},
'JS-config creation test',
Expand Down Expand Up @@ -391,8 +392,8 @@ export default [{
await withFailClosedCleanup(
async () => {
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(nestedFilePath, 'debugger;\n', 'utf8');
fs.writeFileSync(rootConfigPath, rootConfigWithMarker, 'utf8');
await writeFileAtomic(nestedFilePath, 'debugger;\n', 'utf8');
await writeFileAtomic(rootConfigPath, rootConfigWithMarker, 'utf8');

await vscode.window.showTextDocument(rootDoc);
await waitForDiagnostics(rootDoc, (diags) =>
Expand All @@ -414,7 +415,7 @@ export default [{
await closeTextEditor(nestedDoc);
nestedDoc = undefined;

fs.writeFileSync(
await writeFileAtomic(
nestedConfigPath,
`import fs from 'node:fs';
fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted', 'utf8');
Expand All @@ -434,7 +435,7 @@ export default [];
// so its diagnostics cannot be a stale snapshot from before the failed
// refresh. Its first lint must run after the blocking config transaction
// and resolve through the still-valid ancestor config.
fs.writeFileSync(postFailureFilePath, 'debugger;\n', 'utf8');
await writeFileAtomic(postFailureFilePath, 'debugger;\n', 'utf8');
postFailureDoc =
await vscode.workspace.openTextDocument(postFailureFilePath);
await vscode.window.showTextDocument(postFailureDoc);
Expand Down Expand Up @@ -480,7 +481,7 @@ export default [];
) && diagnostic.severity === vscode.DiagnosticSeverity.Error,
),
);
fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8');
await writeFileAtomic(rootConfigPath, originalRootConfig, 'utf8');
// Wait for the restored root config to take effect BEFORE deleting
// the nested directory: on Windows the server still holds handles
// inside it (the broken config's evaluator) until the refresh
Expand Down Expand Up @@ -528,8 +529,12 @@ export default [];
await withFailClosedCleanup(
async () => {
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(nestedFilePath, 'console.log("nested");\n', 'utf8');
fs.writeFileSync(
await writeFileAtomic(
nestedFilePath,
'console.log("nested");\n',
'utf8',
);
await writeFileAtomic(
nestedConfigPath,
`import fs from 'node:fs';
fs.appendFileSync(${JSON.stringify(loadMarkerPath)}, 'x');
Expand Down Expand Up @@ -577,7 +582,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
),
);

fs.writeFileSync(rootConfigPath, ignoredRootConfig, 'utf8');
await writeFileAtomic(rootConfigPath, ignoredRootConfig, 'utf8');
await Promise.all([parentApplied, nestedCleared]);

assert.strictEqual(
Expand Down Expand Up @@ -605,7 +610,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'),
),
);
fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8');
await writeFileAtomic(rootConfigPath, originalRootConfig, 'utf8');
await restored;
fs.rmSync(nestedDir, {
recursive: true,
Expand Down Expand Up @@ -639,7 +644,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
async () => {
for (let index = 0; index < probes.length; index++) {
fs.mkdirSync(probes[index], { recursive: true });
fs.writeFileSync(
await writeFileAtomic(
path.join(probes[index], 'rslint.config.mjs'),
`import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(markerPaths[index])}, 'loaded'); export default [];`,
'utf8',
Expand All @@ -652,7 +657,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'),
),
);
fs.writeFileSync(rootConfigPath, changedRootConfig, 'utf8');
await writeFileAtomic(rootConfigPath, changedRootConfig, 'utf8');
await reloaded;

for (const markerPath of markerPaths) {
Expand Down Expand Up @@ -688,7 +693,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
// Leave a concurrently populated directory intact.
}
}
fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8');
await writeFileAtomic(rootConfigPath, originalRootConfig, 'utf8');
await restored;
},
'Excluded-config search test',
Expand Down Expand Up @@ -760,17 +765,17 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];

await withFailClosedCleanup(
async () => {
fs.writeFileSync(
await writeFileAtomic(
mjsPath,
configFor('@typescript-eslint/no-explicit-any'),
'utf8',
);
fs.writeFileSync(
await writeFileAtomic(
tsPath,
configFor('@typescript-eslint/no-unsafe-member-access'),
'utf8',
);
fs.writeFileSync(
await writeFileAtomic(
mtsPath,
configFor('@typescript-eslint/no-explicit-any'),
'utf8',
Expand All @@ -794,7 +799,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
d.severity === vscode.DiagnosticSeverity.Error,
),
);
fs.writeFileSync(jsPath, originalJS, 'utf8');
await writeFileAtomic(jsPath, originalJS, 'utf8');
fs.rmSync(mjsPath, { force: true });
fs.rmSync(tsPath, { force: true });
fs.rmSync(mtsPath, { force: true });
Expand Down Expand Up @@ -828,8 +833,8 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];

await withFailClosedCleanup(
async () => {
fs.writeFileSync(mjsPath, lowerPriorityConfig, 'utf8');
fs.writeFileSync(
await writeFileAtomic(mjsPath, lowerPriorityConfig, 'utf8');
await writeFileAtomic(
jsPath,
`import fs from 'node:fs';
fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted');
Expand Down Expand Up @@ -895,7 +900,7 @@ export default [];
diagnosticRuleIdIncludes(d, 'no-unsafe-member-access'),
),
);
fs.writeFileSync(jsPath, originalJS, 'utf8');
await writeFileAtomic(jsPath, originalJS, 'utf8');
fs.rmSync(mjsPath, { force: true });
fs.rmSync(attemptedLoadPath, { force: true });
await restored;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import * as vscode from 'vscode';
import type { StackState } from '../../../src/types';
Expand All @@ -8,6 +7,7 @@ import {
waitForRslintDiagnostics,
} from '../utils/diagnostics';
import { extensionExports } from '../utils/extension';
import { writeFileAtomic } from '../../shared/atomicWrite';

function lintExports(): {
getFolderStates(): ReadonlyMap<string, StackState>;
Expand Down Expand Up @@ -68,14 +68,14 @@ suite('Rslint missing config dependency', function () {
assert.ok(!warnings[0].includes('\n'), 'warning must remain one line');

const configPath = path.join(root, 'rslint.config.mjs');
fs.writeFileSync(
await writeFileAtomic(
configPath,
"export default [{ files: ['src/**/*.ts'], rules: { 'no-debugger': 'error' } }];\n",
);
await waitForRslintDiagnostics(document);
await waitForRuntimeKind('running');

fs.writeFileSync(
await writeFileAtomic(
configPath,
"import 'missing-rslint-config-dependency';\nexport default [];\n",
);
Expand Down
Loading
Loading