From 672d38a3ddd25139811f1c135d2d06cb9afd7c60 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 20 Aug 2026 13:04:35 +0800 Subject: [PATCH 1/2] test(vscode): wait for expected diagnostics in fixAll E2E setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since @rslint/core 0.8.1 (web-infra-dev/rslint#1790), a file created after its project was loaded is served by a type-info-less fallback Program until a watcher event admits it into the configured project, so the first non-empty publish may carry only non-type-aware rules on platforms with slow file watchers (macOS). The fixAll suites create their targets through withTmpFile and asserted on that first non-empty publish, which now fails. Setup waits go through a new waitForDiagnosticsWithMessages helper that waits until the expected diagnostics are present — the terminal assertion is unchanged and holds on both 0.8.0 and 0.8.1. The lint fixture floor moves to ^0.8.1 to match what CI resolves. --- .../vscode/e2e/lint/fixtures/package.json | 2 +- .../e2e/lint/suite/fixall-error.test.ts | 10 +++++--- .../vscode/e2e/lint/suite/fixall-helpers.ts | 23 ++++++++++++++++++ .../e2e/lint/suite/fixall-onsave.test.ts | 24 +++++++++++++++---- packages/vscode/e2e/lint/suite/fixall.test.ts | 20 ++++++++++++---- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/packages/vscode/e2e/lint/fixtures/package.json b/packages/vscode/e2e/lint/fixtures/package.json index 56baa1e..711708e 100644 --- a/packages/vscode/e2e/lint/fixtures/package.json +++ b/packages/vscode/e2e/lint/fixtures/package.json @@ -4,7 +4,7 @@ "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.", "dependencies": { - "@rslint/core": "^0.8.0", + "@rslint/core": "^0.8.1", "jiti": "^2.7.0" } } diff --git a/packages/vscode/e2e/lint/suite/fixall-error.test.ts b/packages/vscode/e2e/lint/suite/fixall-error.test.ts index 15c5185..3d4080f 100644 --- a/packages/vscode/e2e/lint/suite/fixall-error.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall-error.test.ts @@ -1,9 +1,10 @@ -// Ported verbatim from web-infra-dev/rslint +// Ported from web-infra-dev/rslint (deviation: setup waits go through +// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall-error.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; import { - waitForDiagnostics, + waitForDiagnosticsWithMessages, waitForContentChange, findFixAllAction, requestFixAll, @@ -57,7 +58,10 @@ suite('rslint fixAll - error flows', function () { editor, "const pVal: string = 'x';\nconst pRes = (pVal as string).trim();\n", ); - const probeDiags = await waitForDiagnostics(doc); + const probeDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assert.ok( probeDiags.some((d) => d.message.includes('no-unnecessary-type-assertion'), diff --git a/packages/vscode/e2e/lint/suite/fixall-helpers.ts b/packages/vscode/e2e/lint/suite/fixall-helpers.ts index 998a223..71606c7 100644 --- a/packages/vscode/e2e/lint/suite/fixall-helpers.ts +++ b/packages/vscode/e2e/lint/suite/fixall-helpers.ts @@ -19,6 +19,29 @@ import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry export { saveDocumentOnce } from '../utils/codeActionRegistry'; export const waitForDiagnostics = waitForRslintDiagnostics; + +/** + * Wait until the rslint diagnostics for `doc` include every given message + * substring. + * + * Deviation from the upstream suites, which assert on the first non-empty + * publish: since @rslint/core 0.8.1 (web-infra-dev/rslint#1790), a file + * created after its project was loaded is served by a type-info-less fallback + * Program until a watcher event admits it into the configured project, so the + * first non-empty publish may carry only non-type-aware rules on platforms + * with slow file watchers (macOS). Waiting for the expected diagnostics keeps + * the terminal assertion identical without depending on publish batching. + */ +export function waitForDiagnosticsWithMessages( + doc: vscode.TextDocument, + ...messages: string[] +): Promise { + return waitForRslintDiagnostics(doc, (diagnostics) => + messages.every((message) => + diagnostics.some((diagnostic) => diagnostic.message.includes(message)), + ), + ); +} export const waitForDiagnosticsCount = waitForRslintDiagnosticsCount; export const waitForDiagnosticsToChange = waitForRslintDiagnosticsToChange; diff --git a/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts b/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts index 4e658ce..b094801 100644 --- a/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall-onsave.test.ts @@ -1,4 +1,5 @@ -// Ported verbatim from web-infra-dev/rslint +// Ported from web-infra-dev/rslint (deviation: setup waits go through +// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall-onsave.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; @@ -6,6 +7,7 @@ import { getRslintDiagnostics } from '../utils/diagnostics'; import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; import { waitForDiagnostics, + waitForDiagnosticsWithMessages, waitForDiagnosticsCount, waitForContentChange, withOnSaveFixAll, @@ -38,7 +40,10 @@ suite('rslint fixAll - on-save', function () { "const gfVal: string = 'x';\nconst gfRes = (gfVal as string).trim();\n", ); - const diags = await waitForDiagnostics(doc); + const diags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assertHasFixableDiagnostic(diags, 'generic source.fixAll setup'); await saveDocumentOnce( @@ -69,7 +74,10 @@ suite('rslint fixAll - on-save', function () { ].join('\n'); await replaceAll(editor, fixableContent); - const diags = await waitForDiagnostics(doc); + const diags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assertHasFixableDiagnostic(diags, 'fixable on-save setup'); await saveDocumentOnce(doc, 'Fixable document should save'); @@ -93,7 +101,10 @@ suite('rslint fixAll - on-save', function () { editor, "const probeVal: string = 'x';\nconst probeRes = (probeVal as string).trim();\n", ); - const probeDiags = await waitForDiagnostics(doc); + const probeDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assertHasFixableDiagnostic(probeDiags, 'clean-file probe setup'); await saveDocumentOnce(doc, 'Clean-file probe should save'); await waitForContentChange( @@ -130,7 +141,10 @@ suite('rslint fixAll - on-save', function () { editor, "const probeVal2: string = 'x';\nconst probeRes2 = (probeVal2 as string).trim();\n", ); - const probeDiags = await waitForDiagnostics(doc); + const probeDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assertHasFixableDiagnostic(probeDiags, 'non-fixable probe setup'); await saveDocumentOnce(doc, 'Non-fixable probe should save'); await waitForContentChange( diff --git a/packages/vscode/e2e/lint/suite/fixall.test.ts b/packages/vscode/e2e/lint/suite/fixall.test.ts index 42803be..f8f3c05 100644 --- a/packages/vscode/e2e/lint/suite/fixall.test.ts +++ b/packages/vscode/e2e/lint/suite/fixall.test.ts @@ -1,9 +1,11 @@ -// Ported verbatim from web-infra-dev/rslint +// Ported from web-infra-dev/rslint (deviation: setup waits go through +// waitForDiagnosticsWithMessages -- see fixall-helpers.ts for why). // `packages/vscode-extension/__tests__/suite/fixall.test.ts` (origin/main). import * as assert from 'assert'; import * as vscode from 'vscode'; import { waitForDiagnostics, + waitForDiagnosticsWithMessages, waitForDiagnosticsToChange, waitForDiagnosticsCount, openFixture, @@ -127,7 +129,10 @@ suite('rslint fixAll - code actions', function () { const fixableContent = "const frVal: string = 'hello';\nconst frRes = (frVal as string).toUpperCase();\n"; await withTmpFile(fixableContent, async (doc) => { - const initialDiags = await waitForDiagnostics(doc); + const initialDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); assert.ok(initialDiags.length > 0, 'Should have initial diagnostics'); const fixableDiags = initialDiags.filter((d) => @@ -166,7 +171,11 @@ suite('rslint fixAll - code actions', function () { '', ].join('\n'); await withTmpFile(mixedContent, async (doc) => { - const initialDiags = await waitForDiagnostics(doc); + const initialDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + 'no-unsafe', + ); assert.ok(initialDiags.length > 0, 'Should have diagnostics'); const fixableBefore = initialDiags.filter((d) => @@ -248,7 +257,10 @@ suite('rslint fixAll - code actions', function () { const fixableContent = "const sfVal: string = 'x';\nconst sfRes = (sfVal as string).trim();\n"; await withTmpFile(fixableContent, async (doc) => { - const initialDiags = await waitForDiagnostics(doc); + const initialDiags = await waitForDiagnosticsWithMessages( + doc, + 'no-unnecessary-type-assertion', + ); const fixableCount = initialDiags.filter((d) => d.message.includes('no-unnecessary-type-assertion'), ).length; From a29838dc59ceb188bf8e427c5572a13f45b8e749 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 20 Aug 2026 16:24:20 +0800 Subject: [PATCH 2/2] test(vscode): port new Rslint E2E suites --- .../fixtures/import-cycle/rslint.config.mjs | 18 ++ .../e2e/lint/fixtures/import-cycle/src/a.ts | 7 + .../e2e/lint/fixtures/import-cycle/src/b.ts | 7 + .../e2e/lint/fixtures/import-cycle/src/c.ts | 11 + .../lint/fixtures/import-cycle/src/leaf.ts | 5 + .../lint/fixtures/import-cycle/tsconfig.json | 9 + .../rule-option-types/rslint.config.ts | 41 ++++ .../fixtures/rule-option-types/src/index.ts | 2 + .../fixtures/rule-option-types/tsconfig.json | 10 + packages/vscode/e2e/lint/runTest.ts | 10 + .../suite-import-cycle/import-cycle.test.ts | 217 ++++++++++++++++++ .../e2e/lint/suite-import-cycle/index.ts | 3 + .../e2e/lint/suite-rule-option-types/index.ts | 3 + .../rule-option-types.test.ts | 115 ++++++++++ 14 files changed, 458 insertions(+) create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/src/a.ts create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/src/b.ts create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/src/c.ts create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/src/leaf.ts create mode 100644 packages/vscode/e2e/lint/fixtures/import-cycle/tsconfig.json create mode 100644 packages/vscode/e2e/lint/fixtures/rule-option-types/rslint.config.ts create mode 100644 packages/vscode/e2e/lint/fixtures/rule-option-types/src/index.ts create mode 100644 packages/vscode/e2e/lint/fixtures/rule-option-types/tsconfig.json create mode 100644 packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts create mode 100644 packages/vscode/e2e/lint/suite-import-cycle/index.ts create mode 100644 packages/vscode/e2e/lint/suite-rule-option-types/index.ts create mode 100644 packages/vscode/e2e/lint/suite-rule-option-types/rule-option-types.test.ts diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/import-cycle/rslint.config.mjs new file mode 100644 index 0000000..a5f7dd7 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/rslint.config.mjs @@ -0,0 +1,18 @@ +// Upstream uses rslint.json. This equivalent native config is intentional: +// deprecated JSON configs are not supported by the unified extension. +export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + 'import/no-cycle': 'error', + 'no-var': 'error', + }, + plugins: ['import'], + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/src/a.ts b/packages/vscode/e2e/lint/fixtures/import-cycle/src/a.ts new file mode 100644 index 0000000..67b869c --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/src/a.ts @@ -0,0 +1,7 @@ +import { fromB } from './b'; + +export var witnessA = 1; + +export function fromA(): number { + return fromB() + witnessA; +} diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/src/b.ts b/packages/vscode/e2e/lint/fixtures/import-cycle/src/b.ts new file mode 100644 index 0000000..077be5b --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/src/b.ts @@ -0,0 +1,7 @@ +import { fromC } from './c'; + +export var witnessB = 1; + +export function fromB(): number { + return fromC() + witnessB; +} diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/src/c.ts b/packages/vscode/e2e/lint/fixtures/import-cycle/src/c.ts new file mode 100644 index 0000000..8491c6d --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/src/c.ts @@ -0,0 +1,11 @@ +import { fromA } from './a'; + +export var witnessC = 1; + +export function fromC(): number { + return witnessC; +} + +export function backToA(): number { + return fromA(); +} diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/src/leaf.ts b/packages/vscode/e2e/lint/fixtures/import-cycle/src/leaf.ts new file mode 100644 index 0000000..f1b03be --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/src/leaf.ts @@ -0,0 +1,5 @@ +export var witnessLeaf = 1; + +export function leaf(): number { + return witnessLeaf; +} diff --git a/packages/vscode/e2e/lint/fixtures/import-cycle/tsconfig.json b/packages/vscode/e2e/lint/fixtures/import-cycle/tsconfig.json new file mode 100644 index 0000000..8420c26 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/import-cycle/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/fixtures/rule-option-types/rslint.config.ts b/packages/vscode/e2e/lint/fixtures/rule-option-types/rslint.config.ts new file mode 100644 index 0000000..021bd5e --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/rule-option-types/rslint.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from '@rslint/core'; + +export default defineConfig([ + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + 'no-console': ['error', { allow: ['warn'] }], + }, + }, +]); + +// rslint validates every config entry's rule options at load time +// regardless of `files`, so an invalid entry can't live in the array above +// without breaking extension activation. rslint's config loader only reads +// the module's default export, so this named export is never loaded — it +// exists purely for TypeScript to type-check. +export const typeCheckOnly = defineConfig([ + { + rules: { + // @ts-expect-error `allow` must be a string[], not a number. + 'no-console': ['error', { allow: 123 }], + // A plugin rule, whose generated type name is derived from a rule ID + // carrying both a scope and digits — the shape most likely to drift + // between the name `RulesRecord` references and the name the generated + // declaration actually uses. + // @ts-expect-error `ignoreNonDOM` must be a boolean, not a string. + 'jsx-a11y/no-autofocus': ['error', { ignoreNonDOM: 'yes' }], + }, + }, +]); + +// Unsuppressed type error — the e2e test's readiness signal that TypeScript +// finished analyzing this file, since the error above is swallowed by +// `@ts-expect-error` on success. `export`ed so it isn't flagged as unused. +export const tsReadySentinel: string = 123; diff --git a/packages/vscode/e2e/lint/fixtures/rule-option-types/src/index.ts b/packages/vscode/e2e/lint/fixtures/rule-option-types/src/index.ts new file mode 100644 index 0000000..7e6d2f2 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/rule-option-types/src/index.ts @@ -0,0 +1,2 @@ +console.log('not allowed by rslint.config.ts'); +console.warn('allowed by rslint.config.ts'); diff --git a/packages/vscode/e2e/lint/fixtures/rule-option-types/tsconfig.json b/packages/vscode/e2e/lint/fixtures/rule-option-types/tsconfig.json new file mode 100644 index 0000000..e9cdcd1 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/rule-option-types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "rslint.config.ts"] +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 3015856..11a5a87 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -279,6 +279,16 @@ async function main(): Promise { workspace: fixture('eslint-plugins'), tests: suiteDir('suite-eslint-plugins'), }, + { + name: 'Generated rule-option-types tests', + workspace: fixture('rule-option-types'), + tests: suiteDir('suite-rule-option-types'), + }, + { + name: 'import/no-cycle tests', + workspace: fixture('import-cycle'), + tests: suiteDir('suite-import-cycle'), + }, { name: 'Rstack lint bridge tests', workspace: sharedFixture('rstack'), diff --git a/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts b/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts new file mode 100644 index 0000000..a736d7e --- /dev/null +++ b/packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts @@ -0,0 +1,217 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-import-cycle/import-cycle.test.ts` +// at 760c4135. Assertion semantics are unchanged. The upstream fixture's +// deprecated rslint.json is an intentional deviation: this extension does not +// support JSON configs, so it uses an equivalent rslint.config.mjs. +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { closeTextEditor } from '../utils/documents'; + +/** + * `import/no-cycle` is the one rule whose answer for a file depends on every + * other file of the program, and its cross-file structures are cached per + * Program. In the editor that cache is exercised the hard way: every buffer + * edit produces a new Program, the entry keyed by the old one becomes + * unreachable, and the next lint must rebuild from the new Program's overlay + * text — never answer from the graph of a Program that no longer describes + * the workspace. + * + * The fixture is a three-file cycle, `a.ts => b.ts => c.ts => a.ts`, plus an + * acyclic `leaf.ts`. Every file declares a `var`, so `no-var` marks each + * publish that reaches the client and an assertion of "no cycle reported" + * always runs against a pass that demonstrably linted the file. + * + * The edits below stay in the editor buffer and are never saved: the files on + * disk hold the cycle throughout, and only the overlay the language server + * mirrors changes. A report that clears — or returns — therefore proves the + * lint answered from the current overlay Program, not from any cached graph + * of a previous one. + */ +suite('rslint import/no-cycle over LSP', function () { + this.timeout(120000); + + const cycleMarker = '[import/no-cycle]'; + const sentinelMarker = '[no-var]'; + + const brokenC = [ + 'export var witnessC = 1;', + '', + 'export function fromC(): number {', + ' return witnessC;', + '}', + '', + ].join('\n'); + + let touchCount = 0; + const openedDocuments = new Set(); + let originalC: string | undefined; + + function workspaceRoot(): string { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) throw new Error('VS Code test workspace is unavailable'); + return folder.uri.fsPath; + } + + async function openFixture(filename: string): Promise { + const doc = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'src', filename), + ); + await vscode.window.showTextDocument(doc, { preview: false }); + openedDocuments.add(doc); + return doc; + } + + function cycleDiagnostics( + diagnostics: vscode.Diagnostic[], + ): vscode.Diagnostic[] { + return diagnostics.filter((d) => d.message.includes(cycleMarker)); + } + + function isLintedPass(diagnostics: vscode.Diagnostic[]): boolean { + return diagnostics.some((d) => d.message.includes(sentinelMarker)); + } + + /** Replaces the whole buffer, leaving the document dirty and unsaved. */ + async function replaceDocumentText( + doc: vscode.TextDocument, + text: string, + ): Promise { + const editor = await vscode.window.showTextDocument(doc, { + preview: false, + }); + const fullRange = new vscode.Range( + new vscode.Position(0, 0), + doc.lineAt(doc.lineCount - 1).range.end, + ); + const applied = await editor.edit((edit) => edit.replace(fullRange, text)); + assert.ok(applied, `could not replace the content of ${doc.uri}`); + } + + /** + * Diagnostics are published per document and only when that document is + * linted again, so an edit elsewhere does not repaint this file by itself. + * Appending a comment line is the editor gesture that forces the next pass — + * and, being an edit, it also forces that pass onto yet another new Program. + */ + async function touchAndWait( + doc: vscode.TextDocument, + predicate: (diagnostics: vscode.Diagnostic[]) => boolean, + ): Promise { + const editor = await vscode.window.showTextDocument(doc, { + preview: false, + }); + touchCount += 1; + const appended = await editor.edit((edit) => + edit.insert( + new vscode.Position(doc.lineCount, 0), + `// relint ${touchCount}\n`, + ), + ); + assert.ok(appended, `could not touch ${doc.uri}`); + return waitForRslintDiagnostics(doc, predicate); + } + + suiteTeardown(async () => { + for (const doc of openedDocuments) { + await closeTextEditor(doc); + } + }); + + test('every member of the cycle reports it, with the route as written', async () => { + const docA = await openFixture('a.ts'); + const diagnosticsA = await waitForRslintDiagnostics( + docA, + (all) => cycleDiagnostics(all).length > 0, + ); + const [cycleA] = cycleDiagnostics(diagnosticsA); + assert.ok( + cycleA.message.includes('Dependency cycle via ./c:1'), + `a.ts should report the route through b.ts's import, got: ${cycleA.message}`, + ); + assert.strictEqual( + cycleA.range.start.line, + 0, + 'the report should sit on the import declaration', + ); + + const docB = await openFixture('b.ts'); + const diagnosticsB = await waitForRslintDiagnostics( + docB, + (all) => cycleDiagnostics(all).length > 0, + ); + assert.ok( + cycleDiagnostics(diagnosticsB)[0].message.includes( + 'Dependency cycle via ./a:1', + ), + `b.ts should report its own route, got: ${diagnosticsB + .map((d) => d.message) + .join(' | ')}`, + ); + }); + + test('an acyclic file of the same program stays clean while demonstrably linted', async () => { + const doc = await openFixture('leaf.ts'); + const diagnostics = await waitForRslintDiagnostics(doc, isLintedPass); + assert.deepStrictEqual( + cycleDiagnostics(diagnostics).map((d) => d.message), + [], + 'leaf.ts imports nothing and must not be caught in the cycle', + ); + }); + + test('an unsaved edit two hops away clears the report, and its revert restores it', async () => { + const docA = await openFixture('a.ts'); + await waitForRslintDiagnostics( + docA, + (all) => cycleDiagnostics(all).length > 0, + ); + + // Break the cycle at its far end: a.ts keeps its import of b.ts, but the + // route back from c.ts disappears from the overlay only. + const docC = await openFixture('c.ts'); + originalC = docC.getText(); + await replaceDocumentText(docC, brokenC); + const diagnosticsC = await waitForRslintDiagnostics( + docC, + (all) => isLintedPass(all) && cycleDiagnostics(all).length === 0, + ); + assert.ok( + isLintedPass(diagnosticsC), + 'c.ts should have been re-linted from its edited buffer', + ); + + // 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. + const clearedA = await touchAndWait( + docA, + (all) => isLintedPass(all) && cycleDiagnostics(all).length === 0, + ); + assert.deepStrictEqual( + cycleDiagnostics(clearedA).map((d) => d.message), + [], + 'a.ts must stop reporting once the overlay no longer closes the cycle', + ); + + // Put the import back, still without saving: the report must return just + // as promptly, proving the cleared answer was not cached against a.ts. + await replaceDocumentText(docC, originalC); + await waitForRslintDiagnostics( + docC, + (all) => cycleDiagnostics(all).length > 0, + ); + const restoredA = await touchAndWait( + docA, + (all) => cycleDiagnostics(all).length > 0, + ); + assert.ok( + cycleDiagnostics(restoredA)[0].message.includes( + 'Dependency cycle via ./c:1', + ), + `the restored report should carry the same route, got: ${restoredA + .map((d) => d.message) + .join(' | ')}`, + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-import-cycle/index.ts b/packages/vscode/e2e/lint/suite-import-cycle/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-import-cycle/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-rule-option-types/index.ts b/packages/vscode/e2e/lint/suite-rule-option-types/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-rule-option-types/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-rule-option-types/rule-option-types.test.ts b/packages/vscode/e2e/lint/suite-rule-option-types/rule-option-types.test.ts new file mode 100644 index 0000000..8d09dfa --- /dev/null +++ b/packages/vscode/e2e/lint/suite-rule-option-types/rule-option-types.test.ts @@ -0,0 +1,115 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-rule-option-types/rule-option-types.test.ts` +// at 760c4135. +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import { waitForRslintDiagnostics } from '../utils/diagnostics'; + +// Exercises the types generated by the generate-rule-option-types rslib +// plugin (packages/rslint/plugins/generate-rule-option-types.ts) end to end: +// the built dist/index.d.ts must actually constrain a rule's options in the +// editor (TypeScript diagnostics on rslint.config.ts), and a value that +// type-checks must actually reach the Go rule at lint time (rslint +// diagnostics on the linted source file). +suite('rslint generated rule-option types', function () { + this.timeout(120_000); + + function getWorkspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + function nonRslintDiagnostics(uri: vscode.Uri): vscode.Diagnostic[] { + return vscode.languages + .getDiagnostics(uri) + .filter((diagnostic) => diagnostic.source !== 'rslint'); + } + + async function waitForNonRslintDiagnostics( + uri: vscode.Uri, + predicate: (diagnostics: vscode.Diagnostic[]) => boolean, + timeoutMs = 60_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + let diagnostics = nonRslintDiagnostics(uri); + while (!predicate(diagnostics)) { + if (Date.now() > deadline) { + throw new Error( + `Timed out after ${timeoutMs}ms waiting for TypeScript diagnostics on ${uri.toString()}. ` + + `Last diagnostics: ${diagnostics.map((d) => `${d.source ?? ''}: ${d.message}`).join(' | ') || ''}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + diagnostics = nonRslintDiagnostics(uri); + } + return diagnostics; + } + + test('a valid typed rule option actually configures the Go rule at lint time', async () => { + const filePath = path.join(getWorkspaceRoot(), 'src', 'index.ts'); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForRslintDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('Unexpected console statement')), + ); + + const consoleLogLine = doc.lineAt(0); + const consoleWarnLine = doc.lineAt(1); + assert.ok( + diagnostics.some( + (d) => + d.message.includes('Unexpected console statement') && + d.range.start.line === consoleLogLine.lineNumber, + ), + 'console.log must be reported: `allow` only lists "warn"', + ); + assert.ok( + !diagnostics.some( + (d) => + d.message.includes('Unexpected console statement') && + d.range.start.line === consoleWarnLine.lineNumber, + ), + 'console.warn must NOT be reported: it is in the configured `allow` list', + ); + }); + + test('an invalid typed rule option is rejected by TypeScript in the config file', async () => { + const configPath = path.join(getWorkspaceRoot(), 'rslint.config.ts'); + const doc = await vscode.workspace.openTextDocument(configPath); + await vscode.window.showTextDocument(doc); + + // `tsReadySentinel` at the bottom of the fixture is an unconditional, + // unsuppressed type error — wait for it specifically rather than for + // "any diagnostic", since the intentional error under test is expected + // to be swallowed by `@ts-expect-error` and would otherwise never make + // diagnostics non-empty. + const diagnostics = await waitForNonRslintDiagnostics(doc.uri, (diags) => + diags.some((d) => + String(d.message).includes("not assignable to type 'string'"), + ), + ); + + assert.ok( + !diagnostics.some((d) => + String(d.message).includes('Cannot find module'), + ), + `'@rslint/core' must resolve so its generated types are actually in effect. Diagnostics: ${diagnostics.map((d) => d.message).join(' | ')}`, + ); + // Every `@ts-expect-error` in the fixture marks an option value that the + // rule's JSON Schema rejects, so each one must be triggered. An unused one + // means that rule's generated type stopped constraining its options — + // typically because `RulesRecord` references a type name that no + // declaration in the emitted `.d.ts` actually defines, which silently + // degrades the options to `any`. + const unusedExpectErrorLines = diagnostics + .filter((d) => String(d.message).includes("Unused '@ts-expect-error'")) + .map((d) => d.range.start.line + 1); + assert.deepStrictEqual( + unusedExpectErrorLines, + [], + 'generated rule-option types stopped constraining options for the rule(s) ' + + `configured at rslint.config.ts line(s) ${unusedExpectErrorLines.join(', ')}`, + ); + }); +});