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(', ')}`, + ); + }); +});