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
18 changes: 18 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/rslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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'],
},
];
7 changes: 7 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/src/a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { fromB } from './b';

export var witnessA = 1;

export function fromA(): number {
return fromB() + witnessA;
}
7 changes: 7 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/src/b.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { fromC } from './c';

export var witnessB = 1;

export function fromB(): number {
return fromC() + witnessB;
}
11 changes: 11 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/src/c.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fromA } from './a';

export var witnessC = 1;

export function fromC(): number {
return witnessC;
}

export function backToA(): number {
return fromA();
}
5 changes: 5 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/src/leaf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export var witnessLeaf = 1;

export function leaf(): number {
return witnessLeaf;
}
9 changes: 9 additions & 0 deletions packages/vscode/e2e/lint/fixtures/import-cycle/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true
},
"include": ["src/**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('not allowed by rslint.config.ts');
console.warn('allowed by rslint.config.ts');
10 changes: 10 additions & 0 deletions packages/vscode/e2e/lint/fixtures/rule-option-types/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "rslint.config.ts"]
}
10 changes: 10 additions & 0 deletions packages/vscode/e2e/lint/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,16 @@ async function main(): Promise<void> {
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'),
Expand Down
217 changes: 217 additions & 0 deletions packages/vscode/e2e/lint/suite-import-cycle/import-cycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.TextDocument>();
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<vscode.TextDocument> {
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<void> {
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<vscode.Diagnostic[]> {
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(' | ')}`,
);
});
});
3 changes: 3 additions & 0 deletions packages/vscode/e2e/lint/suite-import-cycle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRun } from '../runSuite';

export const run = createRun();
3 changes: 3 additions & 0 deletions packages/vscode/e2e/lint/suite-rule-option-types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRun } from '../runSuite';

export const run = createRun();
Loading