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
34 changes: 31 additions & 3 deletions packages/vscode/e2e/lint/suite-jsconfig/core-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
Expand Down
63 changes: 41 additions & 22 deletions packages/vscode/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down
88 changes: 88 additions & 0 deletions packages/vscode/tests/stacks/lint/workerCore.test.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
});
});
Loading