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
1 change: 1 addition & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
## Testing

- 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.
- **No upstream-internal coupling in tests.** A test may depend on an upstream package's internal detail (wire protocol, dist layout, log or error text) only when `src/` depends on that same detail; otherwise assert the user-visible behavior. Documented public upstream APIs and importing our own `src` are allowed.
- 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.
12 changes: 5 additions & 7 deletions packages/vscode/e2e/fixtures/rslint/local-plugin.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
/**
* An object-form ESLint plugin, mounted by `rslint.config.mjs`.
*
* Object-form plugin rules run in JS, not in the Go linter: the server sends a
* reverse `rslint/pluginLint` request back to the client, which answers it from
* a worker pool owned by `@rslint/core/eslint-plugin`. That is exactly the path
* the plugin-host regression smoke test exercises, so the rule has to come from a
* plugin rather than from Rslint's native rule set.
* Object-form plugin rules run in the project's own plugin runtime, so this
* rule has to come from a plugin rather than from the built-in rule set. That
* is exactly the path the plugin-host regression smoke test exercises.
*
* `.mjs` on purpose: a `.ts` plugin/config would drag `jiti` (or native type
* stripping) into a test that is about module resolution, not about config
* loaders.
* stripping) into a test that is about module resolution, not about loading
* TypeScript configs.
*/
export default {
meta: { name: 'rstack-editor-e2e-local-plugin', version: '1.0.0' },
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/e2e/lint/fixtures/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "rstack-lint-e2e-fixtures",
"version": "0.0.0",
"private": true,
"description": "Shared install root for the Rslint E2E fixture workspaces. The extension ships no binary: every fixture resolves @rslint/core - including its native Go binary, config-loader and eslint-plugin host - from this one published-npm install. jiti backs the config-file-loader's TypeScript-config fallback.",
"description": "Shared install root for the Rslint E2E fixture workspaces. The extension ships no binary: every fixture resolves @rslint/core - including the binary and the plugin runtime - from this one published-npm install. jiti backs TypeScript config loading.",
"dependencies": {
"@rslint/core": "0.10.0",
"jiti": "^2.7.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { isMainThread } from 'node:worker_threads';
// Config discovery must succeed; only the plugin-host worker import fails.
// A non-config marker keeps a file-watch refresh or restart from recovering
// the state early; the dependency poll retries the generation.
// Known exception to the test-coupling rule: no public surface can fail only
// the plugin host, so if the host stops using worker threads this fixture must
// be updated.
if (!isMainThread) {
const state = fs.readFileSync(new URL('./host-state.txt', import.meta.url), 'utf8').trim();
if (state === 'crash') throw new Error('fixture plugin import exploded');
Expand Down
10 changes: 4 additions & 6 deletions packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// The opened workspace folder is `packages/app`; the fixture's only
// `rslint.config.*` sits two levels above it. The rslint CLI and its Go server
// walk up to the filesystem root for a config, so native lint detection must
// light this folder, and its ancestor watchers must keep that decision fresh.
// `rslint.config.*` sits two levels above it, so native lint detection must
// light this folder from the ancestor config and keep that decision fresh.
import * as vscode from 'vscode';
import path from 'node:path';
import fs from 'node:fs';
Expand Down Expand Up @@ -66,9 +65,8 @@ suite('rslint config above the workspace folder', function () {
await waitForDiagnostics(document, hasNoDebugger);
});

// The live runtime keeps its key; the extension's ancestor watchers send
// configRefresh, as do the Go server's own (`ancestorJSConfigFileWatchers`,
// registered through the worker) once they are live.
// The live runtime keeps its key; edits above the folder are picked up by
// the extension's own config watchers and applied without a restart.
test('applies a nearer config created above the folder while linting', async () => {
const document = await openIndex();
await waitForDiagnostics(document, hasNoDebugger);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ import {
temporaryFilePath,
} from '../utils/documents';

// 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
// config's object-form `plugins` to the extension-side worker pool
// (PluginLintPool), then merges + publishes. The Go merge/dispatch units are
// covered in internal/lsp/eslint_plugin_test.go; this exercises the full loop.
// End-to-end VS Code coverage for the object-form `plugins` path: rules
// mounted through a config's `plugins` run in the extension-side worker pool
// (PluginLintPool) and their diagnostics are published alongside native rules.
//
// Fixture: rslint.config.mjs mounts ./local-plugin.mjs under object-form
// `plugins` with rules local/no-null + local/prefer-array-some, plus native
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ suite('PluginLintPool generations', () => {
assert.strictEqual(
hostB.lintCalls,
1,
'Go can route an accepted generation before Node observes the ack',
'a caller can route an accepted generation before the pool observes the ack',
);

assert.strictEqual(await pool.commit('b'), true);
Expand All @@ -209,7 +209,7 @@ suite('PluginLintPool generations', () => {
assert.strictEqual(
hostA.shutdownCalls,
0,
'the predecessor remains rollback-capable until a later commit proves Go accepted b',
'the predecessor remains rollback-capable until a later commit proves the newer generation was accepted',
);

assert.strictEqual(
Expand Down Expand Up @@ -239,9 +239,9 @@ suite('PluginLintPool generations', () => {
await pool.prepare(descriptor('b'), 'fingerprint-b', 'b');
await pool.commit('b');

// Node already switched to b, but Go did not receive/accept the commit
// response and compensates with abort. The pool must return to a rather
// than leaving both processes on different generations.
// The pool already switched to b, but the caller did not receive/accept
// the commit response and compensates with abort. The pool must return to a
// rather than leaving both sides on different generations.
await pool.abort('b');
await pool.lint(request('a'));
assert.strictEqual(hosts.get('/a.mjs')!.lintCalls, 1);
Expand All @@ -252,7 +252,7 @@ suite('PluginLintPool generations', () => {
assert.strictEqual(hosts.get('/a.mjs')!.shutdownCalls, 1);
});

test('default grace retains at most two old WorkerPools', async () => {
test('default grace retains at most two old hosts', async () => {
const hosts = new Map<string, TestHost>();
const pool = new PluginLintPool(testLogger(), async (configs) => {
const host = new TestHost();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ suite('rslint import/no-cycle over LSP', function () {
);

// a.ts is unchanged in meaning, so only a fresh cross-file answer — built
// from the Program that holds c.ts's edited buffer — can clear its report.
// from the edited buffer for c.ts — can clear its report.
const clearedA = await touchAndWait(
docA,
(all) => isLintedPass(all) && cycleDiagnostics(all).length === 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
// constant: `@rslint/core/config-loader` is resolved from the project at
// runtime, so the adapter takes the loader's
// `CONFIG_DISCOVERY_PROTOCOL_VERSION` as a constructor argument. The tests
// inject the devDependency's constant; the fixtures currently pin the same
// 0.9.0 release line.
// - The watch-glob test asserts upstream's current JS/TS config list is kept
// verbatim, with this extension's existing lockfile additions.
// inject the devDependency's constant.
// - The watch-glob test asserts the JS/TS config names the extension watches,
// with this extension's existing lockfile additions.
import * as assert from 'node:assert';

import {
Expand Down Expand Up @@ -263,7 +262,7 @@ suite('LSP config discovery transactions', () => {
reportError: () => assert.fail('unexpected config error'),
};

test('the extension watcher leaves gitignore ownership to Go', () => {
test('the extension watcher covers configs and lockfiles only', () => {
assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.js/);
assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.mjs/);
assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.ts/);
Expand Down Expand Up @@ -327,7 +326,7 @@ suite('LSP config discovery transactions', () => {
assert.deepStrictEqual(host.deletedTransactions, ['tx-1']);
});

test('a rejected commit retains the session until Go aborts it', async () => {
test('a rejected commit retains the session until the caller aborts it', async () => {
const host = new TestConfigHost();
const pool = new TestPluginPool();
pool.commitResult = false;
Expand Down Expand Up @@ -366,7 +365,7 @@ suite('LSP config discovery transactions', () => {
assert.deepStrictEqual(host.deletedTransactions, ['tx-abort']);
});

test('a later Running state requests a fresh initial catalog', async () => {
test('a later Running state requests a fresh initial refresh', async () => {
const events: string[] = [];
const recovery = recoverConfigDiscoveryOnServerState(
State.Running,
Expand Down
31 changes: 12 additions & 19 deletions packages/vscode/e2e/lint/suite-jsconfig/jsconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ suite('rslint JS config support', function () {
const doc = await openFixture('index.ts');
await vscode.window.showTextDocument(doc);

// Wait specifically for JS config diagnostics. The startup snapshot may
// publish JSON fallback results before JS config activation commits.
// Wait specifically for JS config diagnostics: the JSON config may apply
// first, and the JS config must win once it loads.
const diagnostics = await waitForDiagnostics(doc, (diags) =>
diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')),
);
Expand All @@ -96,8 +96,8 @@ suite('rslint JS config support', function () {
await vscode.window.showTextDocument(doc);

// Wait specifically for JS config diagnostics (no-unsafe-member-access).
// JSON config may load first with no-explicit-any, but the committed JS
// config catalog should override it.
// JSON config may load first with no-explicit-any, but the JS config must
// take priority.
const diagnostics = await waitForDiagnostics(doc, (diags) =>
diags.some((d) => diagnosticRuleIdIncludes(d, 'no-unsafe-member-access')),
);
Expand Down Expand Up @@ -230,7 +230,7 @@ export default [{
assert.strictEqual(
fs.readFileSync(markerPath, 'utf8'),
'x',
'one edit must evaluate one config-discovery transaction',
'one edit must evaluate the config once',
);
},
async () => {
Expand Down Expand Up @@ -505,14 +505,13 @@ export default [];
);
});

test('parent global ignores remove nested configs from the effective catalog', async () => {
test('parent global ignores disable nested configs', async () => {
const root = getWorkspaceRoot();
const rootConfigPath = path.join(root, 'rslint.config.js');
const originalRootConfig = fs.readFileSync(rootConfigPath, 'utf8');
const nestedDir = path.join(root, 'parent-ignore-catalog-probe');
const nestedConfigPath = path.join(nestedDir, 'rslint.config.mjs');
const nestedFilePath = path.join(nestedDir, 'index.ts');
const loadMarkerPath = path.join(nestedDir, 'config-loads.txt');
const rootDoc = await openFixture('index.ts');

const ignoredRootConfig = originalRootConfig
Expand All @@ -531,9 +530,7 @@ export default [];
fs.writeFileSync(nestedFilePath, 'console.log("nested");\n', 'utf8');
fs.writeFileSync(
nestedConfigPath,
`import fs from 'node:fs';
fs.appendFileSync(${JSON.stringify(loadMarkerPath)}, 'x');
export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
`export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
`,
'utf8',
);
Expand All @@ -559,9 +556,6 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
fs.appendFileSync(nestedConfigPath, '\n');
}
}
// Every server-side evaluation of the nested config appends to the
// marker; the retry nudges above may legitimately cause more than one.
const evaluationsBeforeIgnore = fs.readFileSync(loadMarkerPath, 'utf8');

await vscode.window.showTextDocument(rootDoc);
const parentApplied = waitForDiagnostics(rootDoc, (diagnostics) =>
Expand All @@ -580,18 +574,17 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }];
fs.writeFileSync(rootConfigPath, ignoredRootConfig, 'utf8');
await Promise.all([parentApplied, nestedCleared]);

assert.strictEqual(
fs.readFileSync(loadMarkerPath, 'utf8'),
evaluationsBeforeIgnore,
'The ignored nested candidate must not be evaluated again',
);
// Deviation from upstream: upstream also asserted that the ignored
// nested candidate is never evaluated again. Which config candidates
// get evaluated is the linter's concern, not this extension's, so this
// port asserts only the visible outcome below.
assert.ok(
!vscode.languages
.getDiagnostics(nestedDoc.uri)
.some((diagnostic) =>
diagnostic.message.includes('Unexpected console statement'),
),
'The ignored nested config must not be sent in the effective catalog',
'The ignored nested config must not apply to its directory',
);
},
async () => {
Expand Down
32 changes: 12 additions & 20 deletions packages/vscode/e2e/lint/suite-monorepo/monorepo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,16 @@ suite('rslint monorepo multi-config support', function () {
/**
* Give `packages/foo` its own **physical** `@rslint/core` copy, so the
* monorepo exercises two Lint runtimes in one workspace folder (rslint
* #1617). Upstream's fixture does the same by copying `package.json` + `dist`
* out of the workspace-resolved core and symlinking that core's own
* `node_modules` next to the copy.
* #1617). The workspace-resolved package is copied as a whole, so the
* fixture does not encode the package's internal file layout; the
* dependencies it needs are linked next to the copy.
*
* Adaptation: this repo installs published npm packages into one shared
* fixture root (`e2e/lint/fixtures/package.json`), and with pnpm's isolated
* store the core's dependencies (`picomatch`, `jiti`, the platform
* `@rslint/native-*`) live in the `node_modules` **two levels above** the
* package directory, not inside it. Those entries are linked individually —
* `@rslint/core` itself is skipped, because a link to the original would
* shadow the copy the Lint worker is meant to load.
* store the core's dependencies live in the `node_modules` **two levels
* above** the package directory, not inside it. Those entries are linked
* individually — `@rslint/core` itself is skipped, because a link to the
* original would shadow the copy the Lint worker is meant to load.
*/
suiteSetup(async function () {
this.timeout(120000);
Expand All @@ -72,18 +71,11 @@ suite('rslint monorepo multi-config support', function () {
getWorkspaceRoot(),
'packages/foo/node_modules/@rslint/core',
);
await fs.promises.mkdir(nestedCore, { recursive: true });
await Promise.all([
fs.promises.copyFile(
path.join(sourceCore, 'package.json'),
path.join(nestedCore, 'package.json'),
),
fs.promises.cp(
path.join(sourceCore, 'dist'),
path.join(nestedCore, 'dist'),
{ recursive: true, force: false, errorOnExist: true },
),
]);
await fs.promises.cp(sourceCore, nestedCore, {
recursive: true,
force: false,
errorOnExist: true,
});

const dependencyRoot = path.resolve(sourceCore, '..', '..');
const nestedModules = path.join(nestedCore, 'node_modules');
Expand Down
18 changes: 9 additions & 9 deletions packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
// register the stack without a window reload, and deleting the last one
// must deregister it (the config-glob + lockfile detection watcher).
// - Upstream's "a broken discovered JS config must not fall back to JSON"
// step is preserved: the Go server owns that behavior and is unchanged.
// step is preserved: a broken config yields no diagnostics, never the
// legacy JSON rules.
import * as assert from 'assert';
import * as vscode from 'vscode';
import path from 'node:path';
Expand Down Expand Up @@ -228,10 +229,9 @@ suite('rslint no config fallback', function () {
});

test('a broken JS config does not fall back to rslint.json', async () => {
// Upstream's lifecycle step 4: a *freshly discovered* broken JS config
// (no last-good catalog for its path) must yield an explicit no-lint
// state instead of falling back to JSON. A broken rewrite of an already
// loaded config would instead keep the last-good catalog active — that
// Upstream's lifecycle step 4: a freshly discovered broken JS config must
// yield no lint state instead of falling back to JSON. A broken rewrite of
// an already loaded config keeps linting with the last good config — that
// scenario belongs to the jsconfig suite ("broken higher-priority config
// preserves last-good").
await withConfigFilesAbsent(async ({ json, js }) => {
Expand All @@ -256,15 +256,15 @@ suite('rslint no config fallback', function () {
);

// Delete the JS config: the folder is un-detected (rslint.json does
// not count), the stack deregisters and its last-good
// catalog dies with the server.
// not count), the stack deregisters and the previous config's results
// are gone.
fs.rmSync(js);
await waitForLintStackRegistration(false);
await waitForDiagnostics(doc, (ds) => ds.length === 0);

// 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.
// the module is evaluated (observable via the marker), fails, and
// there is no earlier config to keep — nor a JSON fallback to take.
fs.writeFileSync(
js,
`import fs from 'node:fs';
Expand Down
Loading
Loading