diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5b4f0..44c190c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,13 +56,16 @@ 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 }} @@ -70,7 +73,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest] + os: [windows-latest, macos-latest, ubuntu-latest] steps: - name: Checkout @@ -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 @@ -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 diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index dfeda2c..8f4e03d 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -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 ` (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. diff --git a/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts index 3936792..58c0114 100644 --- a/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts +++ b/packages/vscode/e2e/lint/suite-bridge/bridge.test.ts @@ -10,6 +10,7 @@ import { waitForRslintDiagnostics, waitForRslintDiagnosticsCount, } from '../utils/diagnostics'; +import { writeFileAtomic } from '../../shared/atomicWrite'; const nativeConfigName = 'rslint.config.mjs'; @@ -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 }); @@ -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', @@ -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); }); @@ -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', diff --git a/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts b/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts index b010c23..2494e30 100644 --- a/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts +++ b/packages/vscode/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts @@ -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'; @@ -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 @@ -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; diff --git a/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts index 8cda453..db68df3 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts @@ -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); @@ -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) => @@ -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', @@ -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 @@ -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 }), @@ -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) => @@ -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) => @@ -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', @@ -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) => @@ -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'); @@ -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); @@ -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 @@ -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'); @@ -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( @@ -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, @@ -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', @@ -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) { @@ -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', @@ -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', @@ -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 }); @@ -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'); @@ -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; diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts index 810cd19..1510d19 100644 --- a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts @@ -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'; @@ -8,6 +7,7 @@ import { waitForRslintDiagnostics, } from '../utils/diagnostics'; import { extensionExports } from '../utils/extension'; +import { writeFileAtomic } from '../../shared/atomicWrite'; function lintExports(): { getFolderStates(): ReadonlyMap; @@ -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", ); diff --git a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts index af178e3..a3bb543 100644 --- a/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts +++ b/packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts @@ -14,6 +14,7 @@ import { revertTextDocument } from '../utils/documents'; import { CoreResolver } from '../../../src/stacks/lint/CoreResolver'; import type { StackState } from '../../../src/types'; import { extensionExports } from '../utils/extension'; +import { writeFileAtomic } from '../../shared/atomicWrite'; function getRuntimeStates(): ReadonlyMap { const exports = extensionExports().getStackExports('rslint') as @@ -315,7 +316,7 @@ suite('rslint monorepo multi-config support', function () { `; try { - fs.writeFileSync(fooConfigPath, newConfig, 'utf8'); + await writeFileAtomic(fooConfigPath, newConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); await triggerRelint(editor); @@ -336,7 +337,7 @@ suite('rslint monorepo multi-config support', function () { 'After change: foo file should NOT see no-unsafe-member-access', ); } finally { - fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await writeFileAtomic(fooConfigPath, originalConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); } }); @@ -386,7 +387,7 @@ suite('rslint monorepo multi-config support', function () { 'After delete: foo file should NOT see no-unsafe-member-access (off in root)', ); } finally { - fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await writeFileAtomic(fooConfigPath, originalConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); } }); @@ -414,7 +415,11 @@ suite('rslint monorepo multi-config support', function () { const originalConfig = fs.readFileSync(fooConfigPath, 'utf8'); try { - fs.writeFileSync(fooConfigPath, 'export default [BROKEN SYNTAX;', 'utf8'); + await writeFileAtomic( + fooConfigPath, + 'export default [BROKEN SYNTAX;', + 'utf8', + ); await new Promise((resolve) => setTimeout(resolve, 2000)); // 3. Root config should still work for bar @@ -429,7 +434,7 @@ suite('rslint monorepo multi-config support', function () { 'Bar file should still use root config after foo config is corrupted', ); } finally { - fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await writeFileAtomic(fooConfigPath, originalConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); } }); @@ -471,7 +476,7 @@ suite('rslint monorepo multi-config support', function () { `; try { - fs.writeFileSync(barConfigPath, barConfig, 'utf8'); + await writeFileAtomic(barConfigPath, barConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 3000)); await triggerRelint(editor); @@ -620,7 +625,7 @@ suite('rslint monorepo multi-config support', function () { diagnosticRuleIdIncludes(diagnostic, 'no-explicit-any'), ), ); - fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + await writeFileAtomic(rootConfigPath, originalRootConfig, 'utf8'); await triggerRelint(await vscode.window.showTextDocument(barDoc)); await rootRestored; } catch (error) { @@ -673,7 +678,7 @@ suite('rslint monorepo multi-config support', function () { `; try { - fs.writeFileSync(rootConfigPath, newConfig, 'utf8'); + await writeFileAtomic(rootConfigPath, newConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); await triggerRelint(editor); @@ -696,7 +701,7 @@ suite('rslint monorepo multi-config support', function () { 'After change: bar file should NOT see no-explicit-any (off in updated root)', ); } finally { - fs.writeFileSync(rootConfigPath, originalConfig, 'utf8'); + await writeFileAtomic(rootConfigPath, originalConfig, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 2000)); } }); diff --git a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts index 0382afc..1b4a139 100644 --- a/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts +++ b/packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts @@ -27,6 +27,7 @@ import { isLintStackRegistered, waitForLintStackRegistration, } from '../utils/extension'; +import { writeFileAtomic } from '../../shared/atomicWrite'; suite('rslint no config fallback', function () { this.timeout(120000); @@ -186,7 +187,7 @@ suite('rslint no config fallback', function () { // Upstream expected this write to produce `no-explicit-any` // diagnostics. This extension deliberately drops the deprecated JSON // format: it is not a detection signal, so nothing may happen. - fs.writeFileSync(json, jsonConfig, 'utf8'); + await writeFileAtomic(json, jsonConfig, 'utf8'); await triggerDiagnosticRefresh(doc); await assertStaysUndetected(doc, 5_000, 'rslint.json only'); }); @@ -199,7 +200,7 @@ suite('rslint no config fallback', function () { // ── Step 1: create the JS config → detection flips, the shell // registers the lint stack and the server produces diagnostics. - fs.writeFileSync(js, jsConfig, 'utf8'); + await writeFileAtomic(js, jsConfig, 'utf8'); await waitForLintStackRegistration(true); const diags = await waitForDiagnostics(doc, (ds) => ds.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')), @@ -243,8 +244,8 @@ suite('rslint no config fallback', function () { // Establish a positive publication first, so the later empty snapshot // cannot be the document's not-yet-linted initial state. - fs.writeFileSync(json, jsonConfig, 'utf8'); - fs.writeFileSync(js, jsConfig, 'utf8'); + await writeFileAtomic(json, jsonConfig, 'utf8'); + await writeFileAtomic(js, jsConfig, 'utf8'); await waitForLintStackRegistration(true); await waitForDiagnostics(doc, (ds) => ds.some((d) => @@ -262,7 +263,7 @@ suite('rslint no config fallback', function () { // Create a broken JS config fresh. Detection lights the stack again; // the new server evaluates the module (observable via the marker), // fails, and has no last-good to keep — nor a JSON fallback to take. - fs.writeFileSync( + await writeFileAtomic( js, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted'); diff --git a/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts b/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts index f9fee1d..02c635e 100644 --- a/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts +++ b/packages/vscode/e2e/lint/suite-unicode-bom/unicode-bom.test.ts @@ -7,6 +7,7 @@ import { waitForRslintDiagnostics, } from '../utils/diagnostics'; import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; +import { writeFileAtomic } from '../../shared/atomicWrite'; // Intentional adaptation from upstream: issue #27 moves rule ids from the // diagnostic message into VS Code's clickable diagnostic-code field. Matching @@ -68,8 +69,8 @@ suite('rslint unicode-bom over LSP', function () { ); } - suiteSetup(() => { - fs.writeFileSync(fixturePath('marked.ts'), BOM + markedSource, 'utf8'); + suiteSetup(async () => { + await writeFileAtomic(fixturePath('marked.ts'), BOM + markedSource, 'utf8'); }); suiteTeardown(() => { diff --git a/packages/vscode/e2e/lint/suite/fixall-helpers.ts b/packages/vscode/e2e/lint/suite/fixall-helpers.ts index 6bf3f3a..ce0e93f 100644 --- a/packages/vscode/e2e/lint/suite/fixall-helpers.ts +++ b/packages/vscode/e2e/lint/suite/fixall-helpers.ts @@ -3,7 +3,6 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import path from 'node:path'; -import fs from 'node:fs'; import { diagnosticRuleIdIncludes, waitForRslintDiagnostics, @@ -16,6 +15,7 @@ import { temporaryFilePath, } from '../utils/documents'; import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; +import { writeFileAtomic } from '../../shared/atomicWrite'; export { saveDocumentOnce } from '../utils/codeActionRegistry'; export { diagnosticRuleIdIncludes } from '../utils/diagnostics'; @@ -112,7 +112,7 @@ export async function withTmpFile( path.join(getFixturesDir(), 'src'), '_fixall_tmp_', ); - fs.writeFileSync(tmpFile, content, 'utf-8'); + await writeFileAtomic(tmpFile, content, 'utf-8'); let doc: vscode.TextDocument | undefined; let testError: unknown; try { @@ -194,7 +194,7 @@ export async function withOnSaveFixAll( path.join(getFixturesDir(), 'src'), '_fixall_test_', ); - fs.writeFileSync(tmpFile, '// placeholder\n', 'utf-8'); + await writeFileAtomic(tmpFile, '// placeholder\n', 'utf-8'); let doc: vscode.TextDocument | undefined; let testError: unknown; diff --git a/packages/vscode/e2e/lint/utils/configuration.ts b/packages/vscode/e2e/lint/utils/configuration.ts index 2021e8f..b3af7a5 100644 --- a/packages/vscode/e2e/lint/utils/configuration.ts +++ b/packages/vscode/e2e/lint/utils/configuration.ts @@ -1,9 +1,13 @@ -// Ported verbatim from web-infra-dev/rslint +// Ported from web-infra-dev/rslint // `packages/vscode-extension/__tests__/utils/configuration.ts` (origin/main). +// One deviation: the settings restore goes through `writeFileAtomic`, so VS +// Code never observes the truncated intermediate state on Linux. That makes +// `restoreWorkspaceSettings` async; its one caller already awaits in place. import fs from 'node:fs'; import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import * as vscode from 'vscode'; +import { writeFileAtomic } from '../../shared/atomicWrite'; type CodeActionsOnSave = Record; @@ -36,10 +40,12 @@ function captureWorkspaceSettings( }; } -function restoreWorkspaceSettings(snapshot: WorkspaceSettingsSnapshot): void { +async function restoreWorkspaceSettings( + snapshot: WorkspaceSettingsSnapshot, +): Promise { if (snapshot.content) { fs.mkdirSync(snapshot.directoryPath, { recursive: true }); - fs.writeFileSync(snapshot.filePath, snapshot.content); + await writeFileAtomic(snapshot.filePath, snapshot.content); if (!fs.readFileSync(snapshot.filePath).equals(snapshot.content)) { throw new Error( `Could not restore workspace settings: ${snapshot.filePath}`, @@ -185,7 +191,7 @@ export async function withCodeActionsOnSave( restoreErrors.push(new Error('Workspace settings snapshot is missing')); } else { try { - restoreWorkspaceSettings(settingsSnapshot); + await restoreWorkspaceSettings(settingsSnapshot); } catch (error) { restoreErrors.push(error); } diff --git a/packages/vscode/e2e/rstest/suite/progress.test.ts b/packages/vscode/e2e/rstest/suite/progress.test.ts index 76a0d3f..9b7975d 100644 --- a/packages/vscode/e2e/rstest/suite/progress.test.ts +++ b/packages/vscode/e2e/rstest/suite/progress.test.ts @@ -7,7 +7,7 @@ // the copied stack keeps `diagnostic.source === 'rstest'` and the reporter // output format. import assert from 'node:assert'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import vscode from 'vscode'; import { @@ -17,6 +17,7 @@ import { getTestItemByLabels, waitFor, } from './helpers'; +import { writeFileAtomic } from '../../shared/atomicWrite'; suite('Test Progress Reporting', () => { let deferred = Promise.withResolvers(); @@ -231,7 +232,7 @@ suite('Test Progress Reporting', () => { replaceValue: string, ) => { const fullPath = path.resolve(FIXTURES_ROOT, 'workspace-1/test', file); - await writeFile( + await writeFileAtomic( fullPath, (await readFile(fullPath, 'utf-8')).replace(searchValue, replaceValue), ); diff --git a/packages/vscode/e2e/shared/atomicWrite.ts b/packages/vscode/e2e/shared/atomicWrite.ts new file mode 100644 index 0000000..d64903d --- /dev/null +++ b/packages/vscode/e2e/shared/atomicWrite.ts @@ -0,0 +1,19 @@ +/** + * EXPERIMENT (do not merge): plain fixture writes. + * + * The rename-based implementation is replaced by a bare `fs.promises.write + * File` so a Linux E2E run measures whether the suites actually race the + * truncate/write pair inotify reports as two events. The signature is + * unchanged, so no call site moves. + */ +import fs from 'node:fs'; + +export type FileContent = string | NodeJS.ArrayBufferView; + +export async function writeFileAtomic( + filePath: string, + content: FileContent, + encoding?: BufferEncoding, +): Promise { + return fs.promises.writeFile(filePath, content, encoding); +}