diff --git a/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts index 44c7649..313d79c 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts @@ -21,9 +21,10 @@ // for a shared one; this host loads nothing and caches nothing, so "the same // installation" is the same runtime key and a structurally equal record. // - Upstream's "rejects a core package whose binary is missing" has no -// host-side analogue (the binary is the worker's business). It is replaced -// by the rejection this host actually performs: the SUPPORT_MATRIX version -// floor. Same shape, same count, the check that exists here. +// host-side analogue (the binary is the worker's business), so this host +// replaces it with the rejection it actually performs: the SUPPORT_MATRIX +// version floor. The binary check itself is pinned where it lives now, in +// `tests/stacks/lint/workerCore.test.ts` (`loadCoreInstallation`). import * as assert from 'node:assert'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -303,6 +304,33 @@ suite('local core resolver', () => { await assertSamePhysicalPath(named, packageDirectory); }); + test('rejects an unsupported configured corePath', async () => { + // Same gate as the node_modules case above, on the branch that applies + // `rstack.rslint.corePath`: the override must not bypass SUPPORT_MATRIX. + // A version below the current floor (`@rslint/core >= 0.8.0`; upstream's + // 0.10.0 is supported here now). + const packageDirectory = await createCore( + path.join(temporaryDirectory, 'vendor', 'core'), + '0.7.3', + ); + const document = await createSource('src/index.ts'); + + let failure: unknown; + try { + await new CoreResolver().resolve(document, temporaryWorkspaceFolder(), { + mode: 'native', + corePath: './vendor/core', + }); + } catch (error) { + failure = error; + } + assert.ok(failure instanceof Error, 'expected the resolver to reject'); + assert.ok(failure.message.includes('0.7.3'), failure.message); + const named = /\(([^()]+)\)$/.exec(failure.message)?.[1]; + assert.ok(named, `expected a core directory in: ${failure.message}`); + await assertSamePhysicalPath(named, packageDirectory); + }); + test('does not execute a Yarn PnP resolver as a fallback', async () => { await fs.writeFile( path.join(temporaryDirectory, '.pnp.cjs'), diff --git a/packages/vscode/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts index 97f4162..71b57aa 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts @@ -122,6 +122,14 @@ suite('Rslint lifecycle', () => { const outputChannel = window.createOutputChannel( `Rslint lifecycle hang ${Date.now()}`, ); + // Upstream's assertion that the forced close leaves no rejection behind: + // the hung `initialize` tail must settle as a rejection, not surface as an + // unhandled one. + const unhandledRejections: unknown[] = []; + const recordUnhandledRejection = (reason: unknown): void => { + unhandledRejections.push(reason); + }; + process.on('unhandledRejection', recordUnhandledRejection); let closing = false; let spawnCount = 0; let initializeReceived!: () => void; @@ -155,28 +163,39 @@ suite('Rslint lifecycle', () => { }, ); - const startPromise = client.start(); - void startPromise.catch(() => undefined); - await waitForPromiseSettlement( - receivedInitialize, - 2_000, - 'initialize request probe', - ); - assert.strictEqual(client.state, State.Starting); - - closing = true; - owner.beginClose(); - await disposeLanguageClient(client); - await owner.close(); - await waitForPromiseSettlement( - startPromise, - 2_000, - 'hung language client start', - ); - - assert.strictEqual(client.state, State.Stopped); - assert.strictEqual(spawnCount, 1, 'closing must suppress restart'); - outputChannel.dispose(); + try { + const startPromise = client.start(); + void startPromise.catch(() => undefined); + await waitForPromiseSettlement( + receivedInitialize, + 2_000, + 'initialize request probe', + ); + assert.strictEqual(client.state, State.Starting); + + closing = true; + owner.beginClose(); + await disposeLanguageClient(client); + await owner.close(); + await waitForPromiseSettlement( + startPromise, + 2_000, + 'hung language client start', + ); + await assert.rejects(startPromise); + + assert.strictEqual(client.state, State.Stopped); + assert.strictEqual(spawnCount, 1, 'closing must suppress restart'); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + assert.deepStrictEqual(unhandledRejections, []); + } finally { + process.off('unhandledRejection', recordUnhandledRejection); + closing = true; + owner.beginClose(); + await disposeLanguageClient(client).catch(() => undefined); + await owner.close(); + outputChannel.dispose(); + } }); test('resets document sessions as soon as a Running server exits', () => { diff --git a/packages/vscode/tests/stacks/lint/workerCore.test.ts b/packages/vscode/tests/stacks/lint/workerCore.test.ts new file mode 100644 index 0000000..4921188 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/workerCore.test.ts @@ -0,0 +1,88 @@ +/** + * `loadCoreInstallation` is the worker's last hop: it loads the project's + * `@rslint/core` and refuses a package that cannot produce a runnable LSP + * binary. Upstream asserted this in `CoreResolver`, because the extension host + * loaded the core there; since rslint #1617's worker split the check lives in + * `worker/core.ts`, and the E2E resolver suite cannot reach it (the host never + * loads project code — ADR 0003). The stub package keeps this a unit test: the + * real core is a fixture dependency, not a unit-test one. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from '@rstest/core'; +import { loadCoreInstallation } from '../../../src/stacks/lint/worker/core'; +import { + removeTemporaryDirectories, + temporaryDirectory, +} from './packageFixtures'; + +afterEach(removeTemporaryDirectories); + +/** + * A stub `@rslint/core` with only the shape `loadCoreInstallation` validates, + * whose `resolveRslintBinary()` answers `binaryPath` — so the binary guard is + * what the test exercises. The self-referencing `exports` map is what lets the + * package's own `createRequire` resolve `@rslint/core/config-loader`, exactly + * as the published package does. + */ +function writeStubCore(root: string, binaryPath: string): string { + const coreDirectory = path.join(root, 'node_modules', '@rslint', 'core'); + fs.mkdirSync(coreDirectory, { recursive: true }); + fs.writeFileSync( + path.join(coreDirectory, 'package.json'), + JSON.stringify({ + name: '@rslint/core', + version: '0.9.0', + exports: { + '.': './index.mjs', + './config-loader': './config-loader.mjs', + './eslint-plugin': './eslint-plugin.mjs', + }, + }), + ); + fs.writeFileSync( + path.join(coreDirectory, 'config-loader.mjs'), + [ + 'export class ConfigModuleHost {', + ' constructor(options) {', + ' this.options = options;', + ' }', + '}', + 'export const CONFIG_DISCOVERY_PROTOCOL_VERSION = 3;', + 'export const loadConfigFileFresh = async () => ({});', + 'export function resolveRslintBinary() {', + ` return ${JSON.stringify(binaryPath)};`, + '}', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(coreDirectory, 'eslint-plugin.mjs'), + 'export function createPluginLintHost() {\n return {};\n}\n', + ); + fs.writeFileSync(path.join(coreDirectory, 'index.mjs'), 'export {};\n'); + return coreDirectory; +} + +describe('lint worker core installation', () => { + it('rejects a core package whose binary is missing', async () => { + const root = temporaryDirectory('rslint-worker-core-'); + const binaryPath = path.join(root, 'missing', 'rslint'); + const coreDirectory = writeStubCore(root, binaryPath); + + await expect(loadCoreInstallation(coreDirectory)).rejects.toThrow( + `Rslint binary does not exist at ${binaryPath}`, + ); + }); + + it('rejects a core package whose binary is not a file', async () => { + const root = temporaryDirectory('rslint-worker-core-'); + const binaryPath = path.join(root, 'rslint-directory'); + fs.mkdirSync(binaryPath); + const coreDirectory = writeStubCore(root, binaryPath); + + await expect(loadCoreInstallation(coreDirectory)).rejects.toThrow( + `Rslint binary is not a file: ${binaryPath}`, + ); + }); +});