diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0349267..d1ed108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,12 @@ Downloads, profiles and result JSON normally live under `artifacts/editor-host/` path exceeds Unix socket limits, set `TYPED_SQL_HOST_DATA_ROOT` to a shorter user-owned directory. Result JSON is also copied into `artifacts/editor-host/results/`; CI excludes downloads and full profiles from artifact uploads. Runs preserve their unique directories for diagnosis. +Application and extension updates are disabled in these profiles; a pre-launch CLI version +check rejects a mutated cache rather than mixing editor versions. For focused diagnosis, +`TYPED_SQL_HOST_SCENARIOS=postgres-extended` selects named scenarios (comma-separated); omitted +cells remain `not-run`. `TYPED_SQL_HOST_CAPTURE_PREVIEW=true` captures bounded upstream stderr +only for the generated extended fixtures, without changing production log redaction. Do not +point this diagnostic harness at private application projects. ### Shared editor/grammar hub @@ -86,11 +92,23 @@ adapters. These are representative integration fixtures, not all syntax or serve Each run writes `matrix.json` beside individual results, crossing VS Code/Zed, four grammars and the interface inventory. Missing and unimplemented cells stay `not-run`; protocol evidence is rejected as host evidence. Failures remain failures and prevent the host command from passing. -The current VS Code host executes eight checks per grammar plus three independent trust scenarios. +The VS Code host implements all 21 inventoried interfaces per grammar, plus three independent +trust scenarios and a controlled lifecycle scenario. Baseline, extended and built-in-coexistence +hosts have disjoint evidence; aggregation rejects duplicate interfaces or mixed host versions. +The extended hosts use a fresh tarball installation, not workspace-linked production packages. +Their five edit cases per grammar also produce `edit-matrix.json`: local/cross-file rename, +document/range formatting and a structural quick fix. Edits are validated against original +buffers, simulated, applied through VS Code and compared with the simulated result. Formatting +must preserve SQL and be idempotent; renames and quick fixes require exact intended changes. +Implemented scenarios are not automatically passing evidence: inspect each run's result JSON. +Each added interface keeps a 25-second eventual-observation bound; the extended process budget +allows every independent check to finish even after an earlier failure. No failed host is retried. An additional controlled-server host scenario tests missing-path recovery, configuration changes during startup, automatic process-crash recovery and configuration teardown. It asserts one active server process and one hover provider after configuration settles. This probes client ownership, -not SQL inference after restart; the per-grammar restart-recovery cells remain `not-run`. +not SQL inference after restart. Separate extended hosts verify real-server configuration restart +with unsaved SQL replay. Real abrupt-crash inference, remote hosts, stale edit rejection by the +editor, every upstream refactor and exhaustive grammar syntax remain outside this inventory. Schema lifecycle checks write the actual snapshot file while leaving the source buffer unchanged. PostgreSQL/MySQL/SQLite verify nullability refresh; every grammar verifies incompatible-envelope rejection, fail-closed inference and recovery after restoring the snapshot. The synthetic fixture diff --git a/editors/vscode/test/extended-suite.cjs b/editors/vscode/test/extended-suite.cjs new file mode 100644 index 0000000..eb1a152 --- /dev/null +++ b/editors/vscode/test/extended-suite.cjs @@ -0,0 +1,332 @@ +const assert = require("node:assert/strict"); +const { readFileSync, realpathSync, writeFileSync } = require("node:fs"); +const { join, relative, isAbsolute } = require("node:path"); +const vscode = require("vscode"); + +exports.run = async () => { + const { grammarCases, sourceFor } = await import("../../../test/editor-hub/cases.mjs"); + const { pendingInterfaces } = await import("../../../test/editor-hub/matrix.mjs"); + const { editCases } = await import("../../../test/editor-hub/edit-matrix.mjs"); + const { runExtendedScenario } = await import("../../../test/editor-hub/extended-scenario.mjs"); + const spec = grammarCases.find((item) => item.id === process.env.TYPED_SQL_HOST_GRAMMAR); + assert.ok(spec); + const coexist = process.env.TYPED_SQL_HOST_MODE === "coexist"; + const owned = coexist ? ["builtin-coexistence"] : pendingInterfaces.filter((id) => id !== "builtin-coexistence"); + const report = { + editor: "vscode", + grammar: spec.id, + mode: coexist ? "coexist" : "extended", + vscode: vscode.version, + evidence: "actual-host", + passed: false, + checks: Object.fromEntries(owned.map((id) => [id, { status: "not-run" }])), + editMatrix: coexist ? {} : Object.fromEntries(editCases.map((id) => [id, { status: "not-run" }])), + }; + const save = () => writeFileSync(process.env.TYPED_SQL_HOST_REPORT, JSON.stringify(report)); + save(); + const root = vscode.workspace.workspaceFolders[0].uri; + const primary = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(root, "query.ts")); + let document = primary; + const tracked = new Map([[primary.uri.toString(), primary]]); + const position = (doc, needle, amount = 0) => { + const index = doc.getText().indexOf(needle); + assert.notEqual(index, -1, needle); + return doc.positionAt(index + amount); + }; + const fullRange = (doc) => new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); + const replace = async (doc, text) => { + const edit = new vscode.WorkspaceEdit(); + edit.replace(doc.uri, fullRange(doc), text); + assert.equal(await vscode.workspace.applyEdit(edit), true); + assert.equal(doc.getText(), text); + }; + const open = async (name, text) => { + const uri = vscode.Uri.joinPath(root, name); + await vscode.workspace.fs.writeFile(uri, Buffer.from(text)); + const doc = await vscode.workspace.openTextDocument(uri); + await replace(doc, text); + tracked.set(uri.toString(), doc); + return doc; + }; + const hover = async (doc, needle, amount = 0) => { + const result = await vscode.commands.executeCommand( + "vscode.executeHoverProvider", + doc.uri, + position(doc, needle, amount), + ); + return (result ?? []).flatMap((item) => item.contents.map((content) => content.value ?? content)).join("\n"); + }; + const serialRange = (range) => ({ + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + }); + const editsFor = (edit) => { + assert.ok(edit && typeof edit.entries === "function", "workspace edit required"); + const entries = edit.entries(); + assert.equal(entries.reduce((count, [, edits]) => count + edits.length, 0) > 0, true); + return Object.fromEntries( + entries.map(([uri, edits]) => [ + uri.toString(), + edits.map((item) => ({ range: serialRange(item.range), newText: item.newText })), + ]), + ); + }; + const host = { + async reset(source) { + for (const doc of tracked.values()) if (doc !== primary) await replace(doc, ""); + tracked.clear(); + tracked.set(primary.uri.toString(), primary); + document = primary; + await replace(primary, source); + await vscode.window.showTextDocument(primary); + }, + hover: (needle, amount) => hover(document, needle, amount), + async sources() { + return Object.fromEntries([...tracked].map(([uri, doc]) => [uri, doc.getText()])); + }, + async completions(needle, amount) { + const result = await vscode.commands.executeCommand( + "vscode.executeCompletionItemProvider", + document.uri, + position(document, needle, amount), + ); + return (result?.items ?? []).map((item) => (typeof item.label === "string" ? item.label : item.label.label)); + }, + async diagnostics() { + return vscode.languages.getDiagnostics(document.uri).map((item) => ({ + line: item.range.start.line, + error: item.severity === vscode.DiagnosticSeverity.Error, + message: item.message, + source: item.source, + code: typeof item.code === "object" ? item.code.value : item.code, + })); + }, + async crossFile(source) { + await this.reset(`${source}\nexport const sharedValue = 1; void sharedValue;\n`); + const other = await open( + "consumer.ts", + `${source}\nimport { sharedValue } from "./query.js"; void sharedValue;\n`, + ); + await vscode.window.showTextDocument(other); + await hover(other, "void sharedValue", "void ".length); + await vscode.window.showTextDocument(primary); + return this.sources(); + }, + async references(symbol) { + return ( + (await vscode.commands.executeCommand( + "vscode.executeReferenceProvider", + document.uri, + position(document, symbol), + )) ?? [] + ).map((item) => ({ uri: item.uri.toString(), range: serialRange(item.range) })); + }, + locationOrder: (a, b) => + a.uri.localeCompare(b.uri) || + a.range.start.line - b.range.start.line || + a.range.start.character - b.range.start.character, + occurrences(texts, symbol) { + return Object.entries(texts).flatMap(([uri, text]) => + [...text.matchAll(new RegExp(`\\b${symbol}\\b`, "g"))].map((match) => { + const doc = tracked.get(uri); + return { + uri, + range: serialRange( + new vscode.Range(doc.positionAt(match.index), doc.positionAt(match.index + symbol.length)), + ), + }; + }), + ); + }, + async rename(symbol, replacement) { + return editsFor( + await vscode.commands.executeCommand( + "vscode.executeDocumentRenameProvider", + document.uri, + position(document, symbol), + replacement, + ), + ); + }, + async apply(edits, before, after) { + const operation = new vscode.WorkspaceEdit(); + for (const [uri, text] of Object.entries(before)) + assert.equal(tracked.get(uri).getText(), text, "reject stale source snapshots before applying edits"); + for (const [uri, changes] of Object.entries(edits)) + for (const change of changes) + operation.replace( + vscode.Uri.parse(uri), + new vscode.Range( + change.range.start.line, + change.range.start.character, + change.range.end.line, + change.range.end.character, + ), + change.newText, + ); + assert.equal(await vscode.workspace.applyEdit(operation), true); + for (const [uri, text] of Object.entries(after)) + assert.equal(tracked.get(uri).getText(), text, "editor must apply exactly the validated source edits"); + }, + async format(ranged) { + const options = { tabSize: 2, insertSpaces: true }; + const edits = await vscode.commands.executeCommand( + ranged ? "vscode.executeFormatRangeProvider" : "vscode.executeFormatDocumentProvider", + document.uri, + ...(ranged + ? [ + new vscode.Range( + position(document, "export const unformatted"), + document.positionAt(document.getText().length), + ), + ] + : []), + options, + ); + // VS Code returns undefined when an already formatted document needs no + // edits. The shared scenario separately requires meaningful first-pass edits. + assert.ok(edits === undefined || Array.isArray(edits), "unexpected formatter response"); + return { + [document.uri.toString()]: (edits ?? []).map((item) => ({ + range: serialRange(item.range), + newText: item.newText, + })), + }; + }, + async codeActions() { + const actions = await vscode.commands.executeCommand( + "vscode.executeCodeActionProvider", + document.uri, + fullRange(document), + vscode.CodeActionKind.QuickFix.value, + 20, + ); + return (actions ?? []).map((item) => ({ title: item.title, edits: item.edit ? editsFor(item.edit) : undefined })); + }, + async semanticTokens(ranged) { + const result = await vscode.commands.executeCommand( + ranged ? "vscode.provideDocumentRangeSemanticTokens" : "vscode.provideDocumentSemanticTokens", + document.uri, + ...(ranged ? [fullRange(document)] : []), + ); + assert.ok(result?.data, "semantic token provider required"); + const data = result.data; + assert.equal(data.length % 5, 0); + let line = 0; + let character = 0; + const tokens = []; + for (let index = 0; index < data.length; index += 5) { + line += data[index]; + character = data[index] === 0 ? character + data[index + 1] : data[index + 1]; + assert.ok(line < document.lineCount); + const length = data[index + 2]; + assert.ok(character + length <= document.lineAt(line).text.length, "token outside original source"); + tokens.push({ text: document.lineAt(line).text.slice(character, character + length), length }); + } + return tokens; + }, + async openTsx(source) { + document = await open("view.tsx", source); + await vscode.window.showTextDocument(document); + }, + async restart() { + const settings = vscode.workspace.getConfiguration("typedSql", root); + const server = settings.get("serverPath"); + try { + await settings.update("serverPath", "missing-restart-server.cjs", vscode.ConfigurationTarget.WorkspaceFolder); + const deadline = Date.now() + 25_000; + let stopped = false; + do { + const value = await hover(document, "return row.", "return row.".length).catch(() => ""); + if (value === "") { + stopped = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (Date.now() < deadline); + assert.ok(stopped, "old server providers must stop before restart"); + assert.equal(document.isDirty, true, "restart must replay unsaved SQL"); + } finally { + await settings.update("serverPath", server, vscode.ConfigurationTarget.WorkspaceFolder); + } + }, + async multiRoot(verify) { + const prepared = JSON.parse(readFileSync(process.env.TYPED_SQL_SECONDARY, "utf8")); + const uri = vscode.Uri.file(prepared.workspace); + assert.equal(vscode.workspace.updateWorkspaceFolders(1, 0, { uri, name: "other-grammar" }), true); + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(uri, "query.ts")); + await vscode.window.showTextDocument(doc); + await verify({ + member: prepared.member, + type: prepared.type, + hover: () => hover(doc, `return row.${prepared.member}`, "return row.".length), + }); + } finally { + const index = vscode.workspace.workspaceFolders.findIndex((folder) => folder.uri.toString() === uri.toString()); + if (index >= 0) assert.equal(vscode.workspace.updateWorkspaceFolders(index, 1), true); + await vscode.window.showTextDocument(primary); + } + }, + async verifyPacked() { + const consumer = realpathSync(process.env.TYPED_SQL_PACKED_ROOT); + for (const name of ["@typed-sql/core", spec.packageName, "@typed-sql/language-server"]) { + const resolved = realpathSync(join(consumer, "node_modules", name)); + const owned = relative(consumer, resolved); + assert.ok( + !owned.startsWith("..") && !isAbsolute(owned), + `package must resolve inside isolated install: ${resolved}`, + ); + } + const configured = vscode.workspace.getConfiguration("typedSql", root).get("serverPath"); + assert.ok(realpathSync(configured).startsWith(`${consumer}/`), "host must run installed server artifact"); + }, + }; + await vscode.window.showTextDocument(primary); + const extension = vscode.extensions.getExtension("lojhan.typed-sql"); + assert.ok(extension); + await extension.activate(); + if (coexist) { + try { + const builtin = vscode.extensions.getExtension("vscode.typescript-language-features"); + assert.ok(builtin); + await builtin.activate(); + await host.reset(sourceFor(spec)); + const deadline = Date.now() + 25_000; + let observed; + do { + observed = await host.hover(`return row.${spec.initial.member}`, "return row.".length); + if (observed.includes(spec.initial.member) && !/\b(any|unknown)\b/.test(observed)) break; + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + assert.ok(observed.includes(spec.initial.member), observed); + assert.doesNotMatch(observed, /\b(any|unknown)\b/); + assert.match(await host.hover("ordinary.count", "ordinary.".length), /number/); + const errors = await host.diagnostics(); + assert.ok(!errors.some((item) => item.error && item.line === 6), JSON.stringify(errors)); + report.checks["builtin-coexistence"] = { status: "passed" }; + } catch (error) { + report.checks["builtin-coexistence"] = { status: "failed", error: String(error.stack ?? error) }; + } + save(); + } else { + assert.equal(vscode.extensions.getExtension("vscode.typescript-language-features"), undefined); + await runExtendedScenario( + spec, + host, + (id, status, error) => { + report.checks[id] = { status, ...(error ? { error } : {}) }; + save(); + }, + (id, status, error) => { + report.editMatrix[id] = { status, ...(error ? { error } : {}) }; + save(); + }, + ); + } + report.passed = + Object.values(report.checks).every((item) => item.status === "passed") && + Object.values(report.editMatrix).every((item) => item.status === "passed"); + save(); + assert.equal(report.passed, true, JSON.stringify(report)); +}; diff --git a/editors/vscode/test/packed-install.mjs b/editors/vscode/test/packed-install.mjs new file mode 100644 index 0000000..becdcb0 --- /dev/null +++ b/editors/vscode/test/packed-install.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { execFile as callback } from "node:child_process"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFile = promisify(callback); +export async function installPackedServer(root, run) { + const tarballs = join(run, "tarballs"); + const consumer = join(run, "installed"); + await mkdir(tarballs); + await mkdir(consumer); + const dependencies = {}; + // Include each workspace dependency in the isolated install, never resolve a + // prerelease workspace package from the registry or link production sources. + const directories = [ + "ast", + "core", + "config", + "schema", + "compiler", + "conformance", + "postgres", + "mysql", + "sqlite", + "ts-bridge", + "language-server", + ].map((name) => `packages/${name}`); + directories.push("examples/synthetic-grammar"); + for (const directory of directories) { + const manifest = JSON.parse(await readFile(join(root, directory, "package.json"), "utf8")); + const before = new Set(await readdir(tarballs)); + await execFile("pnpm", ["pack", "--pack-destination", tarballs], { cwd: join(root, directory), timeout: 60_000 }); + const added = (await readdir(tarballs)).filter((name) => !before.has(name)); + assert.equal(added.length, 1, `one archive for ${manifest.name}`); + dependencies[manifest.name] = `file:${join(tarballs, added[0])}`; + } + await writeFile( + join(consumer, "package.json"), + JSON.stringify({ private: true, type: "module", dependencies, pnpm: { overrides: dependencies } }), + ); + const { NODE_PATH: _nodePath, ...env } = process.env; + await execFile("pnpm", ["install", "--ignore-workspace", "--ignore-scripts", "--no-frozen-lockfile"], { + cwd: consumer, + env: { ...env, CI: "true" }, + timeout: 180_000, + maxBuffer: 4 * 1024 * 1024, + }); + await writeFile(join(run, "packed-install.json"), JSON.stringify({ consumer, dependencies }, null, 2)); + return consumer; +} diff --git a/editors/vscode/test/preview-trace.cjs b/editors/vscode/test/preview-trace.cjs new file mode 100644 index 0000000..dc3cb48 --- /dev/null +++ b/editors/vscode/test/preview-trace.cjs @@ -0,0 +1,26 @@ +// Opt-in tracing for generated, isolated host fixtures only. Production stderr +// remains redacted by the language server; never enable this in user projects. +const { spawn } = require("node:child_process"); +const { appendFileSync, realpathSync } = require("node:fs"); +const { createRequire } = require("node:module"); +const { dirname, join } = require("node:path"); +const resolve = createRequire( + realpathSync(join(process.env.TYPED_SQL_PACKED_ROOT, "node_modules/@typed-sql/ts-bridge/package.json")), +); +const cli = join(dirname(resolve.resolve("@typed-sql/typescript-preview/package.json")), "bin/tsc"); +const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], { stdio: ["pipe", "pipe", "pipe"] }); +let bytes = 0; +child.stderr.on("data", (data) => { + if (bytes < 262_144) appendFileSync(process.env.TYPED_SQL_PREVIEW_TRACE, data.subarray(0, 262_144 - bytes)); + bytes += data.length; +}); +process.stdin.pipe(child.stdin); +child.stdout.pipe(process.stdout); +child.stderr.pipe(process.stderr); +child.on("error", (error) => { + process.stderr.write(String(error)); + process.exitCode = 1; +}); +child.on("exit", (code) => { + process.exit(code ?? 1); +}); diff --git a/editors/vscode/test/run-host.mjs b/editors/vscode/test/run-host.mjs index 9e66c98..3688e89 100644 --- a/editors/vscode/test/run-host.mjs +++ b/editors/vscode/test/run-host.mjs @@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; import { grammarCases } from "../../../test/editor-hub/cases.mjs"; -import { buildMatrix } from "../../../test/editor-hub/matrix.mjs"; +import { editCases } from "../../../test/editor-hub/edit-matrix.mjs"; +import { buildMatrix, combineHostReports } from "../../../test/editor-hub/matrix.mjs"; +import { prepareWorkspace } from "../../../test/editor-hub/workspace.mjs"; +import { installPackedServer } from "./packed-install.mjs"; import { prepareOverlayWorkspace } from "./setup-overlays.mjs"; const execFile = promisify(execFileCallback); @@ -23,16 +26,48 @@ const executable = await downloadAndUnzipVSCode({ timeout: 30_000, }); const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(executable); -const scenarios = [ +const allScenarios = [ ...["trusted", "untrusted", "virtual"].map((mode) => ({ mode, id: mode })), { mode: "lifecycle", id: "lifecycle" }, ...grammarCases.map((spec) => ({ mode: "overlays", id: spec.id, spec })), + ...grammarCases.map((spec) => ({ mode: "extended", id: `${spec.id}-extended`, spec })), + ...grammarCases.map((spec) => ({ mode: "coexist", id: `${spec.id}-coexist`, spec })), ]; const reports = []; +const selected = process.env.TYPED_SQL_HOST_SCENARIOS?.split(","); +if (selected?.some((id) => !allScenarios.some((scenario) => scenario.id === id))) + throw new Error("Unknown TYPED_SQL_HOST_SCENARIOS entry"); +const scenarios = + selected === undefined ? allScenarios : allScenarios.filter((scenario) => selected.includes(scenario.id)); const failures = []; const results = join(artifacts, "results", basename(run)); await mkdir(results, { recursive: true }); -await writeFile(join(results, "matrix.json"), JSON.stringify(buildMatrix(reports), null, 2)); +const saveMatrices = async () => { + await writeFile(join(results, "matrix.json"), JSON.stringify(buildMatrix(combineHostReports(reports)), null, 2)); + await writeFile( + join(results, "edit-matrix.json"), + JSON.stringify( + { + evidence: "actual-host", + cells: grammarCases.flatMap((spec) => + editCases.map((id) => ({ + editor: "vscode", + grammar: spec.id, + interface: id, + ...(reports.find((report) => report.grammar === spec.id && report.editMatrix)?.editMatrix[id] ?? { + status: "not-run", + }), + })), + ), + }, + null, + 2, + ), + ); +}; +await saveMatrices(); +const packed = await installPackedServer(root, run); +await writeFile(join(results, "packed-install.json"), await readFile(join(run, "packed-install.json"))); for (const [index, { mode, id, spec }] of scenarios.entries()) { const base = join(run, String(index)); const workspace = join(base, "workspace"); @@ -46,6 +81,7 @@ for (const [index, { mode, id, spec }] of scenarios.entries()) { await mkdir(join(workspace, ".vscode"), { recursive: true }); await writeFile(join(workspace, "query.ts"), "export const example = 1;\n"); const workspaceFile = join(base, "virtual.code-workspace"); + const extended = mode === "extended" || mode === "coexist"; if (mode === "virtual") await writeFile( workspaceFile, @@ -65,6 +101,29 @@ for (const [index, { mode, id, spec }] of scenarios.entries()) { // A workspace file must still honor the folder-owned server/schema paths. await writeFile(workspaceFile, JSON.stringify({ folders: [{ path: workspace }] })); } + if (extended) { + const server = join(packed, "node_modules/@typed-sql/language-server/dist/packages/language-server/src/server.js"); + const configure = async (target, grammar) => { + const settings = await prepareWorkspace(target, root, grammar, packed); + await mkdir(join(target, ".vscode"), { recursive: true }); + await writeFile( + join(target, ".vscode/settings.json"), + JSON.stringify({ + "typedSql.serverPath": server, + ...Object.fromEntries(Object.entries(settings).map(([key, value]) => [`typedSql.${key}`, value])), + }), + ); + }; + await configure(workspace, spec); + await writeFile(workspaceFile, JSON.stringify({ folders: [{ path: workspace }] })); + const other = grammarCases[(grammarCases.indexOf(spec) + 1) % grammarCases.length]; + const secondary = join(base, "secondary"); + await configure(secondary, other); + await writeFile( + join(base, "secondary.json"), + JSON.stringify({ workspace: secondary, member: other.initial.member, type: other.initial.type }), + ); + } await mkdir(join(profile, "User"), { recursive: true }); await writeFile( join(profile, "User/settings.json"), @@ -92,19 +151,20 @@ for (const [index, { mode, id, spec }] of scenarios.entries()) { await execFile( executable, [ - mode === "virtual" || mode === "overlays" ? workspaceFile : workspace, + mode === "virtual" || mode === "overlays" || extended ? workspaceFile : workspace, ...isolated, "--skip-welcome", "--skip-release-notes", ...(process.platform === "linux" ? ["--no-sandbox"] : []), - "--disable-extension", - "vscode.typescript-language-features", + ...(mode === "coexist" ? [] : ["--disable-extension", "vscode.typescript-language-features"]), `--extensionDevelopmentPath=${join(directory, "harness")}`, - `--extensionTestsPath=${join(directory, mode === "overlays" ? "overlay-suite.cjs" : mode === "lifecycle" ? "lifecycle-suite.cjs" : "host-suite.cjs")}`, + `--extensionTestsPath=${join(directory, extended ? "extended-suite.cjs" : mode === "overlays" ? "overlay-suite.cjs" : mode === "lifecycle" ? "lifecycle-suite.cjs" : "host-suite.cjs")}`, ...(mode !== "untrusted" ? ["--disable-workspace-trust"] : []), ], { - timeout: 90_000, + // Each independent added interface retains its 25-second eventual + // bound. Allow the complete matrix to report all failures in one host. + timeout: mode === "extended" ? 420_000 : 90_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, @@ -113,6 +173,14 @@ for (const [index, { mode, id, spec }] of scenarios.entries()) { TYPED_SQL_HOST_MARKER: join(base, "server-started"), TYPED_SQL_HOST_PROBE: join(directory, "lifecycle-server.cjs"), TYPED_SQL_HOST_REPORT: join(base, "result.json"), + TYPED_SQL_PACKED_ROOT: packed, + TYPED_SQL_SECONDARY: join(base, "secondary.json"), + ...(extended && process.env.TYPED_SQL_HOST_CAPTURE_PREVIEW === "true" + ? { + TYPED_SQL_TYPESCRIPT_PREVIEW_CLI: join(directory, "preview-trace.cjs"), + TYPED_SQL_PREVIEW_TRACE: join(results, `${id}-preview.log`), + } + : {}), }, }, ); @@ -146,7 +214,7 @@ for (const [index, { mode, id, spec }] of scenarios.entries()) { await writeFile(join(results, `${id}.json`), JSON.stringify(report, null, 2)); if (report.passed !== true) console.error(`Host failure ${id}: ${JSON.stringify(report)}`); if (report?.grammar !== undefined) reports.push(report); - await writeFile(join(results, "matrix.json"), JSON.stringify(buildMatrix(reports), null, 2)); + await saveMatrices(); } console.log(`VS Code host evidence: ${run}`); if (failures.length > 0) throw new Error(`Host scenarios failed: ${failures.join(", ")}. Evidence: ${results}`); diff --git a/packages/language-server/test/edit-matrix.test.ts b/packages/language-server/test/edit-matrix.test.ts new file mode 100644 index 0000000..968d74e --- /dev/null +++ b/packages/language-server/test/edit-matrix.test.ts @@ -0,0 +1,91 @@ +import { describe, it, strict } from "poku"; +import { mapProtocolCoordinates } from "../src/protocol-mapping.js"; + +const caller = "file:///project/query.ts"; +const target = "file:///project/consumer.ts"; +const dependency = "file:///external/index.d.ts"; +const range = { start: { line: 20, character: 2 }, end: { line: 21, character: 5 } }; +const shifted = (delta: number) => ({ + start: { line: 20 - delta, character: 2 }, + end: { line: 21 - delta, character: 5 }, +}); +const lookup = (uri: string) => (uri === caller ? 3 : uri === target ? 7 : undefined); +const mapper = { + lookup, + position: (delta: number, value: { line: number; character: number }) => ({ ...value, line: value.line - delta }), + version: (delta: number, version: number) => version - delta, +}; + +await describe("edit-bearing protocol matrix", async () => { + for (const [uri, delta] of [ + [caller, 3], + [target, 7], + [dependency, 0], + ] as const) { + await it(`maps plain, insert/replace, additional and resolved edits owned by ${uri}`, () => { + const opaque = { uri: caller, range, version: 999, newText: "opaque" }; + const input = { + uri, + textEdit: { insert: range, replace: range, newText: "renamed" }, + additionalTextEdits: [{ range, newText: "imported" }], + data: opaque, + }; + const projected = mapProtocolCoordinates(input, mapper, 3); + strict.deepStrictEqual(projected, { + ...input, + textEdit: { insert: shifted(delta), replace: shifted(delta), newText: "renamed" }, + additionalTextEdits: [{ range: shifted(delta), newText: "imported" }], + }); + strict.strictEqual((projected as typeof input).data, opaque); + strict.deepStrictEqual( + mapProtocolCoordinates( + projected, + { ...mapper, position: (amount, value) => ({ ...value, line: value.line + amount }) }, + 3, + ), + input, + ); + }); + for (const version of [null, 12]) { + await it(`maps annotated/versioned edits for ${uri} at version ${version}`, () => { + const input = { + documentChanges: [ + { textDocument: { uri, version }, edits: [{ range, newText: "replacement", annotationId: "safe" }] }, + ], + changeAnnotations: { safe: { label: "Replace original identifier", needsConfirmation: true } }, + }; + strict.deepStrictEqual(mapProtocolCoordinates(input, mapper, 3), { + ...input, + documentChanges: [ + { + textDocument: { uri, version: version === null ? null : version - delta }, + edits: [{ range: shifted(delta), newText: "replacement", annotationId: "safe" }], + }, + ], + }); + }); + } + } + await it("preserves resource operations and resolves each URI-keyed edit independently", () => { + const input = { + edit: { + changes: { + [caller]: [{ range, newText: "a" }], + [target]: [{ range, newText: "b" }], + [dependency]: [{ range, newText: "c" }], + }, + documentChanges: [{ kind: "rename", oldUri: caller, newUri: target, options: { overwrite: false } }], + }, + }; + strict.deepStrictEqual(mapProtocolCoordinates(input, mapper, 3), { + edit: { + ...input.edit, + changes: { + [caller]: [{ range: shifted(3), newText: "a" }], + [target]: [{ range: shifted(7), newText: "b" }], + [dependency]: [{ range, newText: "c" }], + }, + }, + }); + }); +}); diff --git a/test/contracts/editor-edits.test.ts b/test/contracts/editor-edits.test.ts new file mode 100644 index 0000000..6612657 --- /dev/null +++ b/test/contracts/editor-edits.test.ts @@ -0,0 +1,64 @@ +import { describe, it, strict } from "poku"; +import { applySourceEdits, assertSqlPreserved, editCases } from "../editor-hub/edit-matrix.mjs"; + +const range = (start: number, end = start) => ({ + start: { line: 0, character: start }, + end: { line: 0, character: end }, +}); +await describe("source edit matrix safety", async () => { + await it("applies independent cross-file edits against original UTF-16/CRLF buffers without mutation", () => { + const sources = { first: "đŸ˜€name\r\nnext", second: "name" }; + strict.deepStrictEqual( + applySourceEdits(sources, { + first: [{ range: range(2, 6), newText: "renamed" }], + second: [{ range: range(0, 4), newText: "renamed" }], + }), + { first: "đŸ˜€renamed\r\nnext", second: "renamed" }, + ); + strict.strictEqual(sources.first, "đŸ˜€name\r\nnext"); + }); + await it("rejects wrong ownership, invalid ranges and ambiguous overlapping edits", () => { + strict.throws(() => applySourceEdits({ a: "abc" }, { b: [] }), /unexpected/); + for (const invalid of [range(-1), range(4), range(2, 1)]) + strict.throws(() => applySourceEdits({ a: "abc" }, { a: [{ range: invalid, newText: "x" }] })); + strict.throws( + () => + applySourceEdits( + { a: "abc" }, + { + a: [ + { range: range(0, 2), newText: "x" }, + { range: range(1), newText: "y" }, + ], + }, + ), + /overlapping/, + ); + strict.throws( + () => + applySourceEdits( + { a: "abc" }, + { + a: [ + { range: range(0), newText: "x" }, + { range: range(0), newText: "y" }, + ], + }, + ), + /ambiguous/, + ); + }); + await it("requires original SQL and rejects generated overlay text", () => { + const source = "const q = sql`SELECT value FROM widgets`;"; + assertSqlPreserved(source, source, "SELECT value FROM widgets"); + strict.throws(() => assertSqlPreserved(source, `${source} __typed_sql`, "SELECT value FROM widgets")); + strict.throws(() => assertSqlPreserved(source, "const q = null;", "SELECT value FROM widgets")); + strict.deepStrictEqual(editCases, [ + "rename-local", + "rename-cross-file", + "format-document", + "format-range", + "quick-fix", + ]); + }); +}); diff --git a/test/contracts/editor-hub.test.ts b/test/contracts/editor-hub.test.ts index 499fdfd..20e5b20 100644 --- a/test/contracts/editor-hub.test.ts +++ b/test/contracts/editor-hub.test.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { describe, it, strict } from "poku"; import { grammarCases, interfaces, sourceFor } from "../editor-hub/cases.mjs"; -import { buildMatrix, pendingInterfaces } from "../editor-hub/matrix.mjs"; +import { buildMatrix, combineHostReports, pendingInterfaces } from "../editor-hub/matrix.mjs"; await describe("editor grammar evidence hub", async () => { await it("retains host failure evidence even when an editor job fails", async () => { @@ -28,6 +28,17 @@ await describe("editor grammar evidence hub", async () => { strict.strictEqual(matrix.cells.length, 2 * 4 * (interfaces.length + pendingInterfaces.length)); strict.ok(matrix.cells.every((cell) => cell.status === "not-run")); }); + await it("assigns every additional interface to executable scenarios without counting implementation as success", async () => { + const scenario = await readFile(new URL("../editor-hub/extended-scenario.mjs", import.meta.url), "utf8"); + const adapter = await readFile(new URL("../../editors/vscode/test/extended-suite.cjs", import.meta.url), "utf8"); + for (const id of pendingInterfaces) { + strict.ok((id === "builtin-coexistence" ? adapter : scenario).includes(`"${id}"`), id); + } + strict.strictEqual(new Set([...interfaces, ...pendingInterfaces]).size, 21); + const installer = await readFile(new URL("../../editors/vscode/test/packed-install.mjs", import.meta.url), "utf8"); + strict.ok(installer.includes('"--ignore-workspace"')); + strict.ok(!installer.includes("link:")); + }); await it("records schema lifecycle coverage without inventing synthetic column semantics", () => { strict.ok(interfaces.includes("schema-file-refresh")); strict.ok(!pendingInterfaces.includes("schema-file-refresh")); @@ -53,6 +64,14 @@ await describe("editor grammar evidence hub", async () => { strict.ok(matrix.cells.filter((cell) => cell.editor === "zed").every((cell) => cell.status === "not-run")); strict.throws(() => buildMatrix([{ ...report, evidence: "protocol" }]), /protocol evidence/); strict.throws(() => buildMatrix([report, report]), /duplicate/); + const extra = { ...report, checks: { "restart-recovery": { status: "passed" } } }; + strict.strictEqual( + buildMatrix(combineHostReports([report, extra])).cells.filter((cell) => cell.status === "passed").length, + 2, + ); + strict.throws(() => combineHostReports([report, report]), /duplicate interface/); + strict.throws(() => combineHostReports([report, { ...extra, evidence: "protocol" }]), /protocol evidence/); + strict.throws(() => combineHostReports([report, { ...extra, vscode: "other" }]), /mixed host/); strict.throws(() => buildMatrix([{ ...report, grammar: "unknown" }]), /unknown grammar/); strict.throws(() => buildMatrix([{ ...report, checks: { invented: { status: "passed" } } }]), /unknown interface/); strict.throws(() => buildMatrix([{ ...report, checks: { "row-hover": { status: "skipped" } } }]), /unknown status/); diff --git a/test/editor-hub/cases.d.mts b/test/editor-hub/cases.d.mts index 916a0bc..3a2c1cb 100644 --- a/test/editor-hub/cases.d.mts +++ b/test/editor-hub/cases.d.mts @@ -14,6 +14,8 @@ export interface GrammarCase { wrongType: string; invalidQuery: string; diagnosticPattern: string; + parameters: { query: string; tuple: string[] }; + structural: string; schemaRefresh?: { table: string; column: string; type: string }; } export const grammarCases: GrammarCase[]; diff --git a/test/editor-hub/cases.mjs b/test/editor-hub/cases.mjs index 7d3030b..34bbbd2 100644 --- a/test/editor-hub/cases.mjs +++ b/test/editor-hub/cases.mjs @@ -30,6 +30,13 @@ const sqlCase = (id, version, types) => ({ invalidQuery: "SELECT not_a_column FROM users", diagnosticPattern: "not_a_column", schemaRefresh: { table: "users", column: "name", type: "string | null" }, + parameters: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: fixture contains source-level interpolation + query: 'SELECT id FROM users WHERE age = ${1} AND name = ${"Ada"}', + tuple: ["number | null", "string"], + }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: fixture contains source-level interpolation + structural: "SELECT id ${selected ? `, name` : sql.empty} FROM users", }); export const grammarCases = [ @@ -61,6 +68,13 @@ export const grammarCases = [ wrongType: "string", invalidQuery: "UNSUPPORTED", diagnosticPattern: "does not support", + parameters: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: fixture contains source-level interpolation + query: 'INSERT INTO widgets (value, label) VALUES (${1}, ${"Ada"}), (${2}, ${null})', + tuple: ["number", "string | null", "number", "string | null"], + }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: fixture contains source-level interpolation + structural: "SELECT value ${selected ? `, label` : sql.empty} FROM widgets", }, ]; diff --git a/test/editor-hub/edit-matrix.d.mts b/test/editor-hub/edit-matrix.d.mts new file mode 100644 index 0000000..569e874 --- /dev/null +++ b/test/editor-hub/edit-matrix.d.mts @@ -0,0 +1,10 @@ +export const editCases: string[]; +export interface SourceEdit { + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + newText: string; +} +export function applySourceEdits( + sources: Record, + edits: Record, +): Record; +export function assertSqlPreserved(before: string, after: string, query: string): void; diff --git a/test/editor-hub/edit-matrix.mjs b/test/editor-hub/edit-matrix.mjs new file mode 100644 index 0000000..693fe55 --- /dev/null +++ b/test/editor-hub/edit-matrix.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; + +export const editCases = ["rename-local", "rename-cross-file", "format-document", "format-range", "quick-fix"]; + +function offset(source, position) { + const lines = source.split("\n"); + assert.ok( + Number.isInteger(position.line) && position.line >= 0 && position.line < lines.length, + "edit line outside source", + ); + assert.ok( + Number.isInteger(position.character) && + position.character >= 0 && + position.character <= lines[position.line].length, + "edit character outside source", + ); + return lines.slice(0, position.line).reduce((sum, line) => sum + line.length + 1, 0) + position.character; +} + +// Validate and simulate all edits before allowing a host adapter to apply any. +// Sources are the original editor buffers, not generated TypeScript overlays. +export function applySourceEdits(sources, edits) { + const result = { ...sources }; + for (const [uri, changes] of Object.entries(edits)) { + assert.ok(Object.hasOwn(sources, uri), `unexpected edited document: ${uri}`); + const source = sources[uri]; + const ordered = changes + .map(({ range, newText }) => { + assert.equal(typeof newText, "string"); + const start = offset(source, range.start); + const end = offset(source, range.end); + assert.ok(start <= end, "reversed edit"); + return { start, end, newText }; + }) + .sort((a, b) => a.start - b.start || a.end - b.end); + for (let index = 1; index < ordered.length; index++) { + assert.ok(ordered[index - 1].end <= ordered[index].start, "overlapping edits"); + assert.ok(ordered[index - 1].start !== ordered[index].start, "ambiguous same-offset edits"); + } + let changed = source; + for (const { start, end, newText } of ordered.reverse()) + changed = changed.slice(0, start) + newText + changed.slice(end); + result[uri] = changed; + } + return result; +} + +export function assertSqlPreserved(before, after, query) { + const literal = `sql\`${query}\``; + assert.ok(before.includes(literal), "fixture must contain its SQL literal"); + assert.equal(after.split(literal).length, before.split(literal).length, "edit changed or duplicated SQL literal"); + // Typed overlays cast through an imported Query type. This is compiler input, + // never acceptable new text from an ordinary editor edit. + assert.doesNotMatch(after, /import\(["']@typed-sql\/core["']\)\.Query|__typed_sql|__typedSql/); +} diff --git a/test/editor-hub/extended-scenario.mjs b/test/editor-hub/extended-scenario.mjs new file mode 100644 index 0000000..e5dc704 --- /dev/null +++ b/test/editor-hub/extended-scenario.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { sourceFor } from "./cases.mjs"; +import { applySourceEdits, assertSqlPreserved } from "./edit-matrix.mjs"; + +async function eventually(check) { + const deadline = Date.now() + 25_000; + let last; + do { + try { + return await check(); + } catch (error) { + last = error; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw last; +} + +export async function runExtendedScenario(spec, host, record, recordEdit) { + const source = sourceFor(spec); + const check = async (name, operation) => { + try { + await host.reset(source); + await operation(); + record(name, "passed"); + } catch (error) { + record(name, "failed", String(error.stack ?? error)); + } + }; + const editCheck = async (name, operation) => { + try { + await operation(); + recordEdit(name, "passed"); + } catch (error) { + recordEdit(name, "failed", String(error.stack ?? error)); + throw error; + } + }; + const row = (variant = spec.initial) => host.hover(`return row.${variant.member}`, "return row.".length); + const inferred = async (variant = spec.initial) => { + const hover = await row(variant); + assert.ok(hover.includes(variant.member), hover); + for (const type of variant.type.split(" | ")) assert.match(hover, new RegExp(`\\b${type}\\b`)); + assert.doesNotMatch(hover, /\b(any|unknown)\b/); + }; + const apply = async (edits, before, expected) => { + assert.ok( + Object.values(edits).some((items) => items.length > 0), + "provider must return meaningful edits", + ); + const after = applySourceEdits(before, edits); + for (const [uri, text] of Object.entries(before)) assertSqlPreserved(text, after[uri], spec.initial.query); + if (expected !== undefined) assert.deepEqual(after, expected); + await host.apply(edits, before, after); + return after; + }; + + await check("sql-hover", async () => { + await eventually(async () => { + const hover = await host.hover(spec.initial.query, "SELECT ".length); + assert.match(hover, /typed-sql|Query|inferred/i); + assert.ok(hover.includes(spec.initial.member), hover); + assert.doesNotMatch(hover, /\bany\b/); + }); + }); + await check("sql-completion", async () => { + await eventually(async () => { + const labels = await host.completions(spec.initial.query, "SELECT ".length); + for (const label of spec.initial.completions) assert.ok(labels.includes(label), JSON.stringify(labels)); + }); + }); + await check("parameter-inference", async () => { + const parameterSource = `import { sql } from ${JSON.stringify(spec.packageName)};\nimport type { QueryParameters } from "@typed-sql/core";\nconst parameterQuery = sql\`${spec.parameters.query}\`;\ndeclare const inferredParameters: QueryParameters;\nvoid inferredParameters;\n`; + await host.reset(parameterSource); + await eventually(async () => { + const hover = await host.hover("void inferredParameters", "void ".length); + const compact = hover.replace(/\s+/g, " "); + assert.ok(compact.includes(`[${spec.parameters.tuple.join(", ")}]`), hover); + assert.doesNotMatch(hover, /\b(any|unknown)\b/); + }); + }); + await check("references", async () => { + const texts = await host.crossFile(source); + const references = await host.references("sharedValue"); + const observed = references.map(({ uri, range }) => ({ uri, range })); + assert.deepEqual( + observed.sort(host.locationOrder), + host.occurrences(texts, "sharedValue").sort(host.locationOrder), + ); + }); + await check("rename", async () => { + const failures = []; + for (const [id, symbol, replacement, setup] of [ + [ + "rename-local", + "ordinary", + "renamedOrdinary", + async () => { + await host.reset(source); + return host.sources(); + }, + ], + ["rename-cross-file", "sharedValue", "renamedShared", () => host.crossFile(source)], + ]) { + try { + await editCheck(id, async () => { + const before = await setup(); + const edits = await host.rename(symbol, replacement); + const expected = Object.fromEntries( + Object.entries(before).map(([uri, text]) => [ + uri, + text.replace(new RegExp(`\\b${symbol}\\b`, "g"), replacement), + ]), + ); + await apply(edits, before, expected); + await eventually(() => inferred()); + }); + } catch (error) { + failures.push(String(error)); + } + } + assert.deepEqual(failures, []); + }); + await check("formatting", async () => { + const failures = []; + for (const ranged of [false, true]) { + try { + await editCheck(ranged ? "format-range" : "format-document", async () => { + await host.reset(`${source}\nexport const unformatted={answer:1};void unformatted.answer;\n`); + const before = await host.sources(); + const edits = await host.format(ranged); + const after = await apply(edits, before); + assert.notDeepEqual(after, before, "formatter must change the unformatted fixture"); + if (ranged) + for (const [uri, text] of Object.entries(before)) + assert.equal(after[uri].slice(0, source.length), text.slice(0, source.length)); + assert.deepEqual(applySourceEdits(after, await host.format(ranged)), after, "formatting must be idempotent"); + await eventually(() => inferred()); + }); + } catch (error) { + failures.push(String(error)); + } + } + assert.deepEqual(failures, []); + }); + await check("code-actions", () => + editCheck("quick-fix", async () => { + const broken = `import { sql } from ${JSON.stringify(spec.packageName)};\ndeclare const selected: boolean;\nconst query = sql\`${spec.structural}\`;\n`; + await host.reset(broken); + const action = await eventually(async () => { + const actions = await host.codeActions(); + const fix = actions.find((item) => item.title === "Mark as sql.fragment"); + assert.ok(fix?.edits, JSON.stringify(actions)); + return fix; + }); + const before = await host.sources(); + const after = applySourceEdits(before, action.edits); + assert.deepEqual( + after, + Object.fromEntries(Object.entries(before).map(([uri, text]) => [uri, text.replace("? `", "? sql.fragment`")])), + ); + await host.apply(action.edits, before, after); + await eventually(async () => + assert.ok( + !(await host.diagnostics()).some((item) => item.code === "TSQ004"), + "structural quick fix must clear TSQ004", + ), + ); + }), + ); + await check("semantic-tokens", async () => { + await eventually(async () => { + for (const ranged of [false, true]) { + const tokens = await host.semanticTokens(ranged); + assert.ok(tokens.length > 0, "semantic token provider must return source tokens"); + assert.ok( + tokens.some((item) => item.text === "ordinary"), + JSON.stringify(tokens), + ); + for (const item of tokens) + assert.ok(item.text.length === item.length && item.length > 0, "token must fit its original source line"); + } + }); + }); + await check("tsx", async () => { + await host.openTsx( + `${source}\ndeclare global { namespace JSX { interface IntrinsicElements { panel: { count: number } } } }\nconst view = ; void view;\n`, + ); + await eventually(() => inferred()); + assert.match(await host.hover("count={ordinary.count}", "count={ordinary.".length), /number/); + assert.ok(!(await host.diagnostics()).some((item) => /Cannot use JSX|JSX element implicitly/.test(item.message))); + }); + await check("restart-recovery", async () => { + await host.reset(sourceFor(spec, spec.changed)); + await eventually(() => inferred(spec.changed)); + await host.restart(); + await eventually(() => inferred(spec.changed)); + await eventually(async () => + assert.ok((await host.diagnostics()).some((item) => item.line === 6 && /not assignable/.test(item.message))), + ); + }); + await check("multi-root", async () => { + await host.multiRoot(async (other) => { + await eventually(() => inferred()); + await eventually(async () => { + const hover = await other.hover(); + assert.ok(hover.includes(other.member), hover); + assert.match(hover, new RegExp(`\\b${other.type}\\b`)); + assert.doesNotMatch(hover, /\b(any|unknown)\b/); + }); + }); + await eventually(() => inferred()); + }); + await check("packed-server-install", async () => { + await host.verifyPacked(); + await eventually(() => inferred()); + }); +} diff --git a/test/editor-hub/matrix.d.mts b/test/editor-hub/matrix.d.mts index 5553925..4699004 100644 --- a/test/editor-hub/matrix.d.mts +++ b/test/editor-hub/matrix.d.mts @@ -7,6 +7,7 @@ export interface HostReport { checks: Record; } export const pendingInterfaces: string[]; +export function combineHostReports(reports: HostReport[]): HostReport[]; export function buildMatrix(reports: HostReport[]): { formatVersion: number; scope: string; diff --git a/test/editor-hub/matrix.mjs b/test/editor-hub/matrix.mjs index 574bca2..ca93fd4 100644 --- a/test/editor-hub/matrix.mjs +++ b/test/editor-hub/matrix.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { grammarCases, interfaces } from "./cases.mjs"; +// Additional executable interfaces live in extended-scenario and the isolated +// coexistence host. Keep the historical export for existing matrix consumers. export const pendingInterfaces = [ "restart-recovery", "multi-root", @@ -17,6 +19,27 @@ export const pendingInterfaces = [ "builtin-coexistence", ]; +export function combineHostReports(reports) { + const combined = new Map(); + for (const report of reports) { + // Validate evidence before aggregation, so protocol results cannot inherit + // another report's actual-host identity. + buildMatrix([report]); + const key = `${report.editor}/${report.grammar}`; + const previous = combined.get(key); + if (previous === undefined) { + combined.set(key, { ...report, checks: { ...report.checks } }); + continue; + } + assert.equal(previous.vscode ?? previous.hostVersion, report.vscode ?? report.hostVersion, "mixed host versions"); + for (const [id, result] of Object.entries(report.checks)) { + assert.ok(!Object.hasOwn(previous.checks, id), `duplicate interface evidence: ${key}/${id}`); + previous.checks[id] = result; + } + } + return [...combined.values()]; +} + export function buildMatrix(reports) { const inventory = [...interfaces, ...pendingInterfaces]; const seen = new Set(); diff --git a/test/editor-hub/workspace.mjs b/test/editor-hub/workspace.mjs index 710edba..71e9275 100644 --- a/test/editor-hub/workspace.mjs +++ b/test/editor-hub/workspace.mjs @@ -2,19 +2,30 @@ import { copyFile, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { sourceFor } from "./cases.mjs"; -export async function prepareWorkspace(workspace, root, spec) { +export async function prepareWorkspace(workspace, root, spec, dependencyRoot) { await mkdir(join(workspace, "node_modules/@typed-sql"), { recursive: true }); for (const [name, directory] of [ ["@typed-sql/core", "packages/core"], [spec.packageName, spec.packageDirectory], ]) - await symlink(join(root, directory), join(workspace, "node_modules", name), "junction"); + await symlink( + dependencyRoot === undefined ? join(root, directory) : join(dependencyRoot, "node_modules", name), + join(workspace, "node_modules", name), + "junction", + ); await writeFile(join(workspace, "package.json"), JSON.stringify({ type: "module", private: true })); await writeFile( join(workspace, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true, target: "ES2024", module: "NodeNext", skipLibCheck: true }, - include: ["query.ts", "database.ts"], + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2024", + module: "NodeNext", + skipLibCheck: true, + jsx: "preserve", + }, + include: ["*.ts", "*.tsx"], }), ); await copyFile(join(root, "test/fixtures/success/database.ts"), join(workspace, "database.ts"));