diff --git a/.github/scripts/actions-efficiency.test.mjs b/.github/scripts/actions-efficiency.test.mjs index 0a18dc636f..90523b506b 100644 --- a/.github/scripts/actions-efficiency.test.mjs +++ b/.github/scripts/actions-efficiency.test.mjs @@ -25,14 +25,21 @@ function evaluateCondition(content, github) { const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim(); assert.ok(expression, "missing job condition"); const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`); - return evaluate(github, (search, item) => String(search ?? "").toLowerCase().includes(String(item).toLowerCase())); + return evaluate(github, (search, item) => + String(search ?? "") + .toLowerCase() + .includes(String(item).toLowerCase()), + ); } function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) { - return { repository_owner: "t8y2", event: { - issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined }, - comment: { body, user: { type: userType, login } }, - } }; + return { + repository_owner: "t8y2", + event: { + issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined }, + comment: { body, user: { type: userType, login } }, + }, + }; } test("issue command prefilter admits every supported command and skips unrelated comments", async () => { @@ -56,10 +63,15 @@ test("spam prefilter retains all existing malicious-link cases and never bypasse const content = job(workflow("spam-comment-guard"), "delete-malicious-link"); const pattern = content.match(/grep -qiP '([^']+)'/)[1]; const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i"); - for (const body of ["https://evil.example/update.zip", "https://evil.example/update.ZIP?download=1", - "[update.zip](//evil.example/download)", "[update.ZIP](https://evil%2Eexample/download)", - "[update.7z](https://evil.example/download)", "[update.exe](https://evil.example/download)", - "[update.SCR](//evil.example/download)"]) { + for (const body of [ + "https://evil.example/update.zip", + "https://evil.example/update.ZIP?download=1", + "[update.zip](//evil.example/download)", + "[update.ZIP](https://evil%2Eexample/download)", + "[update.7z](https://evil.example/download)", + "[update.exe](https://evil.example/download)", + "[update.SCR](//evil.example/download)", + ]) { assert.equal(matcher.test(body), true, body); assert.equal(evaluateCondition(content, commentEvent(body)), true, body); assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false); @@ -69,9 +81,7 @@ test("spam prefilter retains all existing malicious-link cases and never bypasse for (const body of ["Thanks!", "https://example.com/docs", "", null]) { assert.equal(evaluateCondition(content, commentEvent(body)), false); } - for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip", - "https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)", - "[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) { + for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip", "https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)", "[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) { assert.equal(evaluateCondition(content, commentEvent(body)), true, body); assert.equal(matcher.test(body), false, body); } @@ -98,8 +108,7 @@ test("plugin checks run once before unchanged cross-platform verification", () = assert.match(tests, /needs: checks/); assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/); assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/); - for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc", - "npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) { + for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc", "npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) { assert.ok(tests.includes(command), command); } assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/); @@ -118,11 +127,16 @@ test("GitHub script selection excludes planner tests only when fast checks own t const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs")); for (const fast of ["true", "false", ""]) { const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], { - cwd: repository, encoding: "utf8", env: { ...process.env, FAST_CHECKS: fast }, + cwd: repository, + encoding: "utf8", + env: { ...process.env, FAST_CHECKS: fast }, }); assert.equal(result.status, 0, result.stderr); const selected = result.stdout.trim().split("\n").slice(1).sort(); - const expected = files.filter((file) => fast !== "true" || !file.startsWith("ci-")).map((file) => `.github/scripts/${file}`).sort(); + const expected = files + .filter((file) => fast !== "true" || !file.startsWith("ci-")) + .map((file) => `.github/scripts/${file}`) + .sort(); assert.deepEqual(selected, expected); assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs")); } @@ -135,8 +149,7 @@ async function snapshotFixture(context) { } function snapshot() { - return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10, - contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] }; + return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10, contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] }; } test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => { @@ -154,9 +167,12 @@ test("a missing contributor snapshot is created and timestamp-only refreshes pre test("actual contributor, star and repository changes update the snapshot", async (context) => { const outputPath = await snapshotFixture(context); const original = snapshot(); - for (const updated of [{ ...original, stars: 11 }, { ...original, repository: "other/dbx" }, + for (const updated of [ + { ...original, stars: 11 }, + { ...original, repository: "other/dbx" }, { ...original, contributors: [{ ...original.contributors[0], commits: 4 }] }, - { ...original, contributors: [] }]) { + { ...original, contributors: [] }, + ]) { await writeContributorSnapshot(outputPath, original); const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" }; assert.equal(await writeContributorSnapshot(outputPath, refreshed), true); diff --git a/.github/scripts/ai-issue-priority.mjs b/.github/scripts/ai-issue-priority.mjs index 1a60a110e5..d3b74390d5 100644 --- a/.github/scripts/ai-issue-priority.mjs +++ b/.github/scripts/ai-issue-priority.mjs @@ -52,26 +52,30 @@ If input is truncated, assess only visible evidence and express uncertainty abou class PriorityError extends Error {} function labelNames(issue) { - return (issue.labels || []).map((label) => typeof label === "string" ? label : label.name).filter(Boolean); + return (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean); } function withoutUserPriority(body) { let inPrioritySection = false; - return String(body || "").split(/\r?\n/).filter((line) => { - const heading = line.match(/^#{1,6}\s+(.+)/); - if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]); - return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line); - }).join("\n").trim(); + return String(body || "") + .split(/\r?\n/) + .filter((line) => { + const heading = line.match(/^#{1,6}\s+(.+)/); + if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]); + return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line); + }) + .join("\n") + .trim(); } export function prepareIssue(issue) { const body = withoutUserPriority(issue.body); return { title: String(issue.title || "").slice(0, 500), - body: body.length > MAX_BODY_LENGTH - ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` - : body, - labels: labelNames(issue).filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)).sort(), + body: body.length > MAX_BODY_LENGTH ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` : body, + labels: labelNames(issue) + .filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)) + .sort(), truncated: body.length > MAX_BODY_LENGTH, }; } @@ -83,21 +87,26 @@ export function parseAssessment(content, issue) { } catch { throw new PriorityError("AtlasCloud returned invalid priority JSON"); } - const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 - && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength); - if (!result || typeof result.priority !== "string" || !Object.hasOwn(PRIORITY_LABELS, result.priority) - || !["high", "medium", "low"].includes(result.confidence) - || !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) - || typeof result.reason !== "string" || !result.reason.trim() || result.reason.length > 600 - || !validTextList(result.evidence, 300) || !validTextList(result.missing_information, 250)) { + const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength); + if ( + !result || + typeof result.priority !== "string" || + !Object.hasOwn(PRIORITY_LABELS, result.priority) || + !["high", "medium", "low"].includes(result.confidence) || + !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) || + typeof result.reason !== "string" || + !result.reason.trim() || + result.reason.length > 600 || + !validTextList(result.evidence, 300) || + !validTextList(result.missing_information, 250) + ) { throw new PriorityError("AtlasCloud returned an invalid priority assessment"); } if (!result.evidence.every((quote) => issue.title.includes(quote) || issue.body.includes(quote))) { throw new PriorityError("AtlasCloud priority evidence does not match the issue text"); } const insufficientEvidence = result.confidence === "low" || result.evidence.length === 0 || result.risk === "unknown"; - const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" - || !["security", "data-loss", "core-blocked"].includes(result.risk)); + const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" || !["security", "data-loss", "core-blocked"].includes(result.risk)); return { priority: insufficientEvidence || unsupportedCriticalPriority ? "needs-info" : result.priority, confidence: result.confidence, @@ -219,8 +228,7 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI await github("POST", `${issuePath}/labels`, { labels: [targetLabel] }); } for (const label of existingLabels) { - if (label !== targetLabel && label.startsWith(LABEL_PREFIX) - && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) { + if (label !== targetLabel && label.startsWith(LABEL_PREFIX) && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) { try { await github("DELETE", `${issuePath}/labels/${encodeURIComponent(label)}`); } catch (error) { @@ -234,10 +242,12 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI export function formatSummary(result) { const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); if (result.skipped) return `### AI issue priority\n\nSkipped: ${escape(result.skipped)}\n`; - return `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n` - + "AI suggestion based on issue text only, not a verified diagnosis or release commitment. " - + "User-reported and maintainer priority labels are unchanged.\n\n" - + `
${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}
\n`; + return ( + `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n` + + "AI suggestion based on issue text only, not a verified diagnosis or release commitment. " + + "User-reported and maintainer priority labels are unchanged.\n\n" + + `
${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}
\n` + ); } async function main() { diff --git a/.github/scripts/ai-issue-priority.test.mjs b/.github/scripts/ai-issue-priority.test.mjs index fb191a08a3..2648de4415 100644 --- a/.github/scripts/ai-issue-priority.test.mjs +++ b/.github/scripts/ai-issue-priority.test.mjs @@ -1,22 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - assessIssue, - createGitHubClient, - DEFAULT_MODEL, - formatSummary, - parseAssessment, - prepareIssue, - run, -} from "./ai-issue-priority.mjs"; +import { assessIssue, createGitHubClient, DEFAULT_MODEL, formatSummary, parseAssessment, prepareIssue, run } from "./ai-issue-priority.mjs"; const baseIssue = { number: 42, state: "open", title: "[Bug] Grid column alignment is inconsistent", - body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n" - + "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.", + body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n" + "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.", labels: [{ name: "bug" }, { name: "db/mysql" }, { name: "user-priority/P0" }], }; @@ -51,7 +42,9 @@ function fixture({ issue = baseIssue, latest = issue, result = assessment(), act if (method === "GET") return ++reads === 1 ? issue : latest; return {}; }, - fetchImpl: modelFetch(result, () => { modelCalls += 1; }), + fetchImpl: modelFetch(result, () => { + modelCalls += 1; + }), }; return { options, calls, modelCalls: () => modelCalls }; } @@ -114,14 +107,7 @@ test("validates every priority and normalizes low-confidence or ungrounded decis for (const priority of ["P1", "P2", "P3", "needs-info"]) { assert.equal(parseAssessment(JSON.stringify(assessment({ priority })), prepared).priority, priority); } - for (const override of [ - { confidence: "low" }, - { evidence: [] }, - { risk: "unknown" }, - { priority: "P0", risk: "cosmetic" }, - { priority: "P0", risk: "feature" }, - { priority: "P0", risk: "data-loss", confidence: "medium" }, - ]) { + for (const override of [{ confidence: "low" }, { evidence: [] }, { risk: "unknown" }, { priority: "P0", risk: "cosmetic" }, { priority: "P0", risk: "feature" }, { priority: "P0", risk: "data-loss", confidence: "medium" }]) { assert.equal(parseAssessment(JSON.stringify(assessment(override)), prepared).priority, "needs-info"); } const criticalIssue = prepareIssue({ title: "Data loss", body: "Saving one row overwrites every row in the table." }); @@ -132,13 +118,25 @@ test("validates every priority and normalizes low-confidence or ungrounded decis test("rejects malformed, oversized, unknown and fabricated model results", () => { const prepared = prepareIssue(baseIssue); for (const invalid of [ - "not JSON", "null", "[]", "```json\n{}\n```", + "not JSON", + "null", + "[]", + "```json\n{}\n```", ...[ - { priority: "P9" }, { priority: "__proto__" }, { priority: "constructor" }, { priority: ["P0"] }, - { confidence: "certain" }, { risk: "urgent" }, { reason: " " }, { reason: "x".repeat(601) }, - { evidence: ["Invented widespread data loss"] }, { evidence: ["P0 Must fix next release"] }, - { evidence: [""] }, { evidence: "not an array" }, - { missing_information: ["x".repeat(251)] }, { missing_information: ["a", "b", "c", "d"] }, + { priority: "P9" }, + { priority: "__proto__" }, + { priority: "constructor" }, + { priority: ["P0"] }, + { confidence: "certain" }, + { risk: "urgent" }, + { reason: " " }, + { reason: "x".repeat(601) }, + { evidence: ["Invented widespread data loss"] }, + { evidence: ["P0 Must fix next release"] }, + { evidence: [""] }, + { evidence: "not an array" }, + { missing_information: ["x".repeat(251)] }, + { missing_information: ["a", "b", "c", "d"] }, ].map((override) => JSON.stringify(assessment(override))), ]) { assert.throws(() => parseAssessment(invalid, prepared)); @@ -151,14 +149,22 @@ test("labels independently of reporter and maintainer priority, removing only ob const result = await run(state.options); assert.equal(result.label, "ai-priority/P3"); assert.equal(state.modelCalls(), 1); - assert.deepEqual(state.calls.filter(({ method }) => method !== "GET"), [ - { method: "POST", path: "/labels", body: { - name: "ai-priority/P3", color: "0e8a16", - description: "AI: low repair/implementation priority; minor or optional improvement", - } }, - { method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } }, - { method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined }, - ]); + assert.deepEqual( + state.calls.filter(({ method }) => method !== "GET"), + [ + { + method: "POST", + path: "/labels", + body: { + name: "ai-priority/P3", + color: "0e8a16", + description: "AI: low repair/implementation priority; minor or optional improvement", + }, + }, + { method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } }, + { method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined }, + ], + ); }); test("does not rewrite an unchanged AI label", async () => { @@ -187,8 +193,7 @@ test("skips missing key, unrelated events, PRs, closed and opted-out issues befo test("edits reconcile priority even when a preceding workflow may have been canceled", async () => { for (const config of [ { action: "edited", changes: { body: { from: baseIssue.body.replace("P0", "P3") } } }, - { action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] }, - changes: { body: { from: baseIssue.body.replace("P0", "P3") } } }, + { action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] }, changes: { body: { from: baseIssue.body.replace("P0", "P3") } } }, { action: "reopened" }, { action: "edited", changes: { title: { from: "Old title" } } }, { action: "edited", changes: { body: { from: "Old body" } } }, @@ -211,10 +216,7 @@ test("uses fresh issue content and rechecks eligibility before paid assessment", }); test("does not publish stale results after changes, closure or maintainer opt-out", async () => { - for (const override of [ - { title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] }, - { state: "closed" }, { labels: ["ai-priority/skip"] }, - ]) { + for (const override of [{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] }, { state: "closed" }, { labels: ["ai-priority/skip"] }]) { const state = fixture({ latest: { ...baseIssue, ...override } }); assert.equal((await run(state.options)).skipped, "issue changed during assessment"); assert.equal(state.modelCalls(), 1); @@ -247,7 +249,9 @@ test("provider failures and invalid outputs preserve all existing labels and do async () => Response.json({ error: "secret-response-body" }, { status: 401 }), async () => Response.json({ error: "secret-response-body" }, { status: 429 }), async () => Response.json({ error: "secret-response-body" }, { status: 500 }), - async () => { throw new Error("secret-response-body"); }, + async () => { + throw new Error("secret-response-body"); + }, async () => new Response("secret-response-body"), async () => Response.json({ choices: [] }), async () => Response.json({ choices: [{ finish_reason: "length", message: { content: "secret-response-body" } }] }), @@ -282,7 +286,10 @@ test("failed label addition never removes the previous AI priority", async () => return original(method, path, body); }; await assert.rejects(run(state.options), /GitHub unavailable/); - assert.equal(state.calls.some(({ method }) => method === "DELETE"), false); + assert.equal( + state.calls.some(({ method }) => method === "DELETE"), + false, + ); }); test("invalid issue numbers are rejected before requests", async () => { @@ -299,7 +306,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes assert.throws(() => createGitHubClient({ token: "fixture-token", repository: "https://other.example" })); const calls = []; const github = createGitHubClient({ - token: "fixture-token", repository: "t8y2/dbx", + token: "fixture-token", + repository: "t8y2/dbx", fetchImpl: async (url, options) => { calls.push({ url, options }); return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json(baseIssue); @@ -311,7 +319,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes assert.ok(calls[0].options.signal instanceof AbortSignal); assert.equal(await github("DELETE", "/issues/42/labels/test"), null); const failing = createGitHubClient({ - token: "fixture-token", repository: "t8y2/dbx", + token: "fixture-token", + repository: "t8y2/dbx", fetchImpl: async () => new Response("secret-response-body", { status: 403 }), }); await assert.rejects(failing("GET", "/issues/42"), (error) => { diff --git a/.github/scripts/backfill-similar-issues.mjs b/.github/scripts/backfill-similar-issues.mjs index 26679bc73c..0e4434e834 100644 --- a/.github/scripts/backfill-similar-issues.mjs +++ b/.github/scripts/backfill-similar-issues.mjs @@ -33,23 +33,14 @@ export function selectChunk(issues, { chunkIndex, chunkCount, maxIssues = 0 }) { export async function listOpenUnassignedIssues(client) { const issues = []; for (let page = 1; ; page += 1) { - const items = await client.request( - "GET", - `/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`, - ); + const items = await client.request("GET", `/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`); issues.push(...items.filter((issue) => !issue.pull_request && (issue.assignees || []).length === 0)); if (items.length < 100) break; } return issues; } -export async function backfill({ - client, - chunkIndex, - chunkCount, - maxIssues = 0, - searchDelayMs = DEFAULT_SEARCH_DELAY_MS, -}) { +export async function backfill({ client, chunkIndex, chunkCount, maxIssues = 0, searchDelayMs = DEFAULT_SEARCH_DELAY_MS }) { const issues = selectChunk(await listOpenUnassignedIssues(client), { chunkIndex, chunkCount, maxIssues }); const originalSearchIssues = client.searchIssues.bind(client); let lastSearchStartedAt = 0; @@ -98,10 +89,6 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) chunkIndex: integerValue(process.env.BACKFILL_CHUNK_INDEX, 0, "BACKFILL_CHUNK_INDEX"), chunkCount: integerValue(process.env.BACKFILL_CHUNK_COUNT, 1, "BACKFILL_CHUNK_COUNT"), maxIssues: integerValue(process.env.BACKFILL_MAX_ISSUES, 0, "BACKFILL_MAX_ISSUES"), - searchDelayMs: integerValue( - process.env.BACKFILL_SEARCH_DELAY_MS, - DEFAULT_SEARCH_DELAY_MS, - "BACKFILL_SEARCH_DELAY_MS", - ), + searchDelayMs: integerValue(process.env.BACKFILL_SEARCH_DELAY_MS, DEFAULT_SEARCH_DELAY_MS, "BACKFILL_SEARCH_DELAY_MS"), }); } diff --git a/.github/scripts/backfill-similar-issues.test.mjs b/.github/scripts/backfill-similar-issues.test.mjs index 4c5aeefcd1..2f96dd7092 100644 --- a/.github/scripts/backfill-similar-issues.test.mjs +++ b/.github/scripts/backfill-similar-issues.test.mjs @@ -1,17 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - backfill, - listOpenUnassignedIssues, - selectChunk, -} from "./backfill-similar-issues.mjs"; +import { backfill, listOpenUnassignedIssues, selectChunk } from "./backfill-similar-issues.mjs"; test("selectChunk partitions issue numbers deterministically", () => { const issues = [1, 2, 3, 4, 5, 6].map((number) => ({ number })); - assert.deepEqual(selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number), [6, 3]); - assert.deepEqual(selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number), [4, 1]); + assert.deepEqual( + selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number), + [6, 3], + ); + assert.deepEqual( + selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number), + [4, 1], + ); assert.deepEqual( selectChunk(issues, { chunkIndex: 2, chunkCount: 3, maxIssues: 1 }).map(({ number }) => number), [5], @@ -34,8 +36,14 @@ test("listOpenUnassignedIssues filters pull requests and assigned issues across const issues = await listOpenUnassignedIssues(client); assert.equal(issues.length, 99); - assert.equal(issues.some(({ number }) => number === 1), false); - assert.equal(issues.some(({ number }) => number === 2), false); + assert.equal( + issues.some(({ number }) => number === 1), + false, + ); + assert.equal( + issues.some(({ number }) => number === 2), + false, + ); assert.equal(issues.at(-1).number, 101); }); diff --git a/.github/scripts/bump-agent-versions.mjs b/.github/scripts/bump-agent-versions.mjs index 58779afcf1..36c17d7146 100644 --- a/.github/scripts/bump-agent-versions.mjs +++ b/.github/scripts/bump-agent-versions.mjs @@ -5,10 +5,7 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs const VERSIONS_PATH = "agents/versions.json"; const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]"; const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]); -const NATIVE_RELEASE_PACKAGING_PATHS = new Set([ - ".github/scripts/reuse-agent-release-assets.mjs", - "agents/scripts/version_agent_artifacts.py", -]); +const NATIVE_RELEASE_PACKAGING_PATHS = new Set([".github/scripts/reuse-agent-release-assets.mjs", "agents/scripts/version_agent_artifacts.py"]); function bumpPatchVersion(version) { const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version); @@ -35,11 +32,7 @@ export function parseLegacyStandaloneProjects(buildGradle) { const match = /legacyStandaloneProjects\s*=\s*\[([^\]]*)\]/m.exec(buildGradle); if (!match) return new Set(); - return new Set( - [...match[1].matchAll(/['"]([^'"]+)['"]/g)] - .map((entry) => entry[1]) - .filter(Boolean), - ); + return new Set([...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((entry) => entry[1]).filter(Boolean)); } function fileContainsCommonDependency(path, moduleExists, readModuleFile) { @@ -72,23 +65,10 @@ const crateNativeDriverDirectories = { }; const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]); const nativeDriverSharedPaths = { - hive: [ - "agents/go-common/go-gssapi", - "agents/go-common/gohive", - "agents/go-common/gosasl", - ], - argo: [ - "agents/go-common/go-gssapi", - "agents/go-common/gohive", - "agents/go-common/gosasl", - ], - zookeeper: [ - "agents/go-common/go-gssapi", - "agents/go-common/gosasl", - ], - etcd: [ - "agents/go-common/go-semver", - ], + hive: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"], + argo: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"], + zookeeper: ["agents/go-common/go-gssapi", "agents/go-common/gosasl"], + etcd: ["agents/go-common/go-semver"], }; function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) { @@ -137,16 +117,7 @@ function classifyModules(versions, options) { .filter(({ module }) => module); } -export function evaluateAgentVersionBump({ - versions, - prevVersions = versions, - changedFiles, - legacyStandaloneModules = new Set(), - moduleExists = existsSync, - readModuleFile = (path) => readFileSync(path, "utf8"), - skipBump = false, - manualVersionsChanged = changedFiles.includes(VERSIONS_PATH), -}) { +export function evaluateAgentVersionBump({ versions, prevVersions = versions, changedFiles, legacyStandaloneModules = new Set(), moduleExists = existsSync, readModuleFile = (path) => readFileSync(path, "utf8"), skipBump = false, manualVersionsChanged = changedFiles.includes(VERSIONS_PATH) }) { const nextVersions = { ...versions }; const logs = []; let changed = false; @@ -180,8 +151,7 @@ export function evaluateAgentVersionBump({ } for (const { moduleName, module } of resolvedModules) { - const moduleChanged = [module.modulePath, ...module.sharedPaths] - .some((path) => pathChanged(changedFiles, path)); + const moduleChanged = [module.modulePath, ...module.sharedPaths].some((path) => pathChanged(changedFiles, path)); // Only modules that package agents/common need installer-visible updates // for shared Java runtime changes; native and standalone agents do not. const commonAffectsModule = commonChanged && module.commonDependent; @@ -233,22 +203,15 @@ function git(args) { } function lines(value) { - return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); } export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) { const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`])); - const versionCommits = lines( - gitOutput([ - "log", - "--reverse", - "--ancestry-path", - "--format=%H%x09%s", - `${prevTag}..${headRef}`, - "--", - VERSIONS_PATH, - ]), - ); + const versionCommits = lines(gitOutput(["log", "--reverse", "--ancestry-path", "--format=%H%x09%s", `${prevTag}..${headRef}`, "--", VERSIONS_PATH])); let syncCommit = ""; for (const entry of versionCommits) { @@ -266,12 +229,8 @@ export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutp const versionsRef = syncCommit || prevTag; const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`])); - const versionsChangedAfterSync = syncCommit - ? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0 - : false; - const changedFiles = syncCommit && !versionsChangedAfterSync - ? allChangedFiles.filter((file) => file !== VERSIONS_PATH) - : allChangedFiles; + const versionsChangedAfterSync = syncCommit ? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0 : false; + const changedFiles = syncCommit && !versionsChangedAfterSync ? allChangedFiles.filter((file) => file !== VERSIONS_PATH) : allChangedFiles; return { prevTag, diff --git a/.github/scripts/bump-agent-versions.test.mjs b/.github/scripts/bump-agent-versions.test.mjs index 577e7ebc6f..eb26f8a5e9 100644 --- a/.github/scripts/bump-agent-versions.test.mjs +++ b/.github/scripts/bump-agent-versions.test.mjs @@ -118,11 +118,7 @@ test("bumps the native RocketMQ agent from its Go directory", () => { }); test("bumps ZooKeeper from native and shared SASL source directories", () => { - for (const changedFile of [ - "agents/drivers/zookeeper/main.go", - "agents/go-common/gosasl/sasl.go", - "agents/go-common/go-gssapi/krb5/krb5.go", - ]) { + for (const changedFile of ["agents/drivers/zookeeper/main.go", "agents/go-common/gosasl/sasl.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) { const result = evaluateAgentVersionBump({ versions: { zookeeper: "0.1.0" }, changedFiles: [changedFile], @@ -161,12 +157,7 @@ test("bumps Cassandra from its native Go source directory", () => { }); test("bumps Hive from native and shared Kerberos source directories", () => { - for (const changedFile of [ - "agents/drivers/hive-go/main.go", - "agents/go-common/gohive/driver.go", - "agents/go-common/gosasl/gssapi.go", - "agents/go-common/go-gssapi/krb5/krb5.go", - ]) { + for (const changedFile of ["agents/drivers/hive-go/main.go", "agents/go-common/gohive/driver.go", "agents/go-common/gosasl/gssapi.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) { const result = evaluateAgentVersionBump({ versions: { hive: "0.1.43" }, changedFiles: [changedFile], @@ -207,12 +198,7 @@ test("bumps IoTDB from its native Go source directory", () => { }); test("rebuilds native modules when shared native packaging changes", () => { - const existing = new Set([ - "agents/drivers/access", - "agents/drivers/access/build.gradle", - "agents/drivers/duckdb", - "agents/drivers/neo4j-go", - ]); + const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/duckdb", "agents/drivers/neo4j-go"]); const result = evaluateAgentVersionBump({ versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" }, changedFiles: ["agents/scripts/version_agent_artifacts.py"], @@ -255,12 +241,7 @@ test("bumps DuckDB when its Cargo target configuration changes", () => { }); test("builds only common-dependent Java modules for a shared runtime change", () => { - const existing = new Set([ - "agents/drivers/access", - "agents/drivers/access/build.gradle", - "agents/drivers/mongodb", - "agents/drivers/mongodb/build.gradle", - ]); + const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/mongodb", "agents/drivers/mongodb/build.gradle"]); const result = evaluateAgentVersionBump({ versions: { access: "0.1.0", mongodb: "0.1.0" }, changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"], @@ -341,7 +322,7 @@ function createRepository(versions) { mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true }); writeVersions(repository, versions); writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n"); - writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n"); + writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), '[package]\nname = "duckdb-test"\n'); commitAll(repository, "feat(agents): initial release state"); return repository; } diff --git a/.github/scripts/ci-change-base.mjs b/.github/scripts/ci-change-base.mjs index 9e9914fb62..62720a3822 100644 --- a/.github/scripts/ci-change-base.mjs +++ b/.github/scripts/ci-change-base.mjs @@ -5,8 +5,12 @@ import { pathToFileURL } from "node:url"; export function changeBase({ eventName, before, root = process.cwd() }) { if (eventName === "pull_request") { const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], { - cwd: root, encoding: "utf8", stdio: "pipe", - }).trim().split(/\s+/); + cwd: root, + encoding: "utf8", + stdio: "pipe", + }) + .trim() + .split(/\s+/); if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents"); return commits[1]; } diff --git a/.github/scripts/ci-change-base.test.mjs b/.github/scripts/ci-change-base.test.mjs index e262c83e6d..1d02ba8aa1 100644 --- a/.github/scripts/ci-change-base.test.mjs +++ b/.github/scripts/ci-change-base.test.mjs @@ -52,10 +52,7 @@ test("PR routing uses the exact test merge first parent, excluding unrelated mai assert.equal(base, fixture.actualBase); assert.notEqual(base, fixture.pullRequestHead); const files = changedPaths(base, fixture.root); - assert.deepEqual(files.toSorted(), [ - "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs", - "crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs", - ].toSorted()); + assert.deepEqual(files.toSorted(), ["crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"].toSorted()); const metadata = { workspace_members: ["dbx-core", "dbx-web", "dbx"], packages: [ @@ -66,7 +63,10 @@ test("PR routing uses the exact test merge first parent, excluding unrelated mai }; const corrected = planCi({ files, metadata, root: fixture.root }); assert.equal(corrected.rust_full, false); - assert.deepEqual(corrected.rust_matrix.include.map((entry) => entry.group), ["application"]); + assert.deepEqual( + corrected.rust_matrix.include.map((entry) => entry.group), + ["application"], + ); assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]); assert.equal(corrected.agent_java, false); assert.deepEqual(corrected.agent_go.include, []); @@ -90,7 +90,9 @@ test("a depth-two checkout preserves the PR parents and supports the base CLI", assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase); assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root)); const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], { - cwd: checkout, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase }, + cwd: checkout, + encoding: "utf8", + env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase }, }); assert.equal(output.trim(), fixture.actualBase); }); diff --git a/.github/scripts/ci-config.mjs b/.github/scripts/ci-config.mjs index 8d49cf1dea..145da17d5e 100644 --- a/.github/scripts/ci-config.mjs +++ b/.github/scripts/ci-config.mjs @@ -1,13 +1,6 @@ export const rustGroups = { - foundation: [ - "dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql", - "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime", - ], - drivers: [ - "dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", - "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers", - "dbx-sqlite-worker", - ], + foundation: ["dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"], + drivers: ["dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers", "dbx-sqlite-worker"], application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"], }; @@ -32,7 +25,9 @@ export const integrationCases = [ { driver: "zookeeper", scenario: "zookeeper-sasl", version: "3.7.0" }, ...["4.9.8", "5.3.1"].map((version) => ({ driver: "rocketmq", scenario: "rocketmq", version })), ...["2.4.0.14", "2.6.0.34", "3.0.7.1", "3.3.6.13", "3.4.2.2"].map((version) => ({ - driver: "tdengine", scenario: "tdengine", version, + driver: "tdengine", + scenario: "tdengine", + version, image: `tdengine/${version === "3.4.2.2" ? "tsdb" : "tdengine"}:${version}`, })), ...["3.11.19", "5.0.6"].map((version) => ({ driver: "cassandra-go", scenario: "cassandra", version })), diff --git a/.github/scripts/ci-execution.test.mjs b/.github/scripts/ci-execution.test.mjs index bc503d6f1a..4cf57ae424 100644 --- a/.github/scripts/ci-execution.test.mjs +++ b/.github/scripts/ci-execution.test.mjs @@ -9,10 +9,24 @@ import { goAgents, integrationCases } from "./ci-config.mjs"; import { lockfileManifests } from "./ci-lockfiles.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); -const environmentKeys = ["CGO_ENABLED", "GOOS", "GOARCH", "GONOSUMDB", "DBX_ZOOKEEPER_TEST_CONNECT_STRING", - "DBX_ZOOKEEPER_TEST_AUTH_SCHEME", "DBX_ZOOKEEPER_TEST_USERNAME", "DBX_ZOOKEEPER_TEST_PASSWORD", "TDENGINE_INTEGRATION", - "TDENGINE_TEST_HOST", "TDENGINE_TEST_PORT", "CASSANDRA_TEST_HOST", "CASSANDRA_TEST_PORT", "RABBITMQ_INTEGRATION", - "RABBITMQ_USERNAME", "RABBITMQ_PASSWORD"]; +const environmentKeys = [ + "CGO_ENABLED", + "GOOS", + "GOARCH", + "GONOSUMDB", + "DBX_ZOOKEEPER_TEST_CONNECT_STRING", + "DBX_ZOOKEEPER_TEST_AUTH_SCHEME", + "DBX_ZOOKEEPER_TEST_USERNAME", + "DBX_ZOOKEEPER_TEST_PASSWORD", + "TDENGINE_INTEGRATION", + "TDENGINE_TEST_HOST", + "TDENGINE_TEST_PORT", + "CASSANDRA_TEST_HOST", + "CASSANDRA_TEST_PORT", + "RABBITMQ_INTEGRATION", + "RABBITMQ_USERNAME", + "RABBITMQ_PASSWORD", +]; function withToolStubs(run, extraEnv = {}) { const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-execution-")); @@ -20,7 +34,9 @@ function withToolStubs(run, extraEnv = {}) { mkdirSync(binaries); const log = path.join(directory, "commands.jsonl"); const stub = path.join(binaries, "stub.cjs"); - writeFileSync(stub, `#!/usr/bin/env node + writeFileSync( + stub, + `#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const tool = path.basename(process.argv[1]); @@ -29,100 +45,131 @@ const env = Object.fromEntries(${JSON.stringify(environmentKeys)}.filter(name => fs.appendFileSync(process.env.CI_STUB_LOG, JSON.stringify({tool,args,cwd:process.cwd(),env})+'\\n'); if (process.env.CI_STUB_FAIL === tool) process.exit(23); if (tool === 'docker' && args.includes('taos')) console.log('| ready |'); -`, { mode: 0o755 }); +`, + { mode: 0o755 }, + ); for (const name of ["docker", "go", "cargo", "timeout", "curl", "sleep", "bash"]) symlinkSync(stub, path.join(binaries, name)); try { const env = { ...process.env, ...extraEnv, TMPDIR: directory, CI_STUB_LOG: log, PATH: `${binaries}${path.delimiter}${process.env.PATH}` }; - return run(env, () => readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))); - } finally { rmSync(directory, { recursive: true, force: true }); } + return run(env, () => + readFileSync(log, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } } for (const agent of goAgents) { - test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () => withToolStubs((env, commands) => { - const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr); - const invocations = commands(); - assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]); - assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver))); - const builds = invocations.slice(1); - assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1); - for (const build of builds) { - assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]); - assert.equal(build.env.CGO_ENABLED, "0"); - } - if (agent.driver === "zookeeper") assert.deepEqual(builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`), - ["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"]); - else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`)); - if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver")); - })); + test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () => + withToolStubs((env, commands) => { + const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const invocations = commands(); + assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]); + assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver))); + const builds = invocations.slice(1); + assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1); + for (const build of builds) { + assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]); + assert.equal(build.env.CGO_ENABLED, "0"); + } + if (agent.driver === "zookeeper") + assert.deepEqual( + builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`), + ["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"], + ); + else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`)); + if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver")); + })); } for (const entry of integrationCases) { - test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () => withToolStubs((env, commands) => { - const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr); - const invocations = commands(); - if (entry.scenario === "rocketmq") { - assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version)); - return; - } - const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run"); - assert.ok(start, "starts the selected container"); - const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}` - : entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`); - assert.equal(start.args.at(-1), expectedImage); - const last = invocations.at(-1); - assert.equal(last.tool, "docker"); - assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]); - const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool)); - assert.ok(tests.length); - if (entry.scenario === "zookeeper") { - assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1); - assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$")); - if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$")); - } else if (entry.scenario === "zookeeper-sasl") { - assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest"); - assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx"); - assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true"))); - } else if (entry.scenario === "tdengine") { - assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]); - assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1"); - assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041"); - } else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$")); - else { - assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$")); - assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1"); - assert.ok(start.args.includes("999:999")); - assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700")); - } - })); + test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () => + withToolStubs((env, commands) => { + const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const invocations = commands(); + if (entry.scenario === "rocketmq") { + assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version)); + return; + } + const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run"); + assert.ok(start, "starts the selected container"); + const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}` : entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`); + assert.equal(start.args.at(-1), expectedImage); + const last = invocations.at(-1); + assert.equal(last.tool, "docker"); + assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]); + const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool)); + assert.ok(tests.length); + if (entry.scenario === "zookeeper") { + assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1); + assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$")); + if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$")); + } else if (entry.scenario === "zookeeper-sasl") { + assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest"); + assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx"); + assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true"))); + } else if (entry.scenario === "tdengine") { + assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]); + assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1"); + assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041"); + } else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$")); + else { + assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$")); + assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1"); + assert.ok(start.args.includes("999:999")); + assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700")); + } + })); } -test("failed live tests stay failed and still clean their container", () => withToolStubs((env, commands) => { - const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" }); - assert.equal(result.status, 23); - assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]); -}, { CI_STUB_FAIL: "go" })); +test("failed live tests stay failed and still clean their container", () => + withToolStubs( + (env, commands) => { + const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" }); + assert.equal(result.status, 23); + assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]); + }, + { CI_STUB_FAIL: "go" }, + )); -test("failed nextest live tests propagate failure and still clean their container", () => withToolStubs((env, commands) => { - const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" }); - assert.equal(result.status, 23); - assert.deepEqual(commands().find((command) => command.tool === "cargo").args.slice(0, 2), ["nextest", "run"]); - assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]); -}, { CI_STUB_FAIL: "cargo" })); +test("failed nextest live tests propagate failure and still clean their container", () => + withToolStubs( + (env, commands) => { + const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" }); + assert.equal(result.status, 23); + assert.deepEqual( + commands() + .find((command) => command.tool === "cargo") + .args.slice(0, 2), + ["nextest", "run"], + ); + assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]); + }, + { CI_STUB_FAIL: "cargo" }, + )); test("lock preflight resolves full graphs, keeps --locked and propagates failures", () => { const plan = { rust: true, agent_rust: { include: [{ driver: "duckdb" }, { driver: "tdengine" }] } }; assert.deepEqual(lockfileManifests(plan), ["Cargo.toml", "agents/drivers/duckdb/Cargo.toml", "agents/drivers/tdengine/Cargo.toml"]); assert.throws(() => lockfileManifests({ rust: false, agent_rust: { include: [{ driver: "../../unknown" }] } })); - for (const failure of ["", "cargo"]) withToolStubs((env, commands) => { - const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" }); - assert.equal(result.status, failure ? 23 : 0, result.stderr); - assert.equal(commands().length, failure ? 1 : 3); - for (const command of commands()) { - assert.equal(command.args[0], "metadata"); - assert.ok(command.args.includes("--locked")); - assert.ok(!command.args.includes("--no-deps")); - } - }, { CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure }); + for (const failure of ["", "cargo"]) + withToolStubs( + (env, commands) => { + const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" }); + assert.equal(result.status, failure ? 23 : 0, result.stderr); + assert.equal(commands().length, failure ? 1 : 3); + for (const command of commands()) { + assert.equal(command.args[0], "metadata"); + assert.ok(command.args.includes("--locked")); + assert.ok(!command.args.includes("--no-deps")); + } + }, + { CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure }, + ); }); diff --git a/.github/scripts/ci-gate.mjs b/.github/scripts/ci-gate.mjs index e20f083383..56e8b4f72f 100644 --- a/.github/scripts/ci-gate.mjs +++ b/.github/scripts/ci-gate.mjs @@ -4,33 +4,61 @@ import { pathToFileURL } from "node:url"; export function gateFailures(needs, mode) { if (needs.changes?.result !== "success") return ["changes did not succeed"]; let plan; - try { plan = JSON.parse(needs.changes.outputs.plan); } catch { return ["missing or invalid CI plan"]; } + try { + plan = JSON.parse(needs.changes.outputs.plan); + } catch { + return ["missing or invalid CI plan"]; + } if (!plan || typeof plan !== "object" || Array.isArray(plan)) return ["incomplete CI plan"]; const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"]; if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"]; - const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts", - "windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle", - "duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" }; + const routedJobs = { + frontend: "frontend", + packages: "packages", + "github-scripts": "github_scripts", + "windows-standard-check": "windows_standard", + "windows-win7-bundle": "windows_win7_bundle", + "duckdb-windows-driver": "duckdb_windows", + jdbc: "jdbc", + "offline-jdbc-release": "offline_jdbc", + "nix-packaging": "nix", + }; if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) { return ["missing or invalid job selection outputs"]; } if (mode === "frontend" && !["true", "false"].includes(needs.changes.outputs.frontend)) { return ["missing or invalid frontend selection output"]; } - const expected = mode === "rust" ? { - "fast-checks": plan.fast, "rust-fmt-clippy": plan.rust, "rust-test": plan.rust, - } : mode === "agents" ? { - "fast-checks": plan.fast, "agent-checks": plan.agents, "agent-java": plan.agent_java, "agent-go": plan.agent_go_changed, - "agent-rust": plan.agent_rust_changed, "agent-integration": plan.agent_integration_changed, - } : mode === "frontend" ? { - "frontend-checks": needs.changes.outputs.frontend === "true", - "frontend-typecheck": needs.changes.outputs.frontend === "true", - "frontend-test": needs.changes.outputs.frontend === "true", - } : mode === "all" ? { - rust: true, agents: true, "fast-checks": plan.fast, - ...Object.fromEntries(Object.entries(routedJobs) - .map(([job, output]) => [job, needs.changes.outputs[output] === "true"])), - } : null; + const expected = + mode === "rust" + ? { + "fast-checks": plan.fast, + "rust-fmt-clippy": plan.rust, + "rust-test": plan.rust, + } + : mode === "agents" + ? { + "fast-checks": plan.fast, + "agent-checks": plan.agents, + "agent-java": plan.agent_java, + "agent-go": plan.agent_go_changed, + "agent-rust": plan.agent_rust_changed, + "agent-integration": plan.agent_integration_changed, + } + : mode === "frontend" + ? { + "frontend-checks": needs.changes.outputs.frontend === "true", + "frontend-typecheck": needs.changes.outputs.frontend === "true", + "frontend-test": needs.changes.outputs.frontend === "true", + } + : mode === "all" + ? { + rust: true, + agents: true, + "fast-checks": plan.fast, + ...Object.fromEntries(Object.entries(routedJobs).map(([job, output]) => [job, needs.changes.outputs[output] === "true"])), + } + : null; if (!expected) return [`unknown gate: ${mode}`]; return Object.entries(expected).flatMap(([job, required]) => { const result = needs[job]?.result; diff --git a/.github/scripts/ci-lockfiles.mjs b/.github/scripts/ci-lockfiles.mjs index b15b786aa4..74d0cefd14 100644 --- a/.github/scripts/ci-lockfiles.mjs +++ b/.github/scripts/ci-lockfiles.mjs @@ -3,11 +3,13 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; export function lockfileManifests(plan) { - return [...(plan.rust ? ["Cargo.toml"] : []), + return [ + ...(plan.rust ? ["Cargo.toml"] : []), ...plan.agent_rust.include.map(({ driver }) => { if (!["duckdb", "tdengine"].includes(driver)) throw new Error(`Unknown native Rust driver: ${driver}`); return `agents/drivers/${driver}/Cargo.toml`; - })]; + }), + ]; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { diff --git a/.github/scripts/ci-plan.mjs b/.github/scripts/ci-plan.mjs index 6cd81cba04..86c3ae134e 100644 --- a/.github/scripts/ci-plan.mjs +++ b/.github/scripts/ci-plan.mjs @@ -16,16 +16,7 @@ const win7InfrastructureFiles = new Set([ "src-tauri/tauri.webview2-win7-fixed.conf.json", "src-tauri/build.rs", ]); -const win7InfrastructurePrefixes = [ - ".cargo/", - "rust-toolchain", - ".github/fixtures/win7-", - "src-tauri/windows/nsis/", - "src-tauri/src/commands/update", - "crates/dbx-core/src/host/update", - "crates/dbx-platform/", - "vendor/", -]; +const win7InfrastructurePrefixes = [".cargo/", "rust-toolchain", ".github/fixtures/win7-", "src-tauri/windows/nsis/", "src-tauri/src/commands/update", "crates/dbx-core/src/host/update", "crates/dbx-platform/", "vendor/"]; function expandAffectedPackages(packages, members, affected) { let expanded; @@ -44,17 +35,19 @@ export function changedPaths(base, root) { if (!base || /^0+$/.test(base)) return null; execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd: root, stdio: "pipe" }); return execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, "HEAD", "--"], { - cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, - }).split("\0").filter(Boolean); + cwd: root, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }) + .split("\0") + .filter(Boolean); } export function knownJavaDrivers(root, nativeDrivers) { try { const driversRoot = path.join(root, "agents/drivers"); return readdirSync(driversRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() - && existsSync(path.join(driversRoot, entry.name, "build.gradle")) - && !nativeDrivers.includes(entry.name)) + .filter((entry) => entry.isDirectory() && existsSync(path.join(driversRoot, entry.name, "build.gradle")) && !nativeDrivers.includes(entry.name)) .map((entry) => entry.name) .sort(); } catch { @@ -63,9 +56,13 @@ export function knownJavaDrivers(root, nativeDrivers) { } export function cargoMetadata(root, execute = execFileSync) { - return JSON.parse(execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], { - cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, - })); + return JSON.parse( + execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], { + cwd: root, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }), + ); } export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) { @@ -75,8 +72,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust const members = new Map(packages.map((pkg) => [pkg.name, pkg])); const packagePaths = packages.map((pkg) => [pkg.name, `${path.relative(root, path.dirname(pkg.manifest_path)).split(path.sep).join("/")}/`]); const ciChanged = unknownDiff || files.some((file) => /^\.github\/(?:workflows\/ci(?:[.-]|\/)|scripts\/ci-|actions\/ci-)/.test(file)); - const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file) - || /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file)); + const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file) || /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file)); const affected = new Set(); let unknownRust = false; for (const file of files) { @@ -94,11 +90,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust expandAffectedPackages(packages, members, win7Affected); if (full) for (const pkg of packages) affected.add(pkg.name); expandAffectedPackages(packages, members, affected); - const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file) - || win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)) - || /^src-tauri\/tauri.*\.json$/.test(file)); - const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock") - || files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file)); + const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file) || win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)) || /^src-tauri\/tauri.*\.json$/.test(file)); + const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock") || files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file)); const windowsWin7Reasons = { infrastructure: win7InfrastructureChanged, dependency_input: win7DependencyChanged, @@ -107,20 +100,24 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust unknown_member: rust && unknownMember, }; const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean); - const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups) - .filter(([, names]) => names.some((name) => affected.has(name))) - .map(([group]) => ({ group })); + const rustMatrix = !rust + ? [] + : full + ? [{ group: "workspace" }] + : Object.entries(rustGroups) + .filter(([, names]) => names.some((name) => affected.has(name))) + .map(([group]) => ({ group })); const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents]; const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers); const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)))); const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))); // Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown // agents/drivers// paths still fail open to full coverage below. - const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/") - && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) - && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) - || files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" - || file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json"); + const sharedAgents = + ciChanged || + sharedRust || + files.some((file) => file.startsWith("agents/") && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) || + files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" || file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json"); const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges); if (allAgents) { for (const driver of nativeDrivers) nativeChanges.add(driver); @@ -133,11 +130,19 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges; return { contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"), - rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember, + rust, + rust_full: full, + rust_matrix: { include: rustMatrix }, + rust_groups_known: !unknownMember, affected_packages: [...affected].sort(), - agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix }, - agent_integration: { include: liveMatrix }, agent_go_changed: goMatrix.length > 0, - agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0, + agents, + agent_java: java, + agent_go: { include: goMatrix }, + agent_rust: { include: nativeRustMatrix }, + agent_integration: { include: liveMatrix }, + agent_go_changed: goMatrix.length > 0, + agent_rust_changed: nativeRustMatrix.length > 0, + agent_integration_changed: liveMatrix.length > 0, duckdb_changed: nativeChanges.has("duckdb"), duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")), windows_standard: windowsWin7Candidate, @@ -153,12 +158,21 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar const root = process.cwd(); const metadata = cargoMetadata(root); const plan = planCi({ - files: changedPaths(process.env.BASE_SHA, root), metadata, root, eventName: process.env.GITHUB_EVENT_NAME, - rustChanged: process.env.RUST_CHANGED === "true", agentsChanged: process.env.AGENTS_CHANGED === "true", + files: changedPaths(process.env.BASE_SHA, root), + metadata, + root, + eventName: process.env.GITHUB_EVENT_NAME, + rustChanged: process.env.RUST_CHANGED === "true", + agentsChanged: process.env.AGENTS_CHANGED === "true", }); if (process.env.GITHUB_OUTPUT) { const outputs = { ...plan, plan }; - appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join("")); + appendFileSync( + process.env.GITHUB_OUTPUT, + Object.entries(outputs) + .map(([name, value]) => `${name}=${JSON.stringify(value)}\n`) + .join(""), + ); } console.log(JSON.stringify(plan, null, 2)); } diff --git a/.github/scripts/ci-plan.test.mjs b/.github/scripts/ci-plan.test.mjs index 1c6fc37404..c72810fcb0 100644 --- a/.github/scripts/ci-plan.test.mjs +++ b/.github/scripts/ci-plan.test.mjs @@ -12,11 +12,16 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs"; const root = "/fixture"; const graph = { - "dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"], - "dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"], + "dbx-types": [], + "dbx-platform": [], + "dbx-formats": [], + "dbx-sql-core": ["dbx-types"], + "dbx-sql-dialect": ["dbx-types"], + "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"], "dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"], "dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"], - "dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"], + "dbx-ai-provider": ["dbx-platform"], + "dbx-plugin-runtime": ["dbx-platform", "dbx-types"], "dbx-driver-support": ["dbx-platform", "dbx-types"], "dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"], "dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"], @@ -25,17 +30,22 @@ const graph = { "dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"], "dbx-driver-redis": ["dbx-driver-support", "dbx-types"], "dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"], - "dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", - "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support", - "dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"], + "dbx-sqlite-worker": [], + "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support", "dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"], "dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"], - "dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"], - "dbx-tauri-consul": ["dbx-core"], "dbx-tauri-schema": ["dbx-core"], + "dbx-mcp": ["dbx-core"], + "dbx-cli": ["dbx-core", "dbx-mcp"], + "dbx-web": ["dbx-core", "dbx-mcp"], + dbx: ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"], + "dbx-tauri-consul": ["dbx-core"], + "dbx-tauri-schema": ["dbx-core"], }; const metadata = { workspace_members: Object.keys(graph), packages: Object.entries(graph).map(([name, dependencies]) => ({ - id: name, name, manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`, + id: name, + name, + manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`, dependencies: dependencies.map((dependency) => ({ name: dependency })), })), }; @@ -53,17 +63,22 @@ test("the planner uses the runner stable Cargo only for metadata", () => { assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]); assert.equal(invocation.options.cwd, "/workspace"); }); -const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts", - "windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle", - "duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" }; +const routedJobs = { + frontend: "frontend", + packages: "packages", + "github-scripts": "github_scripts", + "windows-standard-check": "windows_standard", + "windows-win7-bundle": "windows_win7_bundle", + "duckdb-windows-driver": "duckdb_windows", + jdbc: "jdbc", + "offline-jdbc-release": "offline_jdbc", + "nix-packaging": "nix", +}; test("only an exact contributor snapshot diff skips desktop frontend validation", () => { for (const eventName of ["pull_request", "push"]) { assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true); - for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"], - ["docs/data/contributors.json", "apps/desktop/src/main.ts"], - ["docs/data/contributors.json", "docs/lib/contributorActivity.ts"], - ["docs/data/contributors.json", "Cargo.lock"]]) { + for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"], ["docs/data/contributors.json", "apps/desktop/src/main.ts"], ["docs/data/contributors.json", "docs/lib/contributorActivity.ts"], ["docs/data/contributors.json", "Cargo.lock"]]) { assert.equal(plan(files, { eventName }).contributor_snapshot_only, false); } } @@ -97,9 +112,16 @@ test("Win7 candidate follows the desktop package dependency graph", () => { }); test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => { - for (const file of ["crates/dbx-core/src/query/mod.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs", - "crates/dbx-drivers/src/lib.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-driver-mysql/tests/query.rs", - "src-tauri/src/commands/query.rs", "crates/dbx-tauri-consul/src/lib.rs", "crates/dbx-tauri-schema/src/lib.rs"]) { + for (const file of [ + "crates/dbx-core/src/query/mod.rs", + "crates/dbx-core/tests/runtime_diagnostic_history.rs", + "crates/dbx-drivers/src/lib.rs", + "crates/dbx-driver-postgres/src/postgres.rs", + "crates/dbx-driver-mysql/tests/query.rs", + "src-tauri/src/commands/query.rs", + "crates/dbx-tauri-consul/src/lib.rs", + "crates/dbx-tauri-schema/src/lib.rs", + ]) { const result = plan([file]); assert.equal(result.windows_standard, true, file); assert.equal(result.windows_win7_bundle, false, file); @@ -112,12 +134,7 @@ test("ordinary desktop Rust changes keep Windows compilation without full Win7 p }); test("old Win7 Rust paths remain candidates through the dependency graph", () => { - for (const file of [ - "src-tauri/src/commands/update.rs", - "crates/dbx-core/src/host/update.rs", - "crates/dbx-driver-postgres/src/postgres.rs", - "crates/dbx-platform/src/lib.rs", - ]) { + for (const file of ["src-tauri/src/commands/update.rs", "crates/dbx-core/src/host/update.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-platform/src/lib.rs"]) { assert.equal(plan([file]).windows_win7_candidate, true, file); } }); @@ -215,8 +232,7 @@ test("code generation and test-only dependencies participate in impact analysis" assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types")); }); -for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", - "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) { +for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) { test(`shared input ${file} retains full workspace and Agent coverage`, () => { const result = plan([file]); assert.deepEqual(groups(result), ["workspace"]); @@ -275,8 +291,7 @@ test("known JDBC driver changes only select Java agent tests", () => { }); test("shared Agent inputs and unknown native modules never silently lose coverage", () => { - for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", - "agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) { + for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", "agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) { const result = plan([file]); assert.equal(result.agent_go.include.length, goAgents.length, file); assert.equal(result.agent_integration.include.length, 16, file); @@ -289,13 +304,32 @@ test("shared Agent inputs and unknown native modules never silently lose coverag }); test("the independent live matrix preserves every old version and authentication case", () => { - assert.deepEqual(integrationCases.map((entry) => `${entry.scenario}:${entry.version}`), [ - "zookeeper:3.4.14", "zookeeper:3.5.5", "zookeeper:3.7.0", "zookeeper:3.9.5", "zookeeper-sasl:3.7.0", - "rocketmq:4.9.8", "rocketmq:5.3.1", "tdengine:2.4.0.14", "tdengine:2.6.0.34", "tdengine:3.0.7.1", - "tdengine:3.3.6.13", "tdengine:3.4.2.2", "cassandra:3.11.19", "cassandra:5.0.6", "rabbitmq:3.13", "rabbitmq:4.3", - ]); + assert.deepEqual( + integrationCases.map((entry) => `${entry.scenario}:${entry.version}`), + [ + "zookeeper:3.4.14", + "zookeeper:3.5.5", + "zookeeper:3.7.0", + "zookeeper:3.9.5", + "zookeeper-sasl:3.7.0", + "rocketmq:4.9.8", + "rocketmq:5.3.1", + "tdengine:2.4.0.14", + "tdengine:2.6.0.34", + "tdengine:3.0.7.1", + "tdengine:3.3.6.13", + "tdengine:3.4.2.2", + "cassandra:3.11.19", + "cassandra:5.0.6", + "rabbitmq:3.13", + "rabbitmq:4.3", + ], + ); assert.equal(integrationCases.find((entry) => entry.version === "3.4.2.2").image, "tdengine/tsdb:3.4.2.2"); - assert.deepEqual(goAgents.filter((entry) => entry.race).map((entry) => entry.driver), ["rocketmq", "zookeeper"]); + assert.deepEqual( + goAgents.filter((entry) => entry.race).map((entry) => entry.driver), + ["rocketmq", "zookeeper"], + ); }); test("test groups partition every current workspace package exactly once", () => { @@ -309,8 +343,7 @@ test("Rust groups cover the actual workspace metadata, including extracted Tauri const actual = cargoMetadata(repository); const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name); assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted()); - const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"], - metadata: actual, root: repository }); + const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"], metadata: actual, root: repository }); assert.equal(result.rust_groups_known, true); assert.equal(result.rust_full, false); assert.deepEqual(groups(result), ["application"]); @@ -361,13 +394,17 @@ test("nextest, doctests and coverage select identical packages and features in e function results(files) { const selected = plan(files); - return { changes: { result: "success", outputs: { plan: JSON.stringify(selected) } }, + return { + changes: { result: "success", outputs: { plan: JSON.stringify(selected) } }, "fast-checks": { result: selected.fast ? "success" : "skipped" }, - "rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" }, "rust-test": { result: selected.rust ? "success" : "skipped" }, - "agent-checks": { result: selected.agents ? "success" : "skipped" }, "agent-java": { result: selected.agent_java ? "success" : "skipped" }, + "rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" }, + "rust-test": { result: selected.rust ? "success" : "skipped" }, + "agent-checks": { result: selected.agents ? "success" : "skipped" }, + "agent-java": { result: selected.agent_java ? "success" : "skipped" }, "agent-go": { result: selected.agent_go_changed ? "success" : "skipped" }, "agent-rust": { result: selected.agent_rust_changed ? "success" : "skipped" }, - "agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" } }; + "agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" }, + }; } test("gates accept only successful selected jobs and intentionally skipped unselected jobs", () => { @@ -410,17 +447,23 @@ test("git diff routing includes both sides of renames, deleted files, and unusua const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-plan-")); const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: "pipe" }).trim(); try { - git("init"); git("config", "user.email", "fixture@example.invalid"); git("config", "user.name", "CI Fixture"); + git("init"); + git("config", "user.email", "fixture@example.invalid"); + git("config", "user.name", "CI Fixture"); writeFileSync(path.join(directory, "old.rs"), "unchanged source\n"); writeFileSync(path.join(directory, "deleted.rs"), "gone\n"); - git("add", "."); git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline"); + git("add", "."); + git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline"); const base = git("rev-parse", "HEAD"); - git("mv", "old.rs", "renamed\nfile.rs"); git("rm", "deleted.rs"); + git("mv", "old.rs", "renamed\nfile.rs"); + git("rm", "deleted.rs"); git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture change"); assert.deepEqual(changedPaths(base, directory).toSorted(), ["old.rs", "deleted.rs", "renamed\nfile.rs"].toSorted()); assert.equal(changedPaths("0".repeat(40), directory), null); assert.throws(() => changedPaths("not-a-ref", directory)); - } finally { rmSync(directory, { recursive: true, force: true }); } + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); test("the final gate rejects absent routing outputs and skipped selected jobs", () => { @@ -483,8 +526,7 @@ test("the final gate independently requires standard Windows compilation without }); test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => { - const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])), - ...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) }; + const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])), ...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) }; assertCoverage(coverage); const lost = structuredClone(coverage); lost.foundation["dbx-types"] = []; diff --git a/.github/scripts/ci-rust-coverage.mjs b/.github/scripts/ci-rust-coverage.mjs index 9ce356e499..828c1d8b6f 100644 --- a/.github/scripts/ci-rust-coverage.mjs +++ b/.github/scripts/ci-rust-coverage.mjs @@ -6,11 +6,17 @@ import { rustGroups } from "./ci-config.mjs"; import { rustCommand } from "./ci-rust.mjs"; export function parseCoverage(output) { - return Object.fromEntries(output.trim().split("\n").filter(Boolean).map((line) => { - const [name, features] = line.split("|"); - assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`); - return [name.split(" ")[0], features.split(",").filter(Boolean)]; - })); + return Object.fromEntries( + output + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [name, features] = line.split("|"); + assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`); + return [name.split(" ")[0], features.split(",").filter(Boolean)]; + }), + ); } export function assertCoverage(coverage) { diff --git a/.github/scripts/ci-rust.mjs b/.github/scripts/ci-rust.mjs index 6b633e98f2..5e072ae597 100644 --- a/.github/scripts/ci-rust.mjs +++ b/.github/scripts/ci-rust.mjs @@ -4,26 +4,43 @@ import { pathToFileURL } from "node:url"; import { rustGroups } from "./ci-config.mjs"; export function rustCommand(action, group, mode) { - if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode) - || (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) { + if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode) || (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) { throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`); } const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"]; if (mode === "full") capabilityFeatures.push("system-fonts"); const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`)); - const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi", - "dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar", - "dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"]; - const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [ - ...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`), - "dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support", - "dbx-driver-support/test-support", - "dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi", - ] : appFeatures; + const foundationFeatures = [ + "dbx-types/mq-admin", + "dbx-types/openapi", + "dbx-sql/duckdb-sidecar", + "dbx-sql/openapi", + "dbx-sql-data/openapi", + "dbx-sql-schema/duckdb-sidecar", + "dbx-platform/downloads", + "dbx-platform/host-prompts", + "dbx-platform/test-support", + "dbx-plugin-runtime/default", + "dbx-plugin-runtime/test-support", + ]; + const features = + group === "foundation" + ? foundationFeatures + : group === "drivers" + ? [ + ...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`), + "dbx-driver-agent/test-support", + "dbx-driver-mysql/test-support", + "dbx-driver-redis/test-support", + "dbx-driver-support/test-support", + "dbx-sqlite-worker/runtime", + "dbx-types/openapi", + "dbx-sql-data/openapi", + ] + : appFeatures; const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]); const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action]; - return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []), - "--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])]; + return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []), "--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])]; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { diff --git a/.github/scripts/ci-vitest-file-timing-reporter.test.mjs b/.github/scripts/ci-vitest-file-timing-reporter.test.mjs index 734d3b9b48..3d5bb787b4 100644 --- a/.github/scripts/ci-vitest-file-timing-reporter.test.mjs +++ b/.github/scripts/ci-vitest-file-timing-reporter.test.mjs @@ -29,10 +29,13 @@ test("file timing reports sort total work and keep timing phases", () => { }), ]); - assert.deepEqual(rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })), [ - { file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 }, - { file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 }, - ]); + assert.deepEqual( + rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })), + [ + { file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 }, + { file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 }, + ], + ); assert.match(consoleReport(rows, 1), /0\.20s slow\.spec\.ts/); assert.doesNotMatch(consoleReport(rows, 1), /fast\.spec\.ts/); assert.match(markdownReport(rows, "1\/2"), /Vitest file timings \(1\/2\)/); diff --git a/.github/scripts/ci-workflow.test.mjs b/.github/scripts/ci-workflow.test.mjs index 454c10aeb9..2e3e0b689c 100644 --- a/.github/scripts/ci-workflow.test.mjs +++ b/.github/scripts/ci-workflow.test.mjs @@ -20,26 +20,27 @@ function concurrencySettings(github) { assert.ok(block, "missing workflow concurrency"); const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)])); const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase()); - return Object.fromEntries(block.trim().split("\n").map((line) => { - const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/); - const evaluate = (expression) => - new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith); - const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)]; - return [setting, expressions.length === 1 && expressions[0][0] === value - ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))]; - })); + return Object.fromEntries( + block + .trim() + .split("\n") + .map((line) => { + const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/); + const evaluate = (expression) => new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith); + const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)]; + return [setting, expressions.length === 1 && expressions[0][0] === value ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))]; + }), + ); } function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) { - return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, - event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} }; + return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} }; } test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => { const even = concurrencySettings(concurrencyContext()); assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" }); - for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), - concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) { + for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) { assert.deepEqual(concurrencySettings(context), even); } for (const number of [101, 11849, 11853, 7]) { @@ -64,8 +65,7 @@ test("main pushes retain their independent cancellation group without PR context }); test("expanded author queues never combine max queuing with in-progress cancellation", () => { - for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }), - concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) { + for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }), concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) { const settings = concurrencySettings(context); assert.ok(settings.queue === "single" || settings.queue === "max"); assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false); @@ -202,7 +202,11 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context) const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1]; assert.ok(setup, name); assert.doesNotMatch(setup, /^\s+if:/m); - const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n"); + const script = setup + .match(/ run: \|\n([\s\S]*)/)[1] + .split("\n") + .map((line) => line.slice(10)) + .join("\n"); for (const remote of [true, false]) { const output = path.join(directory, `${name}-${remote}.env`); const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], { @@ -221,16 +225,25 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context) }); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, ""); - const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => { - const separator = line.indexOf("="); - return [line.slice(0, separator), line.slice(separator + 1)]; - })); - assert.deepEqual(exported, { - ...(remote ? backend : {}), - SCCACHE_IDLE_TIMEOUT: "0", - CC: `${compiler} cc`, - CXX: `${compiler} c++`, - }, `${name}: ${remote ? "S3" : "local"}`); + const exported = Object.fromEntries( + readFileSync(output, "utf8") + .trim() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); + assert.deepEqual( + exported, + { + ...(remote ? backend : {}), + SCCACHE_IDLE_TIMEOUT: "0", + CC: `${compiler} cc`, + CXX: `${compiler} c++`, + }, + `${name}: ${remote ? "S3" : "local"}`, + ); } } }); @@ -323,7 +336,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+ assert.ok(win7.includes("--config .github/fixtures/win7-host-repro-config.toml")); const hostConfig = readFileSync(new URL("../fixtures/win7-host-repro-config.toml", import.meta.url), "utf8"); assert.ok(hostConfig.includes("target-applies-to-host = false")); - assert.ok(hostConfig.includes('[host.x86_64-pc-windows-msvc]')); + assert.ok(hostConfig.includes("[host.x86_64-pc-windows-msvc]")); assert.ok(hostConfig.includes('rustflags = ["-Clink-arg=/Brepro"]')); assert.ok(win7.includes("--timings")); assert.ok(win7.includes("name: DBX-win7-cargo-timings")); @@ -366,8 +379,7 @@ test("path filtering and the planner use the exact test merge first parent", () assert.ok(changes.includes("node .github/scripts/ci-plan.mjs")); assert.doesNotMatch(changes, /fetch-depth:\s*0/); assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/); - for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration", - "windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) { + for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration", "windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) { assert.ok(changes.includes(`steps.plan.outputs.${flag}`)); } assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/); diff --git a/.github/scripts/cleanup-cnb-releases.mjs b/.github/scripts/cleanup-cnb-releases.mjs index f4d40d66e8..bab458998e 100644 --- a/.github/scripts/cleanup-cnb-releases.mjs +++ b/.github/scripts/cleanup-cnb-releases.mjs @@ -33,9 +33,7 @@ function parseArgs(argv) { } if (!args.token) throw new Error("CNB_TOKEN is required."); if (!args.currentTag || !args.tagPattern || !Number.isInteger(args.retain) || args.retain < 1) { - throw new Error( - "Usage: cleanup-cnb-releases.mjs --current-tag --tag-pattern --retain [--apply]", - ); + throw new Error("Usage: cleanup-cnb-releases.mjs --current-tag --tag-pattern --retain [--apply]"); } try { new RegExp(args.tagPattern); diff --git a/.github/scripts/cleanup-cnb-releases.test.mjs b/.github/scripts/cleanup-cnb-releases.test.mjs index 202527f0f3..bac2758cc3 100644 --- a/.github/scripts/cleanup-cnb-releases.test.mjs +++ b/.github/scripts/cleanup-cnb-releases.test.mjs @@ -17,18 +17,11 @@ const appReleases = [ ]; test("retention only removes old releases from the selected family", () => { - const plan = planReleaseRetention( - [ - release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"), - release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"), - ...appReleases.toReversed(), - ], - { - currentTag: "v0.5.69", - tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$", - retain: 5, - }, - ); + const plan = planReleaseRetention([release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"), release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"), ...appReleases.toReversed()], { + currentTag: "v0.5.69", + tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$", + retain: 5, + }); assert.equal(plan.skipped, ""); assert.deepEqual( diff --git a/.github/scripts/database-issue-catalog.mjs b/.github/scripts/database-issue-catalog.mjs index 9d923b7562..625610fd64 100644 --- a/.github/scripts/database-issue-catalog.mjs +++ b/.github/scripts/database-issue-catalog.mjs @@ -1,36 +1,8 @@ import fs from "node:fs"; const extraAliases = { - mysql: [ - "mariadb", - "percona", - "tidb", - "greatsql", - "oceanbase mysql", - "oceanbase mysql mode", - "oceanbase mysql 模式", - "tdsql mysql", - "tdsql-mysql", - "polardb mysql", - "polardb for mysql", - "polardb-x", - "polardbx", - ], - postgres: [ - "postgresql", - "postgres", - "pgsql", - "pg", - "hologres", - "cloudberry", - "apache cloudberry", - "cockroachdb", - "cockroach db", - "polardb postgresql", - "polardb for postgresql", - "polardb postgres", - "polardb for postgres", - ], + mysql: ["mariadb", "percona", "tidb", "greatsql", "oceanbase mysql", "oceanbase mysql mode", "oceanbase mysql 模式", "tdsql mysql", "tdsql-mysql", "polardb mysql", "polardb for mysql", "polardb-x", "polardbx"], + postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres", "cloudberry", "apache cloudberry", "cockroachdb", "cockroach db", "polardb postgresql", "polardb for postgresql", "polardb postgres", "polardb for postgres"], sqlite: ["sqlite3", "sql lite"], clickhouse: ["click house", "ch"], sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"], @@ -104,9 +76,7 @@ const catalogEntries = [ }), ...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)), ]; -const duplicateDbTypes = catalogEntries - .map((driver) => driver.dbType) - .filter((dbType, index, values) => values.indexOf(dbType) !== index); +const duplicateDbTypes = catalogEntries.map((driver) => driver.dbType).filter((dbType, index, values) => values.indexOf(dbType) !== index); if (duplicateDbTypes.length > 0) { throw new Error(`Duplicate database issue catalog entries: ${[...new Set(duplicateDbTypes)].join(", ")}`); @@ -115,12 +85,5 @@ if (duplicateDbTypes.length > 0) { export const databaseIssueDrivers = catalogEntries.map((driver) => ({ dbType: driver.dbType, label: driver.label, - aliases: [ - ...new Set([ - driver.dbType, - driver.label, - ...(driver.aliases || []), - ...(extraAliases[driver.dbType] || []), - ]), - ], + aliases: [...new Set([driver.dbType, driver.label, ...(driver.aliases || []), ...(extraAliases[driver.dbType] || [])])], })); diff --git a/.github/scripts/install-mcp.test.mjs b/.github/scripts/install-mcp.test.mjs index a18dc7179d..07e537873a 100644 --- a/.github/scripts/install-mcp.test.mjs +++ b/.github/scripts/install-mcp.test.mjs @@ -21,20 +21,32 @@ function fixture(context, platform = "darwin-arm64") { const binary = join(source, "package/bin/dbx-mcp"); writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 }); copyFileSync(binary, join(source, "dbx-mcp")); - for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) { + for (const [archive, member] of [ + ["npm.tgz", "package/bin/dbx-mcp"], + ["github.tar.gz", "dbx-mcp"], + ]) { const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]); assert.equal(result.status, 0, result.stderr.toString()); } - const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64"); - const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex"); + const integrity = + "sha512-" + + createHash("sha512") + .update(readFileSync(join(root, "npm.tgz"))) + .digest("base64"); + const checksum = createHash("sha256") + .update(readFileSync(join(root, "github.tar.gz"))) + .digest("hex"); writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" })); writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } })); - writeFileSync(join(root, "refs.json"), JSON.stringify([ - { ref: "refs/tags/packages-v0.4.9", object: {} }, - { ref: "refs/tags/packages-v0.4.96", object: {} }, - { ref: "refs/tags/packages-v1.0.0-beta.1", object: {} }, - { ref: "refs/tags/packages-v0.4.10", object: {} }, - ])); + writeFileSync( + join(root, "refs.json"), + JSON.stringify([ + { ref: "refs/tags/packages-v0.4.9", object: {} }, + { ref: "refs/tags/packages-v0.4.96", object: {} }, + { ref: "refs/tags/packages-v1.0.0-beta.1", object: {} }, + { ref: "refs/tags/packages-v0.4.10", object: {} }, + ]), + ); writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`); const routes = {}; for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) { @@ -46,7 +58,9 @@ function fixture(context, platform = "darwin-arm64") { routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS"; routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz"; writeFileSync(join(root, "routes.json"), JSON.stringify(routes)); - writeFileSync(join(mocks, "curl"), `#!${process.execPath} + writeFileSync( + join(mocks, "curl"), + `#!${process.execPath} const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); @@ -57,9 +71,13 @@ if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process. const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json'))); if (!routes[url]) process.exit(22); fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]); -`, { mode: 0o755 }); +`, + { mode: 0o755 }, + ); writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 }); - writeFileSync(join(mocks, "codesign"), `#!${process.execPath} + writeFileSync( + join(mocks, "codesign"), + `#!${process.execPath} const args = process.argv.slice(2); const mode = process.env.SIGNATURE_MODE || 'valid'; const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"'; @@ -73,13 +91,17 @@ console.error('Identifier=com.dbx.app.mcp'); console.error('TeamIdentifier=TVDM965TDL'); console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026')); console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */'))); -`, { mode: 0o755 }); +`, + { mode: 0o755 }, + ); const installedBinary = join(home, ".dbx/bin/dbx-mcp"); const marker = join(home, ".dbx/bin/.dbx-mcp-version"); - const run = (extra = {}) => spawnSync("sh", [installer], { - env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra }, - encoding: "utf8", timeout: 30_000, - }); + const run = (extra = {}) => + spawnSync("sh", [installer], { + env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra }, + encoding: "utf8", + timeout: 30_000, + }); return { root, home, mocks, binary: installedBinary, marker, run }; } @@ -92,7 +114,10 @@ for (const platform of platforms) { assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n"); const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"'))); assert.equal(json.mcpServers.dbx.command, setup.binary); - const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10); + const tomlCommand = result.stdout + .split("\n") + .find((line) => line.startsWith("command = ")) + .slice(10); assert.equal(JSON.parse(tomlCommand), setup.binary); assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]); }); diff --git a/.github/scripts/issue-commands.mjs b/.github/scripts/issue-commands.mjs index d61c638703..56100539c9 100644 --- a/.github/scripts/issue-commands.mjs +++ b/.github/scripts/issue-commands.mjs @@ -63,13 +63,7 @@ export function detectIssueCommand(commentBody) { export function findEligibleMergedPullRequest({ context, commentUser, repository }) { return context.pullRequests - .filter( - (pullRequest) => - pullRequest.state === "MERGED" && - pullRequest.baseRefName === context.defaultBranch && - pullRequest.repository?.nameWithOwner === repository && - sameLogin(pullRequest.author?.login, commentUser), - ) + .filter((pullRequest) => pullRequest.state === "MERGED" && pullRequest.baseRefName === context.defaultBranch && pullRequest.repository?.nameWithOwner === repository && sameLogin(pullRequest.author?.login, commentUser)) .sort((left, right) => (right.mergedAt || "").localeCompare(left.mergedAt || ""))[0]; } @@ -92,10 +86,7 @@ async function handleUnclaim({ issueNumber, commentUser, client }) { const assignee = context.assignees.find(({ login }) => sameLogin(login, commentUser)); if (!assignee) { - await client.commentIssue( - issueNumber, - `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`, - ); + await client.commentIssue(issueNumber, `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`); return { status: "not-assignee" }; } @@ -116,28 +107,15 @@ async function handleClose({ issueNumber, commentUser, repository, client }) { const pullRequest = findEligibleMergedPullRequest({ context, commentUser, repository }); if (!pullRequest) { - await client.commentIssue( - issueNumber, - `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`, - ); + await client.commentIssue(issueNumber, `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`); return { status: "no-merged-pull-request" }; } - await client.closeIssue( - issueNumber, - `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`, - ); + await client.closeIssue(issueNumber, `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`); return { status: "closed", pullRequest }; } -export async function handleIssueCommand({ - issueNumber, - commentBody, - commentUser, - commentUserType, - repository, - client, -}) { +export async function handleIssueCommand({ issueNumber, commentBody, commentUser, commentUserType, repository, client }) { const command = detectIssueCommand(commentBody); if (!command) return { status: "ignored" }; if (commentUserType === "Bot") return { status: "ignored-bot" }; @@ -158,17 +136,7 @@ export function createGitHubClient({ repository, runGh = gh }) { return { async getIssueContext(issueNumber) { - return JSON.parse( - await runGh([ - "issue", - "view", - String(issueNumber), - "--repo", - repository, - "--json", - "state,assignees", - ]), - ); + return JSON.parse(await runGh(["issue", "view", String(issueNumber), "--repo", repository, "--json", "state,assignees"])); }, async getIssueCloseContext(issueNumber) { @@ -176,18 +144,7 @@ export function createGitHubClient({ repository, runGh = gh }) { let context; do { - const args = [ - "api", - "graphql", - "-f", - `owner=${owner}`, - "-f", - `name=${name}`, - "-F", - `number=${issueNumber}`, - "-f", - `query=${ISSUE_CLOSE_CONTEXT_QUERY}`, - ]; + const args = ["api", "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issueNumber}`, "-f", `query=${ISSUE_CLOSE_CONTEXT_QUERY}`]; if (endCursor) args.push("-f", `endCursor=${endCursor}`); const response = JSON.parse(await runGh(args)); @@ -205,11 +162,7 @@ export function createGitHubClient({ repository, runGh = gh }) { if (!context.defaultBranch) throw new Error(`Default branch was not found for ${repository}`); } - context.pullRequests.push( - ...issue.timelineItems.nodes - .map((node) => node.source) - .filter((source) => source?.__typename === "PullRequest"), - ); + context.pullRequests.push(...issue.timelineItems.nodes.map((node) => node.source).filter((source) => source?.__typename === "PullRequest")); const { hasNextPage, endCursor: nextCursor } = issue.timelineItems.pageInfo; endCursor = hasNextPage ? nextCursor : undefined; @@ -231,17 +184,7 @@ export function createGitHubClient({ repository, runGh = gh }) { }, async closeIssue(issueNumber, comment) { - await runGh([ - "issue", - "close", - String(issueNumber), - "--repo", - repository, - "--reason", - "completed", - "--comment", - comment, - ]); + await runGh(["issue", "close", String(issueNumber), "--repo", repository, "--reason", "completed", "--comment", comment]); }, }; } diff --git a/.github/scripts/issue-commands.test.mjs b/.github/scripts/issue-commands.test.mjs index b03e3ff719..99aec4ee72 100644 --- a/.github/scripts/issue-commands.test.mjs +++ b/.github/scripts/issue-commands.test.mjs @@ -1,12 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - createGitHubClient, - detectIssueCommand, - findEligibleMergedPullRequest, - handleIssueCommand, -} from "./issue-commands.mjs"; +import { createGitHubClient, detectIssueCommand, findEligibleMergedPullRequest, handleIssueCommand } from "./issue-commands.mjs"; const repository = "t8y2/dbx"; @@ -117,13 +112,7 @@ test("findEligibleMergedPullRequest requires the assignee's merged PR on the def repository: { nameWithOwner: repository }, }; const context = closeContext({ - pullRequests: [ - { ...eligible, number: 101, state: "OPEN" }, - { ...eligible, number: 102, baseRefName: "release" }, - { ...eligible, number: 103, author: { login: "someone-else" } }, - { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, - eligible, - ], + pullRequests: [{ ...eligible, number: 101, state: "OPEN" }, { ...eligible, number: 102, baseRefName: "release" }, { ...eligible, number: 103, author: { login: "someone-else" } }, { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, eligible], }); assert.equal(findEligibleMergedPullRequest({ context, commentUser: "contributor", repository }), eligible); @@ -175,9 +164,7 @@ test("close succeeds after the assignee's linked PR is merged", async () => { test("commands ignore bots and closed unclaim requests", async () => { const botClient = recordingClient(); - const botResult = await handleIssueCommand( - commandOptions("/claim", botClient, { commentUserType: "Bot" }), - ); + const botResult = await handleIssueCommand(commandOptions("/claim", botClient, { commentUserType: "Bot" })); assert.equal(botResult.status, "ignored-bot"); assert.deepEqual(botClient.calls, []); @@ -213,10 +200,7 @@ test("GitHub client aggregates paginated pull request cross references", async ( assignees: { nodes: [{ login: "contributor" }] }, timelineItems: { pageInfo: { hasNextPage: false, endCursor: "done" }, - nodes: [ - { source: { __typename: "Issue", number: 11 } }, - { source: { __typename: "PullRequest", number: 12 } }, - ], + nodes: [{ source: { __typename: "Issue", number: 11 } }, { source: { __typename: "PullRequest", number: 12 } }], }, }, }, @@ -233,7 +217,10 @@ test("GitHub client aggregates paginated pull request cross references", async ( }); const context = await client.getIssueCloseContext("123"); - assert.deepEqual(context.pullRequests.map(({ number }) => number), [10, 12]); + assert.deepEqual( + context.pullRequests.map(({ number }) => number), + [10, 12], + ); assert.equal(args.length, 2); assert.equal(args[1].includes("endCursor=next-page"), true); }); diff --git a/.github/scripts/jdbc-plugin-version.test.mjs b/.github/scripts/jdbc-plugin-version.test.mjs index 7005cd3ba4..ac0017e99c 100644 --- a/.github/scripts/jdbc-plugin-version.test.mjs +++ b/.github/scripts/jdbc-plugin-version.test.mjs @@ -17,14 +17,8 @@ test("reads the JDBC plugin version from Gradle", () => { }); test("checks Gradle and manifest versions together", () => { - assert.deepEqual( - evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }), - [], - ); - assert.match( - evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0], - /build\.gradle is 0\.1\.27/, - ); + assert.deepEqual(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }), []); + assert.match(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0], /build\.gradle is 0\.1\.27/); }); test("bumps Gradle and manifest versions for JDBC source changes", () => { diff --git a/.github/scripts/label-issue-database.mjs b/.github/scripts/label-issue-database.mjs index f28aa38448..64e86d6a01 100644 --- a/.github/scripts/label-issue-database.mjs +++ b/.github/scripts/label-issue-database.mjs @@ -17,52 +17,14 @@ const USER_PRIORITY_LABELS = { P3: { color: "bfd4f2", description: "User-reported priority: low" }, }; -const LABEL_PALETTE = [ - "0e8a16", - "1d76db", - "5319e7", - "c2e0c6", - "bfd4f2", - "d4c5f9", - "fef2c0", - "fbca04", - "f9d0c4", - "e99695", - "f29513", - "c5def5", -]; +const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"]; -const globallyAmbiguousAliases = new Set([ - "access", - "ch", - "h2", - "iris", - "jdbc", - "mq", - "pg", - "spark", -]); +const globallyAmbiguousAliases = new Set(["access", "ch", "h2", "iris", "jdbc", "mq", "pg", "spark"]); -const genericDatabaseValues = [ - "general", - "generic", - "global", - "all", - "any", - "none", - "n/a", - "na", - "通用", - "全部", - "所有", - "无", - "不适用", -]; +const genericDatabaseValues = ["general", "generic", "global", "all", "any", "none", "n/a", "na", "通用", "全部", "所有", "无", "不适用"]; const drivers = databaseIssueDrivers; -const exactAsciiAliases = new Set( - drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)), -); +const exactAsciiAliases = new Set(drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5))); function loadIssue() { if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) { @@ -113,20 +75,12 @@ function extractInlineField(body, predicate) { } function extractDatabaseField(body) { - const matchesDatabaseHeading = (heading) => ( - heading.includes("数据库类型") || - heading.toLowerCase().includes("database type") || - /^database$/i.test(heading) - ); - return extractIssueFormSection(body, matchesDatabaseHeading) - || extractInlineField(body, matchesDatabaseHeading); + const matchesDatabaseHeading = (heading) => heading.includes("数据库类型") || heading.toLowerCase().includes("database type") || /^database$/i.test(heading); + return extractIssueFormSection(body, matchesDatabaseHeading) || extractInlineField(body, matchesDatabaseHeading); } function extractPriorityField(body) { - return extractIssueFormSection(body, (heading) => ( - heading.includes("优先级") || - heading.toLowerCase().includes("priority") - )); + return extractIssueFormSection(body, (heading) => heading.includes("优先级") || heading.toLowerCase().includes("priority")); } function extractTitleSummaryField(body) { @@ -146,21 +100,24 @@ function extractTitleSummaryField(body) { } function matchPriorityLabel(value) { - const match = String(value || "").normalize("NFKC").match(/\bP([0-3])\b/i); + const match = String(value || "") + .normalize("NFKC") + .match(/\bP([0-3])\b/i); return match ? `P${match[1]}` : null; } function genericIssueTitlePrefix(title) { - const normalized = String(title || "").normalize("NFKC").trim().replace(/\s+/g, " "); + const normalized = String(title || "") + .normalize("NFKC") + .trim() + .replace(/\s+/g, " "); const bracketMatch = normalized.match(/^([\[【][^\]】]*(?:bug|feature|feat|compatibility|question)[^\]】]*[\]】])$/i); if (bracketMatch) return bracketMatch[1]; const bareMatch = normalized.match(/^(bug|feature|feat|compatibility|question):?$/i); if (!bareMatch) return null; - const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : ( - bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase() - ); + const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase(); return `[${normalizedPrefix}]`; } @@ -179,14 +136,7 @@ function cleanTitleSummaryLine(line) { } function isSkippableTitleSummaryLine(line) { - return ( - /^DBX debug log$/i.test(line) || - /^(Exported|User agent|Platform|Timezone):/i.test(line) || - /^(Native|Tauri) log dir:/i.test(line) || - /^=+.*logs?.*=+$/i.test(line) || - /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || - /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line) - ); + return /^DBX debug log$/i.test(line) || /^(Exported|User agent|Platform|Timezone):/i.test(line) || /^(Native|Tauri) log dir:/i.test(line) || /^=+.*logs?.*=+$/i.test(line) || /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line); } function firstSentence(value) { @@ -199,9 +149,7 @@ function firstSentence(value) { for (const line of lines) { const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u); - const sentence = (sentenceMatch ? sentenceMatch[1] : line) - .replace(/[。!?!?.]+$/u, "") - .trim(); + const sentence = (sentenceMatch ? sentenceMatch[1] : line).replace(/[。!?!?.]+$/u, "").trim(); if (sentence.length >= 4) { return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence; } @@ -251,11 +199,7 @@ function levenshteinDistance(left, right) { const current = [leftIndex + 1]; for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) { const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1; - current[rightIndex + 1] = Math.min( - current[rightIndex] + 1, - previous[rightIndex + 1] + 1, - previous[rightIndex] + substitutionCost, - ); + current[rightIndex + 1] = Math.min(current[rightIndex] + 1, previous[rightIndex + 1] + 1, previous[rightIndex] + substitutionCost); } previous = current; } @@ -318,9 +262,7 @@ function aliasMatches(text, alias) { .filter(Boolean) .map(escapeRegExp) .join("[\\s._/-]*"); - const trailingBoundary = /[0-9]$/.test(normalizedAlias) - ? "(?:[^a-z0-9]|$)" - : "(?:[^a-z0-9]|$|(?=v?\\d))"; + const trailingBoundary = /[0-9]$/.test(normalizedAlias) ? "(?:[^a-z0-9]|$)" : "(?:[^a-z0-9]|$|(?=v?\\d))"; const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i"); return pattern.test(normalizedText); } @@ -332,10 +274,7 @@ function isGenericDatabaseValue(value) { .map((part) => part.trim()) .filter(Boolean); - return ( - parts.length > 0 && - parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic))) - ); + return parts.length > 0 && parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic))); } function matchDrivers(text, { allowAmbiguous }) { @@ -456,24 +395,18 @@ export function triageIssue(issue) { const hasDatabaseField = databaseField.length > 0; const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField); const sourceText = hasDatabaseField ? databaseField : issue.title || ""; - const matchedDrivers = isGenericDatabaseValue(sourceText) - ? [] - : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField })); + const matchedDrivers = isGenericDatabaseValue(sourceText) ? [] : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField })); const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false })); if (hasDatabaseField && titleMatchedDrivers.length > 0) { matchedDrivers.push(...titleMatchedDrivers); } if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) { - matchedDrivers.push( - ...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })), - ); + matchedDrivers.push(...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false }))); } const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers); const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`); - const targetLabelColors = Object.fromEntries( - uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]), - ); + const targetLabelColors = Object.fromEntries(uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)])); const priorityField = extractPriorityField(issue.body || ""); const targetPriorityValue = matchPriorityLabel(priorityField); const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null; @@ -484,22 +417,11 @@ export function triageIssue(issue) { const existingLabels = labelNames(issue.labels); const titleToUpdate = inferIssueTitle(issue.title || "", issue.body || ""); const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX)); - const staleDatabaseLabels = hasDatabaseField - ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) - : []; + const staleDatabaseLabels = hasDatabaseField ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) : []; const existingPriorityLabels = existingLabels.filter((name) => name.startsWith(USER_PRIORITY_PREFIX)); - const stalePriorityLabels = targetPriorityLabel - ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) - : []; - const labelsToAdd = [...targetLabels, targetPriorityLabel] - .filter(Boolean) - .filter((name) => !existingLabels.includes(name)); - const labelSpecs = [ - ...uniqueMatchedDrivers.map(databaseLabelSpec), - ...(targetPriorityValue - ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] - : []), - ]; + const stalePriorityLabels = targetPriorityLabel ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) : []; + const labelsToAdd = [...targetLabels, targetPriorityLabel].filter(Boolean).filter((name) => !existingLabels.includes(name)); + const labelSpecs = [...uniqueMatchedDrivers.map(databaseLabelSpec), ...(targetPriorityValue ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] : [])]; const summary = { issue: issue.number, databaseField: hasDatabaseField ? databaseField : null, diff --git a/.github/scripts/label-issue-database.test.mjs b/.github/scripts/label-issue-database.test.mjs index b7a9fa7088..993c574b2c 100644 --- a/.github/scripts/label-issue-database.test.mjs +++ b/.github/scripts/label-issue-database.test.mjs @@ -21,8 +21,8 @@ function issueWithDatabase(database, title = "[Bug]") { } function matchedLabels(database, title) { - return triageIssue(issueWithDatabase(database, title)).summary.labelsToAdd - .filter((label) => label.startsWith("db/")) + return triageIssue(issueWithDatabase(database, title)) + .summary.labelsToAdd.filter((label) => label.startsWith("db/")) .sort(); } @@ -35,10 +35,7 @@ test("database issue catalog covers every DatabaseType", () => { test("database label synchronization includes every catalog entry once", () => { const labelNames = allDatabaseLabelSpecs().map((label) => label.name); assert.equal(new Set(labelNames).size, databaseIssueDrivers.length); - assert.deepEqual( - labelNames.sort(), - databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort(), - ); + assert.deepEqual(labelNames.sort(), databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort()); }); test("Kingbase aliases retain historical and current company names", () => { diff --git a/.github/scripts/label-pull-request.mjs b/.github/scripts/label-pull-request.mjs index 223b3afbdd..63ce79fef9 100644 --- a/.github/scripts/label-pull-request.mjs +++ b/.github/scripts/label-pull-request.mjs @@ -33,20 +33,7 @@ export const STATIC_LABEL_SPECS = { const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//; const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]); -const LABEL_PALETTE = [ - "0e8a16", - "1d76db", - "5319e7", - "c2e0c6", - "bfd4f2", - "d4c5f9", - "fef2c0", - "fbca04", - "f9d0c4", - "e99695", - "f29513", - "c5def5", -]; +const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"]; const DRIVER_DATABASE_ALIASES = { "cassandra-go": "cassandra", @@ -86,12 +73,7 @@ const DATABASE_PATH_TOKENS = [ [/sqlserver/i, "sqlserver"], ]; -const PACKAGE_DEPENDENCY_SECTIONS = [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", -]; +const PACKAGE_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; function stablePaletteColor(value) { let hash = 0; @@ -106,14 +88,14 @@ function normalizeChangedFiles(changedFiles) { } function conventionalType(title) { - const normalized = String(title || "").trim().replace(/^[^\p{L}\p{N}]+/u, ""); + const normalized = String(title || "") + .trim() + .replace(/^[^\p{L}\p{N}]+/u, ""); return /^([a-z]+)(?:\([^)]*\))?!?[::]/i.exec(normalized)?.[1]?.toLowerCase() || null; } export function isDocumentationFile(file) { - return file.startsWith("docs/") - || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) - || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file); + return file.startsWith("docs/") || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file); } export function inferTypeLabel(title, changedFiles) { @@ -130,10 +112,7 @@ export function inferAreaLabels(changedFiles) { const labels = new Set(); const has = (predicate) => changedFiles.some(predicate); - if (has((file) => file === "package.json" - || file.startsWith("apps/desktop/") - || file.startsWith("packages/app-tests/") - || file.startsWith("src-tauri/"))) { + if (has((file) => file === "package.json" || file.startsWith("apps/desktop/") || file.startsWith("packages/app-tests/") || file.startsWith("src-tauri/"))) { labels.add("area/desktop"); } if (has((file) => CORE_CRATE_PATH.test(file) || file.startsWith("packages/node-core/"))) { @@ -143,27 +122,16 @@ export function inferAreaLabels(changedFiles) { labels.add("area/web"); } if (has((file) => file.startsWith("agents/"))) labels.add("area/agents"); - if (has((file) => file.startsWith("crates/dbx-mcp/") - || file.startsWith("packages/mcp-") - || file.startsWith("packages/mcp-server/") - || file.startsWith("examples/mcp/"))) { + if (has((file) => file.startsWith("crates/dbx-mcp/") || file.startsWith("packages/mcp-") || file.startsWith("packages/mcp-server/") || file.startsWith("examples/mcp/"))) { labels.add("area/mcp"); } - if (has((file) => file.startsWith("crates/dbx-cli/") - || file.startsWith("packages/cli-") - || file.startsWith("packages/cli/") - || file.startsWith("skills/dbx/") - || file.startsWith("examples/cli/"))) { + if (has((file) => file.startsWith("crates/dbx-cli/") || file.startsWith("packages/cli-") || file.startsWith("packages/cli/") || file.startsWith("skills/dbx/") || file.startsWith("examples/cli/"))) { labels.add("area/cli"); } if (has((file) => file.startsWith("plugins/"))) labels.add("area/plugins"); if (has(isDocumentationFile)) labels.add("area/docs"); if (has((file) => file.startsWith(".github/"))) labels.add("area/ci"); - if (has((file) => file.startsWith("deploy/") - || file === "flake.nix" - || file === "flake.lock" - || file === "Makefile" - || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) { + if (has((file) => file.startsWith("deploy/") || file === "flake.nix" || file === "flake.lock" || file === "Makefile" || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) { labels.add("area/deploy"); } @@ -178,29 +146,20 @@ export function isUiChangeFile(file) { if (isTestFile(file)) return false; if (file.startsWith("apps/desktop/")) { - return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) - || file.startsWith("apps/desktop/src/components/") - || file.startsWith("apps/desktop/src/i18n/") - || file.startsWith("apps/desktop/src/styles/") - || file.startsWith("apps/desktop/public/") - || isVisualAsset(file); + return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) || file.startsWith("apps/desktop/src/components/") || file.startsWith("apps/desktop/src/i18n/") || file.startsWith("apps/desktop/src/styles/") || file.startsWith("apps/desktop/public/") || isVisualAsset(file); } if (file.startsWith("src-tauri/icons/")) return true; if (file.startsWith("docs/")) { - return /\.(?:css|jsx|tsx)$/i.test(file) - || file.startsWith("docs/components/") - || (file.startsWith("docs/public/") && isVisualAsset(file)); + return /\.(?:css|jsx|tsx)$/i.test(file) || file.startsWith("docs/components/") || (file.startsWith("docs/public/") && isVisualAsset(file)); } return false; } export function isTestFile(file) { - return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) - || /\.(?:spec|test)\.[^/]+$/i.test(file) - || /(^|\/)packages\/app-tests\//.test(file); + return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) || /\.(?:spec|test)\.[^/]+$/i.test(file) || /(^|\/)packages\/app-tests\//.test(file); } function normalizeDatabaseType(value, aliases) { @@ -248,8 +207,7 @@ export function parsePackageDependencyNames(source) { function unquoteTomlKey(value) { const trimmed = String(value || "").trim(); - if ((trimmed.startsWith('"') && trimmed.endsWith('"')) - || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return trimmed.slice(1, -1); } return trimmed; @@ -340,17 +298,10 @@ function dependencySide(file) { if (file === "package.json" || file === "docs/package.json" || file === "packages/mongo-shell/package.json") { return "frontend"; } - if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) - || file === "packages/cli/package.json" - || file === "packages/mcp-server/package.json") { + if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) || file === "packages/cli/package.json" || file === "packages/mcp-server/package.json") { return "backend"; } - if (file === "Cargo.toml" - || file.endsWith("/Cargo.toml") - || file.endsWith("/build.gradle") - || file.endsWith("/build.gradle.kts") - || file.endsWith("/go.mod") - || file.endsWith("/pom.xml")) { + if (file === "Cargo.toml" || file.endsWith("/Cargo.toml") || file.endsWith("/build.gradle") || file.endsWith("/build.gradle.kts") || file.endsWith("/go.mod") || file.endsWith("/pom.xml")) { return "backend"; } return null; @@ -382,13 +333,7 @@ export function inferAddedDependencies(changedFiles, readBaseFile, readHeadFile) return added; } -export function evaluatePullRequestLabels({ - title, - changedFiles, - knownDatabaseTypes, - readBaseFile = () => null, - readHeadFile = () => null, -}) { +export function evaluatePullRequestLabels({ title, changedFiles, knownDatabaseTypes, readBaseFile = () => null, readHeadFile = () => null }) { const files = normalizeChangedFiles(changedFiles); const areaLabels = inferAreaLabels(files); const labels = new Set(areaLabels.length > 3 ? ["area/multiple"] : areaLabels); @@ -510,12 +455,7 @@ async function removeLabel(pullRequestNumber, name) { } function isManagedLabel(name) { - return TYPE_LABELS.has(name) - || name.startsWith("area/") - || name.startsWith(DATABASE_LABEL_PREFIX) - || name.startsWith("dependencies/") - || name === "tests-only" - || name === "ui-change"; + return TYPE_LABELS.has(name) || name.startsWith("area/") || name.startsWith(DATABASE_LABEL_PREFIX) || name.startsWith("dependencies/") || name === "tests-only" || name === "ui-change"; } async function main() { @@ -547,14 +487,20 @@ async function main() { }; } - console.log(JSON.stringify({ - pullRequest: pullRequest.number, - title: pullRequest.title, - ...result, - existingLabels, - labelsToAdd, - labelsToRemove, - }, null, 2)); + console.log( + JSON.stringify( + { + pullRequest: pullRequest.number, + title: pullRequest.title, + ...result, + existingLabels, + labelsToAdd, + labelsToRemove, + }, + null, + 2, + ), + ); if (dryRun) { console.log("Dry run enabled; no GitHub API calls were made"); diff --git a/.github/scripts/label-pull-request.test.mjs b/.github/scripts/label-pull-request.test.mjs index 1779fadea5..222e3cc60c 100644 --- a/.github/scripts/label-pull-request.test.mjs +++ b/.github/scripts/label-pull-request.test.mjs @@ -1,42 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - evaluatePullRequestLabels, - inferAddedDependencies, - inferDatabaseTypes, - parseCargoDependencyNames, - parseGoDependencyNames, - parseGradleDependencyNames, - parseMavenDependencyNames, - parsePackageDependencyNames, -} from "./label-pull-request.mjs"; +import { evaluatePullRequestLabels, inferAddedDependencies, inferDatabaseTypes, parseCargoDependencyNames, parseGoDependencyNames, parseGradleDependencyNames, parseMavenDependencyNames, parsePackageDependencyNames } from "./label-pull-request.mjs"; -const knownDatabaseTypes = new Set([ - "access", - "cassandra", - "doris", - "jdbc", - "mongodb", - "mq", - "mysql", - "neo4j", - "oceanbase-oracle", - "oracle", - "postgres", - "redis", - "sqlite", - "sqlserver", - "vastbase", -]); +const knownDatabaseTypes = new Set(["access", "cassandra", "doris", "jdbc", "mongodb", "mq", "mysql", "neo4j", "oceanbase-oracle", "oracle", "postgres", "redis", "sqlite", "sqlserver", "vastbase"]); test("labels a desktop MySQL UI fix", () => { const result = evaluatePullRequestLabels({ title: "fix(mysql): improve connection dialog", - changedFiles: [ - "apps/desktop/src/components/connection/MySqlConnectionDialog.vue", - "apps/desktop/src/i18n/en.ts", - ], + changedFiles: ["apps/desktop/src/components/connection/MySqlConnectionDialog.vue", "apps/desktop/src/i18n/en.ts"], knownDatabaseTypes, }); @@ -65,15 +37,7 @@ test("labels the bundled DBX skill as CLI work", () => { test("maps agent and dialect paths to existing database types", () => { assert.deepEqual( - inferDatabaseTypes([ - "agents/drivers/oracle-go/go.mod", - "agents/drivers/cassandra-go/go.mod", - "agents/drivers/neo4j-go/go.mod", - "agents/drivers/vastbase-go/go.mod", - "agents/drivers/kafka/build.gradle", - "plugins/dialects/postgresql.yaml", - "plugins/dialects/oceanbase.yaml", - ], knownDatabaseTypes), + inferDatabaseTypes(["agents/drivers/oracle-go/go.mod", "agents/drivers/cassandra-go/go.mod", "agents/drivers/neo4j-go/go.mod", "agents/drivers/vastbase-go/go.mod", "agents/drivers/kafka/build.gradle", "plugins/dialects/postgresql.yaml", "plugins/dialects/oceanbase.yaml"], knownDatabaseTypes), ["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"], ); }); @@ -81,10 +45,7 @@ test("maps agent and dialect paths to existing database types", () => { test("labels tests-only changes", () => { const result = evaluatePullRequestLabels({ title: "test(core): cover transaction recovery", - changedFiles: [ - "crates/dbx-core/tests/transaction_recovery.rs", - "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts", - ], + changedFiles: ["crates/dbx-core/tests/transaction_recovery.rs", "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts"], knownDatabaseTypes, }); @@ -94,10 +55,7 @@ test("labels tests-only changes", () => { test("does not treat component tests or MCP server code as UI or deploy changes", () => { const result = evaluatePullRequestLabels({ title: "🐛 fix(mcp): cover server routing", - changedFiles: [ - "apps/desktop/src/components/mcp/McpPanel.spec.ts", - "packages/mcp-server/src/index.ts", - ], + changedFiles: ["apps/desktop/src/components/mcp/McpPanel.spec.ts", "packages/mcp-server/src/index.ts"], knownDatabaseTypes, }); @@ -117,15 +75,7 @@ test("recognizes conventional titles with a full-width colon", () => { test("recognizes extracted core crates and their database sources", () => { const result = evaluatePullRequestLabels({ title: "fix(sql): preserve PostgreSQL query contracts", - changedFiles: [ - "crates/dbx-driver-postgres/src/postgres.rs", - "crates/dbx-sql-core/src/sql.rs", - "crates/dbx-types/src/types.rs", - "crates/dbx-platform/src/process.rs", - "crates/dbx-ai-provider/src/ai.rs", - "crates/dbx-formats/src/csv_export.rs", - "crates/dbx-plugin-runtime/src/plugins.rs", - ], + changedFiles: ["crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-sql-core/src/sql.rs", "crates/dbx-types/src/types.rs", "crates/dbx-platform/src/process.rs", "crates/dbx-ai-provider/src/ai.rs", "crates/dbx-formats/src/csv_export.rs", "crates/dbx-plugin-runtime/src/plugins.rs"], knownDatabaseTypes, }); @@ -168,11 +118,11 @@ test("detects frontend and backend additions in the same pull request", () => { const manifests = { base: { "package.json": JSON.stringify({ dependencies: { vue: "3.5.0" } }), - "crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\n", + "crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\n', }, head: { "package.json": JSON.stringify({ dependencies: { vue: "3.5.0", zod: "4" } }), - "crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n", + "crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\nserde = "1"\n', }, }; const result = evaluatePullRequestLabels({ @@ -183,13 +133,7 @@ test("detects frontend and backend additions in the same pull request", () => { readHeadFile: (file) => manifests.head[file], }); - assert.deepEqual(result.labels, [ - "area/core", - "area/desktop", - "dependencies/backend", - "dependencies/frontend", - "enhancement", - ]); + assert.deepEqual(result.labels, ["area/core", "area/desktop", "dependencies/backend", "dependencies/frontend", "enhancement"]); }); test("ignores lockfile-only dependency changes", () => { @@ -204,7 +148,8 @@ test("ignores lockfile-only dependency changes", () => { test("parses dependency names from supported backend manifests", () => { assert.deepEqual( - [...parseCargoDependencyNames(` + [ + ...parseCargoDependencyNames(` [workspace.dependencies] serde = "1" @@ -213,39 +158,50 @@ windows-sys = "0.61" [dependencies.mysql_async] git = "https://example.com/mysql_async" -`)].sort(), +`), + ].sort(), ["mysql_async", "serde", "windows-sys"], ); assert.deepEqual( - [...parseGradleDependencyNames(` + [ + ...parseGradleDependencyNames(` implementation 'com.zaxxer:HikariCP:7.0.2' testImplementation project(':test-support') runtimeOnly libs.slf4j.nop -`)].sort(), +`), + ].sort(), ["catalog:libs.slf4j.nop", "com.zaxxer:HikariCP", "project::test-support"], ); assert.deepEqual( - [...parseGoDependencyNames(` + [ + ...parseGoDependencyNames(` require github.com/example/direct v1.0.0 require ( github.com/example/block v2.0.0 ) -`)].sort(), +`), + ].sort(), ["github.com/example/block", "github.com/example/direct"], ); assert.deepEqual( - [...parseMavenDependencyNames(` + [ + ...parseMavenDependencyNames(` org.exampleclient1 -`)], +`), + ], ["org.example:client"], ); assert.deepEqual( - [...parsePackageDependencyNames(JSON.stringify({ - dependencies: { vue: "3" }, - devDependencies: { vitest: "4" }, - }))].sort(), + [ + ...parsePackageDependencyNames( + JSON.stringify({ + dependencies: { vue: "3" }, + devDependencies: { vitest: "4" }, + }), + ), + ].sort(), ["vitest", "vue"], ); }); diff --git a/.github/scripts/reuse-agent-release-assets.mjs b/.github/scripts/reuse-agent-release-assets.mjs index d5ab3d32f5..722403bfc2 100644 --- a/.github/scripts/reuse-agent-release-assets.mjs +++ b/.github/scripts/reuse-agent-release-assets.mjs @@ -1,16 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, -} from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { tmpdir } from "node:os"; @@ -23,14 +14,7 @@ const NATIVE_MODULES = new Set(["duckdb", "oracle", "oracle-oci", "xugu", "kingb const NATIVE_MODULE_PLATFORMS = { "oracle-oci": ["windows-x64"], }; -const PLATFORMS = [ - "macos-aarch64", - "macos-x64", - "linux-aarch64", - "linux-x64", - "windows-aarch64", - "windows-x64", -]; +const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"]; function expectedNativePlatforms(moduleName) { return [...(NATIVE_MODULE_PLATFORMS[moduleName] ?? PLATFORMS)].sort(); @@ -93,9 +77,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules, const missingPlatforms = expectedPlatforms.filter((platform) => !nativePlatforms.includes(platform)); const extraPlatforms = nativePlatforms.filter((platform) => !expectedPlatforms.includes(platform)); if (missingPlatforms.length > 0 || extraPlatforms.length > 0) { - throw new Error( - `Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`, - ); + throw new Error(`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`); } } @@ -119,9 +101,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules, const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform)); const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform)); if (missingPlatforms.length > 0 || extraPlatforms.length > 0) { - throw new Error( - `Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`, - ); + throw new Error(`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`); } for (const platform of platforms) { jreAssets.push({ @@ -210,13 +190,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`; if (filenames.has(javaName)) { - extractRawDriver(join(packagesDir, javaName), { - name: javaName, - moduleName, - kind: "jar", - platform: "", - releaseVersion, - }, outputDir); + extractRawDriver( + join(packagesDir, javaName), + { + name: javaName, + moduleName, + kind: "jar", + platform: "", + releaseVersion, + }, + outputDir, + ); extracted += 1; } @@ -225,13 +209,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`; if (!filenames.has(nativeName)) continue; nativePlatforms.push(platform); - extractRawDriver(join(packagesDir, nativeName), { - name: nativeName, - moduleName, - kind: "native", - platform, - releaseVersion, - }, outputDir); + extractRawDriver( + join(packagesDir, nativeName), + { + name: nativeName, + moduleName, + kind: "native", + platform, + releaseVersion, + }, + outputDir, + ); extracted += 1; } @@ -253,23 +241,7 @@ function gh(args, options = {}) { function downloadReleaseAssets(assets, downloadDir) { if (assets.length === 0) return; - const args = [ - "--fail", - "--location", - "--silent", - "--show-error", - "--retry", - "5", - "--retry-all-errors", - "--retry-delay", - "2", - "--connect-timeout", - "30", - "--parallel", - "--parallel-immediate", - "--parallel-max", - "6", - ]; + const args = ["--fail", "--location", "--silent", "--show-error", "--retry", "5", "--retry-all-errors", "--retry-delay", "2", "--connect-timeout", "30", "--parallel", "--parallel-immediate", "--parallel-max", "6"]; for (const asset of assets) { if (!asset.browser_download_url) { throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`); @@ -359,7 +331,10 @@ function main() { })); if (plannedAssets.length > 0) { - downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir); + downloadReleaseAssets( + plannedAssets.map((asset) => asset.releaseAsset), + downloadDir, + ); } for (const asset of plannedAssets) { diff --git a/.github/scripts/reuse-agent-release-assets.test.mjs b/.github/scripts/reuse-agent-release-assets.test.mjs index 9a2cf099b3..9d548b24e5 100644 --- a/.github/scripts/reuse-agent-release-assets.test.mjs +++ b/.github/scripts/reuse-agent-release-assets.test.mjs @@ -3,23 +3,12 @@ import test from "node:test"; import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs"; -const platforms = [ - "macos-aarch64", - "macos-x64", - "linux-aarch64", - "linux-x64", - "windows-aarch64", - "windows-x64", -]; +const platforms = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"]; test("collects complete reusable Java, native, and JRE assets", () => { const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a"); - const kingbase = Object.fromEntries( - platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]), - ); - const jre = Object.fromEntries( - platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]), - ); + const kingbase = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))])); + const jre = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))])); const registry = { drivers: { access: { version: "0.1.34", jar: access }, @@ -39,95 +28,93 @@ test("collects complete reusable Java, native, and JRE assets", () => { assert.equal(plan.driverAssets.length, 7); assert.equal(plan.jreAssets.length, 6); - assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]); + assert.deepEqual( + plan.driverAssets.map((asset) => asset.moduleName), + ["access", ...Array(6).fill("kingbase")], + ); }); test("rejects an incomplete reusable native platform set", () => { - const native = Object.fromEntries( - platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { vastbase: "0.1.38" }, - modules: ["vastbase"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { vastbase: "0.1.38" }, + modules: ["vastbase"], + reuseJre: false, + }), /missing=macos-aarch64/, ); }); test("requires all TDengine native platforms when reusing a release", () => { - const native = Object.fromEntries( - platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { tdengine: "0.1.40" }, - modules: ["tdengine"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { tdengine: "0.1.40" }, + modules: ["tdengine"], + reuseJre: false, + }), /missing=windows-x64/, ); }); test("requires all Neo4j native platforms when reusing a release", () => { - const native = Object.fromEntries( - platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { neo4j: "0.1.40" }, - modules: ["neo4j"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { neo4j: "0.1.40" }, + modules: ["neo4j"], + reuseJre: false, + }), /missing=windows-x64/, ); }); test("requires all IoTDB native platforms when reusing a release", () => { - const native = Object.fromEntries( - platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { iotdb: { version: "0.1.30", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { iotdb: "0.1.30" }, - modules: ["iotdb"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { iotdb: "0.1.30" }, + modules: ["iotdb"], + reuseJre: false, + }), /missing=windows-x64/, ); }); test("requires all RocketMQ native platforms when reusing a release", () => { - const native = Object.fromEntries( - platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { rocketmq: "0.1.40" }, - modules: ["rocketmq"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { rocketmq: "0.1.40" }, + modules: ["rocketmq"], + reuseJre: false, + }), /missing=windows-x64/, ); }); @@ -146,7 +133,10 @@ test("reuses the Windows-only Oracle OCI native package", () => { // Windows-only is the complete platform set for this module, so requiring all // six platforms would reject a correct release. - assert.deepEqual(plan.driverAssets.map((asset) => asset.platform), ["windows-x64"]); + assert.deepEqual( + plan.driverAssets.map((asset) => asset.platform), + ["windows-x64"], + ); }); test("rejects an Oracle OCI release that lost its Windows native package", () => { @@ -154,21 +144,20 @@ test("rejects an Oracle OCI release that lost its Windows native package", () => const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor(Object.values(native)), - versions: { "oracle-oci": "0.1.0" }, - modules: ["oracle-oci"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor(Object.values(native)), + versions: { "oracle-oci": "0.1.0" }, + modules: ["oracle-oci"], + reuseJre: false, + }), /missing=windows-x64, extra=linux-x64/, ); }); test("ignores zero-size legacy JAR placeholders for native-only modules", () => { - const native = Object.fromEntries( - platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]), - ); + const native = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))])); const registry = { drivers: { duckdb: { @@ -193,7 +182,10 @@ test("ignores zero-size legacy JAR placeholders for native-only modules", () => }); assert.equal(plan.driverAssets.length, 6); - assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false); + assert.equal( + plan.driverAssets.some((asset) => asset.kind === "jar"), + false, + ); }); test("rejects a registry version that differs from the effective baseline", () => { @@ -201,13 +193,14 @@ test("rejects a registry version that differs from the effective baseline", () = const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} }; assert.throws( - () => collectReusableAssetPlan({ - registry, - release: releaseFor([access]), - versions: { access: "0.1.34" }, - modules: ["access"], - reuseJre: false, - }), + () => + collectReusableAssetPlan({ + registry, + release: releaseFor([access]), + versions: { access: "0.1.34" }, + modules: ["access"], + reuseJre: false, + }), /registry=0\.1\.33, expected=0\.1\.34/, ); }); diff --git a/.github/scripts/sign-mcp-macos.mjs b/.github/scripts/sign-mcp-macos.mjs index 5e595e349c..00641f3fec 100644 --- a/.github/scripts/sign-mcp-macos.mjs +++ b/.github/scripts/sign-mcp-macos.mjs @@ -22,12 +22,18 @@ async function command(executable, args, signal) { return new Promise((resolveCommand, reject) => { const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], signal }); let output = ""; - child.stdout.on("data", (data) => { output += data; }); - child.stderr.on("data", (data) => { output += data; }); + child.stdout.on("data", (data) => { + output += data; + }); + child.stderr.on("data", (data) => { + output += data; + }); const timer = setTimeout(() => child.kill("SIGKILL"), 120000); const fail = () => reject(new Error(`${executable.split("/").at(-1)} ${args[0]} failed.`)); let failed = false; - child.on("error", () => { failed = true; }); + child.on("error", () => { + failed = true; + }); child.on("close", (code) => { clearTimeout(timer); if (code === 0 && !failed) resolveCommand(output); @@ -43,10 +49,7 @@ export async function verifyMcp(binary, options = {}) { if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file."); await command(ctx.codesign, ["--verify", "--strict", "--all-architectures", "-R", `=${ctx.requirement}`, binary], ctx.signal); const info = await command(ctx.codesign, ["--display", "--verbose=4", "--requirements", "-", binary], ctx.signal); - if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) - || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) - || !/^Timestamp=(?!none$).+/m.test(info) - || /Signature=adhoc/.test(info)) { + if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) || !/^Timestamp=(?!none$).+/m.test(info) || /Signature=adhoc/.test(info)) { throw new Error("MCP signature must have the expected identifier, team and secure timestamp."); } const designated = info.match(/^designated => (.+)$/m)?.[1]; @@ -66,7 +69,10 @@ export async function signMcp(binary, options = {}) { throw new Error("APPLE_CERTIFICATE must be a base64 PKCS12 bundle."); } const originalList = await command(ctx.security, ["list-keychains", "-d", "user"], ctx.signal); - const originalKeychains = originalList.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line.trim())); + const originalKeychains = originalList + .split(/\r?\n/) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line.trim())); if (!originalKeychains.every((keychain) => typeof keychain === "string")) throw new Error("Invalid keychain search list."); const directory = mkdtempSync(join(ctx.env.RUNNER_TEMP || ctx.env.TMPDIR || tmpdir(), "dbx-mcp-signing-")); chmodSync(directory, 0o700); @@ -84,22 +90,33 @@ export async function signMcp(binary, options = {}) { await command(ctx.security, ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain], ctx.signal); await command(ctx.security, ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains], ctx.signal); const identities = await command(ctx.security, ["find-identity", "-v", "-p", "codesigning", keychain], ctx.signal); - const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)] - .filter(([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) - && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase())); + const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)].filter( + ([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()), + ); if (candidates.length !== 1) throw new Error("Expected exactly one valid configured Developer ID Application identity in the temporary keychain."); - await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, - "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal); + await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal); await verifyMcp(binary, options); } catch (failure) { error = failure; } finally { const failures = []; - try { await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } catch (failure) { failures.push(failure); } + try { + await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); + } catch (failure) { + failures.push(failure); + } if (existsSync(keychain)) { - try { await command(ctx.security, ["delete-keychain", keychain]); } catch (failure) { failures.push(failure); } + try { + await command(ctx.security, ["delete-keychain", keychain]); + } catch (failure) { + failures.push(failure); + } + } + try { + rmSync(directory, { recursive: true, force: true }); + } catch (failure) { + failures.push(failure); } - try { rmSync(directory, { recursive: true, force: true }); } catch (failure) { failures.push(failure); } if (failures.length) error = new Error("MCP signing keychain cleanup failed; refusing publication.", { cause: error }); } if (error) throw error; diff --git a/.github/scripts/suggest-similar-issues.mjs b/.github/scripts/suggest-similar-issues.mjs index 1415ddc8b9..8b79371df0 100644 --- a/.github/scripts/suggest-similar-issues.mjs +++ b/.github/scripts/suggest-similar-issues.mjs @@ -10,81 +10,12 @@ const MAX_QUERY_LENGTH = 480; const MAX_CANDIDATES = 3; const SEARCH_RESULT_LIMIT = 20; -const ignoredSectionPatterns = [ - /^来源$/i, - /^source$/i, - /数据库类型/i, - /database type/i, - /支持信息/i, - /support info/i, - /优先级/i, - /priority/i, - /补充信息/i, - /additional context/i, - /环境信息/i, - /environment/i, -]; +const ignoredSectionPatterns = [/^来源$/i, /^source$/i, /数据库类型/i, /database type/i, /支持信息/i, /support info/i, /优先级/i, /priority/i, /补充信息/i, /additional context/i, /环境信息/i, /environment/i]; -const genericLatinTokens = new Set([ - "ai", - "alter", - "bug", - "datagrip", - "dbeaver", - "dbx", - "delete", - "desktop", - "feature", - "from", - "insert", - "issue", - "navicat", - "question", - "request", - "select", - "sql", - "support", - "table", - "update", - "version", - "web", - "where", - "windows", -]); +const genericLatinTokens = new Set(["ai", "alter", "bug", "datagrip", "dbeaver", "dbx", "delete", "desktop", "feature", "from", "insert", "issue", "navicat", "question", "request", "select", "sql", "support", "table", "update", "version", "web", "where", "windows"]); -const genericCjkTokens = new Set([ - "一个", - "以及", - "使用", - "功能", - "可以", - "当前", - "支持", - "数据库", - "新增", - "增加", - "希望", - "异常", - "操作", - "所在", - "数据", - "显示", - "没有", - "现在", - "设置", - "问题", - "进行", - "错误", - "需要", -]); -const identifierQueryNoiseTokens = new Set([ - "失效", - "弹出", - "快捷", - "提醒", - "时候", - "选择", -]); +const genericCjkTokens = new Set(["一个", "以及", "使用", "功能", "可以", "当前", "支持", "数据库", "新增", "增加", "希望", "异常", "操作", "所在", "数据", "显示", "没有", "现在", "设置", "问题", "进行", "错误", "需要"]); +const identifierQueryNoiseTokens = new Set(["失效", "弹出", "快捷", "提醒", "时候", "选择"]); const cjkSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" }); const databaseDrivers = databaseIssueDrivers; @@ -107,7 +38,9 @@ function labelNames(labels) { } function stripIssuePrefix(title) { - return String(title || "").replace(/^\s*\[[^\]]+\]\s*/u, "").trim(); + return String(title || "") + .replace(/^\s*\[[^\]]+\]\s*/u, "") + .trim(); } function stripMarkdown(value) { @@ -143,9 +76,7 @@ function databaseField(body) { } function relevantBody(body) { - const sections = parseIssueSections(body).filter( - ({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)), - ); + const sections = parseIssueSections(body).filter(({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading))); return stripMarkdown(sections.map(({ content }) => content).join("\n")).slice(0, 2400); } @@ -197,9 +128,7 @@ function semanticTokens(value) { export function searchTitleTerms(issue) { const identifiers = technicalIdentifiers(issue); - const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter( - (token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token), - ); + const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter((token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token)); return tokens.slice(0, 8).join(" ").slice(0, MAX_QUERY_LENGTH); } @@ -207,11 +136,7 @@ function latinTokens(value) { return new Set( normalizeText(value) .match(/[a-z0-9][a-z0-9+._-]{1,}/gu) - ?.filter((token) => ( - !genericLatinTokens.has(token) - && !/^v?\d+(?:[._-]\d+)*$/u.test(token) - && !/^(.)\1{2,}$/u.test(token) - )) || [], + ?.filter((token) => !genericLatinTokens.has(token) && !/^v?\d+(?:[._-]\d+)*$/u.test(token) && !/^(.)\1{2,}$/u.test(token)) || [], ); } @@ -334,26 +259,15 @@ export function buildCorpusContext(issue, candidates) { const frequencies = new Map(); for (const token of queryTitleTokens) { - const documentFrequency = candidateTitleTokens.reduce( - (count, tokens) => count + (tokens.has(token) ? 1 : 0), - 0, - ); + const documentFrequency = candidateTitleTokens.reduce((count, tokens) => count + (tokens.has(token) ? 1 : 0), 0); frequencies.set(token, documentFrequency); const queryFrequencyBoost = 1 + Math.log(Math.max(1, tokenOccurrenceCount(issueTitle, token))); weights.set(token, inverseDocumentFrequency(candidates.length, documentFrequency) * queryFrequencyBoost); } - const observedWeights = [...queryTitleTokens] - .filter((token) => (frequencies.get(token) || 0) > 0) - .map((token) => weights.get(token) || 0); + const observedWeights = [...queryTitleTokens].filter((token) => (frequencies.get(token) || 0) > 0).map((token) => weights.get(token) || 0); const maximumWeight = Math.max(0, ...observedWeights); - const anchorTokens = new Set( - [...queryTitleTokens].filter((token) => ( - maximumWeight > 0 - && (frequencies.get(token) || 0) > 0 - && (weights.get(token) || 0) >= maximumWeight * 0.9 - )), - ); + const anchorTokens = new Set([...queryTitleTokens].filter((token) => maximumWeight > 0 && (frequencies.get(token) || 0) > 0 && (weights.get(token) || 0) >= maximumWeight * 0.9)); return { queryTitleTokens, candidateTitleTokens, weights, anchorTokens }; } @@ -369,29 +283,18 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) { const candidateTitle = stripIssuePrefix(candidate.title); const issueBody = relevantBody(issue.body).slice(0, 1200); const candidateBody = relevantBody(candidate.body).slice(0, 1200); - const titleSimilarity = diceCoefficient( - characterNgrams(contentTitleText(issue)), - characterNgrams(contentTitleText(candidate)), - ); + const titleSimilarity = diceCoefficient(characterNgrams(contentTitleText(issue)), characterNgrams(contentTitleText(candidate))); const bodySimilarity = diceCoefficient(characterNgrams(issueBody), characterNgrams(candidateBody)); const context = corpusContext || buildCorpusContext(issue, [candidate]); - const candidateTitleTokens = corpusContext - ? context.candidateTitleTokens[rank] - : context.candidateTitleTokens[0]; + const candidateTitleTokens = corpusContext ? context.candidateTitleTokens[rank] : context.candidateTitleTokens[0]; const titleCoverage = weightedCoverage(context.queryTitleTokens, candidateTitleTokens, context.weights); const anchorHit = setsOverlap(candidateTitleTokens, context.anchorTokens); const issueIdentifiers = technicalIdentifiers(issue); const candidateIdentifiers = technicalIdentifiers(candidate); - const identifierCoverage = issueIdentifiers.size === 0 - ? 0 - : intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size; + const identifierCoverage = issueIdentifiers.size === 0 ? 0 : intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size; const rankPrior = 1 / Math.log2(rank + 2); - let score = titleCoverage * 0.45 - + titleSimilarity * 0.25 - + bodySimilarity * 0.1 - + identifierCoverage * 0.15 - + rankPrior * 0.05; + let score = titleCoverage * 0.45 + titleSimilarity * 0.25 + bodySimilarity * 0.1 + identifierCoverage * 0.15 + rankPrior * 0.05; const issueTypes = typeLabels(issue.labels); const candidateTypes = typeLabels(candidate.labels); if (issueTypes.size > 0 && candidateTypes.size > 0) { @@ -401,10 +304,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) { // Like Discourse, retrieval is deliberately broad and public suggestions // require a separate threshold. Rare title terms act as BM25F-style anchors. - const accepted = score >= 0.34 - || (anchorHit && titleCoverage >= 0.38 && score >= 0.2) - || (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28) - || (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28); + const accepted = score >= 0.34 || (anchorHit && titleCoverage >= 0.38 && score >= 0.2) || (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28) || (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28); return { accepted, @@ -421,9 +321,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) { } export function rankCandidates(issue, items) { - const candidates = items.filter( - (candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number), - ); + const candidates = items.filter((candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number)); const context = buildCorpusContext(issue, candidates); return candidates .map((candidate, rank) => ({ candidate, ...scoreCandidate(issue, candidate, rank, context) })) @@ -510,11 +408,13 @@ export async function run({ issue = loadIssue(), client } = {}) { return []; } - const github = client || new GitHubClient({ - token: process.env.GITHUB_TOKEN, - repository: process.env.GITHUB_REPOSITORY, - apiBase: process.env.GITHUB_API_URL, - }); + const github = + client || + new GitHubClient({ + token: process.env.GITHUB_TOKEN, + repository: process.env.GITHUB_REPOSITORY, + apiBase: process.env.GITHUB_API_URL, + }); if (await github.hasExistingComment(issue.number)) { console.log("Similar issue comment already exists"); return []; diff --git a/.github/scripts/suggest-similar-issues.test.mjs b/.github/scripts/suggest-similar-issues.test.mjs index 8d7e6e0697..8481d77232 100644 --- a/.github/scripts/suggest-similar-issues.test.mjs +++ b/.github/scripts/suggest-similar-issues.test.mjs @@ -2,15 +2,7 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import test from "node:test"; -import { - GitHubClient, - formatComment, - rankCandidates, - run, - scoreCandidate, - searchTerms, - searchTitleTerms, -} from "./suggest-similar-issues.mjs"; +import { GitHubClient, formatComment, rankCandidates, run, scoreCandidate, searchTerms, searchTitleTerms } from "./suggest-similar-issues.mjs"; const baseIssue = { number: 4043, @@ -213,7 +205,10 @@ test("uses corpus rarity to reject generic real-world matches", () => { ]; const ranked = rankCandidates(issue, candidates); - assert.deepEqual(ranked.map(({ candidate }) => candidate.number), [2201]); + assert.deepEqual( + ranked.map(({ candidate }) => candidate.number), + [2201], + ); assert.equal(ranked[0].signals.anchorHit, true); }); @@ -247,9 +242,7 @@ test("ranks candidates, excludes the current issue and limits output", () => { }); test("formats a cautious localized comment with concise issue references", () => { - const comment = formatComment(baseIssue, [ - { candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } }, - ]); + const comment = formatComment(baseIssue, [{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } }]); assert.match(comment, /^/u); assert.match(comment, /\n- #82\n/u); @@ -272,16 +265,20 @@ test("run searches and posts one idempotent comment", async (t) => { return; } if (request.method === "GET" && request.url.startsWith("/search/issues?")) { - response.end(JSON.stringify({ - search_type: "hybrid", - items: [{ - number: 82, - title: "Ctrl+Enter 执行当前 SQL 语句", - body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。", - state: "open", - labels: [{ name: "bug" }, { name: "db/mysql" }], - }], - })); + response.end( + JSON.stringify({ + search_type: "hybrid", + items: [ + { + number: 82, + title: "Ctrl+Enter 执行当前 SQL 语句", + body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。", + state: "open", + labels: [{ name: "bug" }, { name: "db/mysql" }], + }, + ], + }), + ); return; } if (request.method === "POST" && request.url.endsWith("/comments")) { diff --git a/.github/scripts/sync-changelog.mjs b/.github/scripts/sync-changelog.mjs index b3ec839f59..afc535dc45 100644 --- a/.github/scripts/sync-changelog.mjs +++ b/.github/scripts/sync-changelog.mjs @@ -59,13 +59,15 @@ export function stripDownloadSection(body) { // 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。 // 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。 function stripIssueRefs(text) { - return text - // 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456) - .replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "") - // closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx) - .replace(/\(closes #\d+,\s*/g, "(") - // contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx) - .replace(/,\s*closes #\d+/g, ""); + return ( + text + // 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456) + .replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "") + // closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx) + .replace(/\(closes #\d+,\s*/g, "(") + // contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx) + .replace(/,\s*closes #\d+/g, "") + ); } // 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2, @@ -187,9 +189,7 @@ function buildLatestEnNotes(enReleasesJson) { } export function buildLatestReleaseNotes(releases) { - const latest = releases - .filter(isAppRelease) - .sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0]; + const latest = releases.filter(isAppRelease).sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0]; if (!latest) return null; return { version: latest.tag_name, notes: latest.body || "" }; } diff --git a/.github/scripts/sync-cnb-release.mjs b/.github/scripts/sync-cnb-release.mjs index 6b94f2933f..e877d7de2d 100644 --- a/.github/scripts/sync-cnb-release.mjs +++ b/.github/scripts/sync-cnb-release.mjs @@ -40,9 +40,7 @@ async function main() { }); console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`); - await mapWithConcurrency(assets, args.concurrency, (assetPath) => - uploadWithRetry(client, release.id, assetPath, args.overwriteExisting), - ); + await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath, args.overwriteExisting)); } function parseArgs(argv) { @@ -70,9 +68,7 @@ function parseArgs(argv) { } if (!args.token) throw new Error("CNB_TOKEN is required."); if (!args.githubReleasePath || (!args.metadataOnly && !args.assetsDir)) { - throw new Error( - "Usage: sync-cnb-release.mjs --github-release (--assets-dir | --metadata-only) [--prune-assets] [--make-latest]", - ); + throw new Error("Usage: sync-cnb-release.mjs --github-release (--assets-dir | --metadata-only) [--prune-assets] [--make-latest]"); } if (!Number.isInteger(args.concurrency) || args.concurrency < 1) { throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer."); @@ -162,19 +158,13 @@ export class CnbClient { } async deleteAsset(releaseId, assetId) { - await this.request( - "DELETE", - `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`, - ); + await this.request("DELETE", `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`); } async listReleases(pageSize = 100) { const releases = []; for (let page = 1; ; page++) { - const batch = await this.request( - "GET", - `/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`, - ); + const batch = await this.request("GET", `/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`); if (!Array.isArray(batch)) throw new Error("CNB release list response must be an array."); releases.push(...batch); if (batch.length < pageSize) return releases; diff --git a/.github/scripts/sync-cnb-release.test.mjs b/.github/scripts/sync-cnb-release.test.mjs index 80d94a7757..e26071c733 100644 --- a/.github/scripts/sync-cnb-release.test.mjs +++ b/.github/scripts/sync-cnb-release.test.mjs @@ -103,11 +103,7 @@ test("ensureRelease explicitly marks a promoted release as latest", async (t) => token: "test-token", }); - await client.ensureRelease( - "v1.2.3", - { name: "DBX v1.2.3", body: "Stable", isPrerelease: false }, - { makeLatest: true }, - ); + await client.ensureRelease("v1.2.3", { name: "DBX v1.2.3", body: "Stable", isPrerelease: false }, { makeLatest: true }); assert.deepEqual(requests, [ { method: "GET", url: "/dbxio.com/dbx/-/releases/tags/v1.2.3", body: null }, @@ -157,26 +153,19 @@ test("metadata-only sync can mark the release as latest without reading an asset const tempDir = await mkdtemp(join(tmpdir(), "sync-cnb-release-")); t.after(() => rm(tempDir, { recursive: true, force: true })); const releasePath = join(tempDir, "release.json"); - await writeFile( - releasePath, - JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }), - ); + await writeFile(releasePath, JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false })); const address = server.address(); assert.ok(address && typeof address !== "string"); const result = await new Promise((resolve) => { - const child = spawn( - process.execPath, - [scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"], - { - env: { - ...process.env, - CNB_API_BASE: `http://127.0.0.1:${address.port}`, - CNB_REPOSITORY: "dbxio.com/dbx", - CNB_TOKEN: "test-token", - }, + const child = spawn(process.execPath, [scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"], { + env: { + ...process.env, + CNB_API_BASE: `http://127.0.0.1:${address.port}`, + CNB_REPOSITORY: "dbxio.com/dbx", + CNB_TOKEN: "test-token", }, - ); + }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => (stdout += chunk)); diff --git a/.github/ui-preview/publish.mjs b/.github/ui-preview/publish.mjs index a278cd4517..f7f49467bb 100755 --- a/.github/ui-preview/publish.mjs +++ b/.github/ui-preview/publish.mjs @@ -55,7 +55,8 @@ async function files() { // the description with the block in place of the old one, or at its foot function withBlock(body, block) { body = body || ""; - const a = body.indexOf(BEGIN), b = body.indexOf(END); + const a = body.indexOf(BEGIN), + b = body.indexOf(END); if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length); return body.replace(/\s*$/, "") + "\n\n" + block; } @@ -102,7 +103,9 @@ async function pushMedia(sha, dir) { git(["fetch", "-q", "--depth=1", remote, BRANCH], work); base = git(["rev-parse", "FETCH_HEAD"], work); git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work); - } catch { /* the first one */ } + } catch { + /* the first one */ + } // this PR's latest run only, and no PR that's been closed for (const d of await fs.readdir(work).catch(() => [])) { const m = /^pr-(\d+)$/.exec(d); @@ -153,8 +156,12 @@ ${shots.map((x) => ` s.shots.length) && !m.video)) { - const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图" - : m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制" - : `这次没录成(${result})`; + const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图" : m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制" : `这次没录成(${result})`; const errs = m?.errors?.length ? `\n\n
细节\n\n${m.errors.map((e) => "- " + e).join("\n")}\n
` : ""; await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`); return; @@ -195,5 +202,7 @@ async function publish() { } const cmd = process.argv[2]; -(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish"))) - .catch((e) => { console.error(e); process.exit(1); }); +(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish"))).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.github/ui-preview/record.mjs b/.github/ui-preview/record.mjs index c834aba907..0a2f4c5497 100755 --- a/.github/ui-preview/record.mjs +++ b/.github/ui-preview/record.mjs @@ -34,7 +34,10 @@ const MODEL = env("PLAN_MODEL", "deepseek-flash"); const VIEW = { width: 1440, height: 900 }; // never pressed: they delete the sandbox's own data or its setup const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i; -const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8); +const secrets = env("SECRETS", "") + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length >= 8); const log = (...a) => console.log("[ui-preview]", ...a); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -46,7 +49,13 @@ const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [ function outline() { const lines = []; const MAX = 420; - const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70); + const own = (e) => + [...e.childNodes] + .filter((n) => n.nodeType === 3) + .map((n) => n.textContent.trim()) + .join(" ") + .replace(/\s+/g, " ") + .slice(0, 70); const shown = (e) => { const r = e.getBoundingClientRect(); if (!r.width || !r.height) return false; @@ -58,9 +67,19 @@ function outline() { if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return; const tag = e.tagName.toLowerCase(); if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return; - const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join(""); - const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join(""); - const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" "); + const cls = [...e.classList] + .slice(0, 4) + .map((c) => "." + c) + .join(""); + const data = [...e.attributes] + .filter((a) => a.name.startsWith("data-") && a.value.length < 40) + .slice(0, 3) + .map((a) => `[${a.name}="${a.value}"]`) + .join(""); + const aria = ["role", "aria-label", "title", "placeholder", "type"] + .map((k) => (e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "")) + .filter(Boolean) + .join(" "); const text = own(e); const r = e.getBoundingClientRect(); const away = r.top >= fold ? " (below the fold)" : ""; @@ -75,8 +94,7 @@ function outline() { } async function screenText(page) { - return page.evaluate(() => document.documentElement.innerText + "\n" + - [...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => ""); + return page.evaluate(() => document.documentElement.innerText + "\n" + [...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => ""); } async function checkLeak(page, where) { if (!secrets.length) return; @@ -109,8 +127,12 @@ async function askPlanner(messages) { if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`); const j = await res.json(); const text = j.choices?.[0]?.message?.content ?? ""; - try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); } - catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); } + try { + return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); + } catch (e) { + last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); + log(last.message, "— asking again"); + } } throw last; } @@ -141,16 +163,22 @@ async function readTree(root) { async function codeContext(diff, src) { if (!src) return ""; - const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))] - .filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f)); + const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))].filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f)); // 1. the changed files let out = ""; for (const f of changed.slice(0, 12)) { let body; - try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; } + try { + body = await fs.readFile(path.join(src, f), "utf8"); + } catch { + continue; + } const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`; - if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; } + if (out.length + chunk.length > 20e3) { + out += "… (cut)\n"; + break; + } out += chunk; } @@ -170,16 +198,14 @@ async function codeContext(diff, src) { let entries = ""; for (const name of components.slice(0, 6)) { const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); - const re = new RegExp( - "import\\s+" + name + "\\b" + - "|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" + - "|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" + - "|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]"); + const re = new RegExp("import\\s+" + name + "\\b" + "|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" + "|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" + "|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]"); const flags = new Map(); // caller file -> Set of line numbers for (const p of files) { const t = await read(p); if (!t || rel(p).endsWith(`/${name}.vue`)) continue; - t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); }); + t.split("\n").forEach((l, i) => { + if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); + }); } for (const [p, lines] of flags) { // a dynamic import renames the component (const X = defineAsyncComponent): @@ -194,14 +220,19 @@ async function codeContext(diff, src) { } if (aliases.size > 1) { const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]"); - linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); }); + linesArr.forEach((l, i) => { + if (tagRe.test(l)) lines.add(i); + }); } // the usage with its v-if/v-show/:open condition, and identifiers in it const span = []; for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n")); const cond = span.join("\n"); const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`; - if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; } + if (entries.length + chunk.length > 9e3) { + entries += "… (cut)\n"; + break; + } entries += chunk; // who flips those conditions: identifiers in v-if / :open / v-model near the usage const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4); @@ -270,9 +301,10 @@ async function glide(page, x, y) { const n = Math.max(8, Math.round(ms / 16)); const from = { ...mouse }; for (let i = 1; i <= n; i++) { - const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; + const t = i / n, + e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; // a slight arc, not a ruler line - const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08); + const bend = Math.sin(Math.PI * t) * Math.min(40, d * 0.08); const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend; const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend; await page.mouse.move(nx, ny); @@ -319,11 +351,14 @@ async function locate(page, step) { const el = await hit.evaluateHandle((n, text) => { for (;;) { const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0); - if (kids.length === 1) n = kids[0]; else return n; + if (kids.length === 1) n = kids[0]; + else return n; } }, text); return el.asElement() || hit; - } catch { return hit; } + } catch { + return hit; + } }; let loc = text ? all.filter({ hasText: text }) : all; let hit = await visible(loc); @@ -356,7 +391,6 @@ async function locate(page, step) { throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`); } - // Out of sight is scrolled to as a reader would, with the wheel over what // scrolls. Each view that hides it, the innermost first, is wheeled in small // steps until the element is in the middle of it, or at its top when it's @@ -378,19 +412,22 @@ async function bringIntoView(page, loc) { const root = s === document.scrollingElement; const b = root ? clip : s.getBoundingClientRect(); // what of it shows: inside the window and every view around it - let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight); - let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth); + let top = Math.max(b.top, 0), + bottom = Math.min(b.bottom, innerHeight); + let left = Math.max(b.left, 0), + right = Math.min(b.right, innerWidth); for (let p = s.parentElement; p && !root; p = p.parentElement) { if (!scrollers.includes(p) || p === document.scrollingElement) continue; const pb = p.getBoundingClientRect(); - top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom); - left = Math.max(left, pb.left); right = Math.min(right, pb.right); + top = Math.max(top, pb.top); + bottom = Math.min(bottom, pb.bottom); + left = Math.max(left, pb.left); + right = Math.min(right, pb.right); } const room = bottom - top - 2 * PAD; const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD); const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2); - out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)), - x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 }); + out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)), x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 }); } return out; }, PAD); @@ -416,7 +453,8 @@ async function pointAt(page, loc) { await bringIntoView(page, loc); const b = await loc.boundingBox(); if (!b) throw new Error("the element has no box"); - const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2; + const x = b.x + Math.min(b.width / 2, 60), + y = b.y + b.height / 2; await glide(page, x, y); return b; } @@ -425,10 +463,16 @@ async function pointAt(page, loc) { // the seeded connections painted in, is ready async function settle(page) { await page.waitForLoadState("load").catch(() => {}); - await page.waitForFunction(() => { - const txt = document.body.innerText || ""; - return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6; - }, null, { timeout: 20000 }).catch(() => {}); + await page + .waitForFunction( + () => { + const txt = document.body.innerText || ""; + return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6; + }, + null, + { timeout: 20000 }, + ) + .catch(() => {}); await sleep(1500); } @@ -440,13 +484,18 @@ async function runStep(page, step, scene) { switch (step.do) { case "click": { const loc = await locate(page, step); - const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || ""); + const label = (await loc.innerText().catch(() => "")) + " " + ((await loc.getAttribute("title").catch(() => "")) || ""); if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`); await pointAt(page, loc); await sleep(180); if (step.dblclick) { - await page.mouse.down(); await sleep(60); await page.mouse.up(); - await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up(); + await page.mouse.down(); + await sleep(60); + await page.mouse.up(); + await sleep(120); + await page.mouse.down(); + await sleep(60); + await page.mouse.up(); } else { await page.mouse.down(); await sleep(90); @@ -465,7 +514,9 @@ async function runStep(page, step, scene) { case "type": { const loc = await locate(page, step); await pointAt(page, loc); - await page.mouse.down(); await sleep(80); await page.mouse.up(); + await page.mouse.down(); + await sleep(80); + await page.mouse.up(); await loc.fill(""); await page.keyboard.type(String(step.value ?? ""), { delay: 70 }); await sleep(600); @@ -517,7 +568,8 @@ async function runStep(page, step, scene) { if (b) { // the target with room around it, never so small that a button // is shown without where it is - const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48)); + const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), + h = Math.min(VIEW.height, Math.max(340, b.height + 48)); const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2)); const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2)); clip = { x, y, width: w, height: h }; @@ -553,7 +605,9 @@ async function main() { await lp.goto(BASE); await settle(lp); outlines.app = await lp.evaluate(outline); - } catch (e) { outlines.app = `(failed to load: ${e.message})`; } + } catch (e) { + outlines.app = `(failed to load: ${e.message})`; + } await look.close(); const MAXDIFF = 150e3; @@ -561,13 +615,18 @@ async function main() { log(`code context: ${code.length} characters`); const ask = [ { role: "system", content: GUIDE }, - { role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` + - `=== outline: app ===\n${outlines.app}` + - (code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") }, + { + role: "user", + content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` + `=== outline: app ===\n${outlines.app}` + (code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : ""), + }, ]; let plan; - try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); } - catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); } + try { + plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); + } catch (e) { + plan = null; + manifest.errors.push(`plan: ${e.message}`); + } if (!plan || plan.ui_change === false || !plan.scenes?.length) { manifest.summary = plan?.summary || ""; manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan"; @@ -582,7 +641,8 @@ async function main() { if (plan.scenes.length) { const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } }); await rec.addInitScript({ path: path.join(here, "cursor.js") }); - let repairs = 0, interactive = false; + let repairs = 0, + interactive = false; // one take per scene: every scene starts from the app as it opens, and // its video keeps only what happens once the app has settled — the // load never reaches the final cut, so a scene change reads as a cut, @@ -613,8 +673,9 @@ async function main() { for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true; - try { await runStep(page, step, scene); } - catch (e) { + try { + await runStep(page, step, scene); + } catch (e) { if (manifest.leak) break; manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`); log("step failed:", e.message); @@ -640,13 +701,27 @@ async function main() { repairs++; // the page as it is now, and what went wrong: the rest of the scene again try { - const fix = await askPlanner([...ask, { role: "assistant", content: JSON.stringify(plan) }, - { role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]); - if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); } - } catch (e2) { manifest.errors.push(`repair: ${e2.message}`); } + const fix = await askPlanner([ + ...ask, + { role: "assistant", content: JSON.stringify(plan) }, + { + role: "user", + content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.`, + }, + ]); + if (Array.isArray(fix.steps)) { + steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; + log("repaired:", JSON.stringify(fix.steps)); + } + } catch (e2) { + manifest.errors.push(`repair: ${e2.message}`); + } } } - if (manifest.leak) { await page.close().catch(() => {}); break; } + if (manifest.leak) { + await page.close().catch(() => {}); + break; + } await caption(page, ""); await sleep(600); takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) }); @@ -659,9 +734,7 @@ async function main() { // the takes joined, each already trimmed of its load const args = ["-y", "-loglevel", "error"]; const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`)); - args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]", - "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", - path.join(OUT, "preview.mp4")); + args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4")); execFileSync("ffmpeg", args); manifest.video = "preview.mp4"; manifest.poster = await poster(browser); @@ -671,7 +744,8 @@ async function main() { await browser.close(); if (manifest.leak) { for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true }); - manifest.scenes = []; manifest.video = manifest.poster = null; + manifest.scenes = []; + manifest.video = manifest.poster = null; } await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)); log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak })); diff --git a/.github/ui-preview/seed.mjs b/.github/ui-preview/seed.mjs index e212cd5b6c..a750de36c1 100755 --- a/.github/ui-preview/seed.mjs +++ b/.github/ui-preview/seed.mjs @@ -31,14 +31,22 @@ const DBS = [ name: "商店 Shop (SQLite)", note: "订单、商品与客户示例库", tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)", - warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"], + warm: [ + "SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", + "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", + "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC", + ], }, { file: "dbx-analytics.sqlite", name: "分析 Analytics (SQLite)", note: "事件流与每日指标", tables: "events(5000 行,看分页)· daily_stats(90 天)", - warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"], + warm: [ + "SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", + "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", + "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30", + ], }, ]; @@ -112,7 +120,11 @@ async function api(base, p, init = {}) { }); const text = await res.text(); if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`); - try { return JSON.parse(text); } catch { return text; } + try { + return JSON.parse(text); + } catch { + return text; + } } async function seedApi() { @@ -166,8 +178,14 @@ async function seedApi() { await api(base, "/api/saved-sql", { method: "POST", body: JSON.stringify({ - ...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily", - database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)), + ...s, + connectionId: conns[i % conns.length].id, + folderId: folder?.id || "seed-folder-daily", + database: "main", + orderIndex: i, + openCount: 3 + i, + createdAt: iso(36e5 * (70 - i)), + updatedAt: iso(36e5 * (24 - i * 6)), }), }).catch((e) => console.log("[ui-preview] saved-sql:", e.message)); } @@ -188,9 +206,17 @@ async function seedApi() { method: "POST", body: JSON.stringify({ entry: { - id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name, - database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok, - error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor", + id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, + connection_id: c.id, + connection_name: c.name, + database: "main", + sql, + executed_at: iso(36e5 * (2 + Math.random() * 60)), + execution_time_ms: ms, + success: ok, + error: ok ? null : "demo entry: would have modified rows (not really run)", + activity_kind: "query", + source: "editor", }, }), }).catch((e) => console.log("[ui-preview] history:", e.message)); @@ -205,12 +231,13 @@ if (cmd === "dbs") { python(dir); // a small importable file, for import/mapping dialogs (record.mjs's // "upload" step resolves names inside this directory) - fs.writeFileSync(path.join(dir, "sample.csv"), - "name,category,price,stock\n" + - Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n"); + fs.writeFileSync(path.join(dir, "sample.csv"), "name,category,price,stock\n" + Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n"); console.log("[ui-preview] databases written to", dir); } else if (cmd === "api") { - seedApi().catch((e) => { console.error(e); process.exit(1); }); + seedApi().catch((e) => { + console.error(e); + process.exit(1); + }); } else { console.error("usage: seed.mjs dbs | seed.mjs api [--as ]"); process.exit(1); diff --git a/agents/scripts/build_offline_jdbc_payload.mjs b/agents/scripts/build_offline_jdbc_payload.mjs index e728af6828..d6281ce54c 100644 --- a/agents/scripts/build_offline_jdbc_payload.mjs +++ b/agents/scripts/build_offline_jdbc_payload.mjs @@ -40,14 +40,7 @@ function loadAssetManifest(path) { } function resolveBundle(resolver, coordinate, localRepository, repositories) { - const javaToolOptions = [ - process.env.JAVA_TOOL_OPTIONS, - "-Djava.net.preferIPv4Stack=true", - "-Daether.connector.connectTimeout=30000", - "-Daether.connector.requestTimeout=300000", - ] - .filter(Boolean) - .join(" "); + const javaToolOptions = [process.env.JAVA_TOOL_OPTIONS, "-Djava.net.preferIPv4Stack=true", "-Daether.connector.connectTimeout=30000", "-Daether.connector.requestTimeout=300000"].filter(Boolean).join(" "); const args = ["resolve", "--coordinate", coordinate, "--local-repo", localRepository]; for (const repository of repositories) args.push("--repo", repository); const result = spawnSync(resolver, args, { @@ -178,14 +171,8 @@ async function main() { if (workDir) rmSync(workDir, { recursive: true, force: true }); } - writeFileSync( - join(payloadDir, "third-party-notices.json"), - `${JSON.stringify({ format_version: 1, bundles: notices }, null, 2)}\n`, - ); - writeFileSync( - join(payloadDir, "offline-manifest.json"), - `${JSON.stringify({ format_version: 1, plugin_entry: "jdbc/plugin.zip", third_party_entry: "jdbc/third-party-notices.json", bundles: manifestBundles }, null, 2)}\n`, - ); + writeFileSync(join(payloadDir, "third-party-notices.json"), `${JSON.stringify({ format_version: 1, bundles: notices }, null, 2)}\n`); + writeFileSync(join(payloadDir, "offline-manifest.json"), `${JSON.stringify({ format_version: 1, plugin_entry: "jdbc/plugin.zip", third_party_entry: "jdbc/third-party-notices.json", bundles: manifestBundles }, null, 2)}\n`); console.log(`Prepared offline JDBC payload in ${payloadDir}`); } diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 1120e73c88..9936298267 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -3195,7 +3195,10 @@ function imageAttachmentDetail(attachment: AiImageAttachment): string { } function activeImageAttachmentSupportError(attachments: readonly AiImageAttachment[]) { - return imageAttachmentSupportError(activeFullConfig.value?.provider, attachments.map((attachment) => attachment.mediaType)); + return imageAttachmentSupportError( + activeFullConfig.value?.provider, + attachments.map((attachment) => attachment.mediaType), + ); } function imageAttachmentSupportErrorMessage(error: "provider" | "format"): string { diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ac7aa79be4..802ef44ca1 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -475,13 +475,12 @@ function onLocaleOpenChange(open: boolean) { if (!open) restoreLocaleOptionPreview(); } -const appThemePaletteOptions = computed( - (): Array<{ value: AppThemePalette; label: string; previewColor: string }> => - APP_THEME_PALETTES.map((palette) => ({ - value: palette.value, - label: t(palette.labelKey), - previewColor: palette.previewColor, - })), +const appThemePaletteOptions = computed((): Array<{ value: AppThemePalette; label: string; previewColor: string }> => + APP_THEME_PALETTES.map((palette) => ({ + value: palette.value, + label: t(palette.labelKey), + previewColor: palette.previewColor, + })), ); const selectedThemePaletteOption = computed(() => appThemePaletteOptions.value.find((option) => option.value === themePalette.value) ?? appThemePaletteOptions.value[0]); const selectedLocaleOption = computed(() => LOCALE_OPTIONS.find((locale) => locale.value === currentLocale()) ?? LOCALE_OPTIONS[0]); @@ -10029,8 +10028,7 @@ onUnmounted(() => { SELECT t.* FROM ${1:table} t WHERE t.del_flag = 0 -LIMIT 100; +LIMIT 100;
diff --git a/apps/desktop/src/components/editor/queryEditorCodeMirrorRuntime.ts b/apps/desktop/src/components/editor/queryEditorCodeMirrorRuntime.ts index 52374d2a98..dfe164e28a 100644 --- a/apps/desktop/src/components/editor/queryEditorCodeMirrorRuntime.ts +++ b/apps/desktop/src/components/editor/queryEditorCodeMirrorRuntime.ts @@ -92,67 +92,69 @@ interface CodeMirrorBindings { } type ReadyCodeMirrorBindings = CodeMirrorBindings & { - [Key in - | "editorViewModule" - | "hoverCloseEffect" - | "codeMirrorLineNumbers" - | "codeMirrorPrec" - | "codeMirrorEditorSelection" - | "codeMirrorSnippetCompletion" - | "fontThemeComp" - | "codeMirrorTheme" - | "wordWrapComp" - | "showWhitespaceComp" - | "lineNumbersComp" - | "vimModeComp" - | "closeBracketsComp" - | "sqlLanguageComp" - | "sqlSemanticHighlightComp" - | "sqlSignatureComp" - | "codeMirrorCloseBrackets" - | "codeMirrorCloseBracketsKeymap" - | "readOnlyComp" - | "runGutterComp" - | "runKeymapComp" - | "historyResetComp" - | "defaultKeymapComp" - | "completionComp" - | "diagnosticComp" - | "previewRangeComp" - | "indentComp" - | "setSqlDiagnosticsEffect" - | "statementBoundariesRefreshEffect" - | "codeMirrorCompletionStatus" - | "codeMirrorAcceptCompletion" - | "codeMirrorCurrentCompletions" - | "codeMirrorSelectedCompletion" - | "codeMirrorSelectedCompletionIndex" - | "codeMirrorSetSelectedCompletion" - | "codeMirrorMoveCompletionSelection" - | "codeMirrorSelectFirstCompletion" - | "codeMirrorCloseCompletion" - | "codeMirrorStartCompletion" - | "codeMirrorInsertCompletionText" - | "codeMirrorNextSnippetField" - | "codeMirrorIndentMore" - | "codeMirrorIndentLess" - | "codeMirrorCopyLineDown" - | "codeMirrorCopyLineUp" - | "codeMirrorDeleteLine" - | "codeMirrorMoveLineUp" - | "codeMirrorMoveLineDown" - | "codeMirrorUndo" - | "codeMirrorRedo" - | "codeMirrorHistory" - | "codeMirrorSelectAll" - | "codeMirrorInsertNewlineKeepIndent" - | "codeMirrorToggleLineComment" - | "codeMirrorToggleBlockComment" - | "codeMirrorDefaultKeymap" - | "codeMirrorToggleFold" - | "codeMirrorFoldAll" - | "codeMirrorUnfoldAll" - | "codeMirrorIndentUnit"]: NonNullable; + [ + Key in + | "editorViewModule" + | "hoverCloseEffect" + | "codeMirrorLineNumbers" + | "codeMirrorPrec" + | "codeMirrorEditorSelection" + | "codeMirrorSnippetCompletion" + | "fontThemeComp" + | "codeMirrorTheme" + | "wordWrapComp" + | "showWhitespaceComp" + | "lineNumbersComp" + | "vimModeComp" + | "closeBracketsComp" + | "sqlLanguageComp" + | "sqlSemanticHighlightComp" + | "sqlSignatureComp" + | "codeMirrorCloseBrackets" + | "codeMirrorCloseBracketsKeymap" + | "readOnlyComp" + | "runGutterComp" + | "runKeymapComp" + | "historyResetComp" + | "defaultKeymapComp" + | "completionComp" + | "diagnosticComp" + | "previewRangeComp" + | "indentComp" + | "setSqlDiagnosticsEffect" + | "statementBoundariesRefreshEffect" + | "codeMirrorCompletionStatus" + | "codeMirrorAcceptCompletion" + | "codeMirrorCurrentCompletions" + | "codeMirrorSelectedCompletion" + | "codeMirrorSelectedCompletionIndex" + | "codeMirrorSetSelectedCompletion" + | "codeMirrorMoveCompletionSelection" + | "codeMirrorSelectFirstCompletion" + | "codeMirrorCloseCompletion" + | "codeMirrorStartCompletion" + | "codeMirrorInsertCompletionText" + | "codeMirrorNextSnippetField" + | "codeMirrorIndentMore" + | "codeMirrorIndentLess" + | "codeMirrorCopyLineDown" + | "codeMirrorCopyLineUp" + | "codeMirrorDeleteLine" + | "codeMirrorMoveLineUp" + | "codeMirrorMoveLineDown" + | "codeMirrorUndo" + | "codeMirrorRedo" + | "codeMirrorHistory" + | "codeMirrorSelectAll" + | "codeMirrorInsertNewlineKeepIndent" + | "codeMirrorToggleLineComment" + | "codeMirrorToggleBlockComment" + | "codeMirrorDefaultKeymap" + | "codeMirrorToggleFold" + | "codeMirrorFoldAll" + | "codeMirrorUnfoldAll" + | "codeMirrorIndentUnit" + ]: NonNullable; }; export function createQueryEditorCodeMirrorRuntime() { diff --git a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue index be6679e6d9..c6be4209f5 100644 --- a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue +++ b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue @@ -216,8 +216,7 @@ defineExpose({ openSearch }); class="dbx-data-grid-value-font overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50" :class="[{ 'cursor-text': detail.isEditable }, panelIsBottom && detail.imagePreviewUrl ? 'min-h-24 max-h-32 shrink-0' : '', valueFillsHeight && !detail.imagePreviewUrl ? 'min-h-0 flex-1' : '']" @dblclick="emit('startEdit')" - >{{ presentedValuePreview }} + >{{ presentedValuePreview }}
{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}
diff --git a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsoleDirectionFilter.spec.ts b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsoleDirectionFilter.spec.ts index 2e70a8e739..1fbaa7d110 100644 --- a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsoleDirectionFilter.spec.ts +++ b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsoleDirectionFilter.spec.ts @@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({ })); vi.mock("@/lib/backend/api", () => ({ - mqttGetBrokerInfo: vi.fn( - async (): Promise => ({ - brokerUrl: "mqtt://localhost:1883", - clientId: "dbx-test", - connected: true, - protocolVersion: "5.0", - subscriptionCount: 1, - }), - ), + mqttGetBrokerInfo: vi.fn(async (): Promise => ({ + brokerUrl: "mqtt://localhost:1883", + clientId: "dbx-test", + connected: true, + protocolVersion: "5.0", + subscriptionCount: 1, + })), mqttListTopics: vi.fn(async () => [["device/status", "atmostonce"]]), mqttListSavedTopicConfigs: vi.fn(async () => [{ topic: "device/status", qos: "atmostonce", enabled: true, noLocal: false }]), mqttGetMessages: mqttGetMessagesMock, diff --git a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePause.spec.ts b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePause.spec.ts index b356bfcc61..8431836969 100644 --- a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePause.spec.ts +++ b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePause.spec.ts @@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({ })); vi.mock("@/lib/backend/api", () => ({ - mqttGetBrokerInfo: vi.fn( - async (): Promise => ({ - brokerUrl: "mqtt://localhost:1883", - clientId: "dbx-test", - connected: true, - protocolVersion: "5.0", - subscriptionCount: 1, - }), - ), + mqttGetBrokerInfo: vi.fn(async (): Promise => ({ + brokerUrl: "mqtt://localhost:1883", + clientId: "dbx-test", + connected: true, + protocolVersion: "5.0", + subscriptionCount: 1, + })), mqttListTopics: vi.fn(async () => [["device/status", "atmostonce"]]), mqttListSavedTopicConfigs: vi.fn(async () => [{ topic: "device/status", qos: "atmostonce", enabled: true, noLocal: false }]), mqttGetMessages: mqttGetMessagesMock, diff --git a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadCollapse.spec.ts b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadCollapse.spec.ts index e7eb425672..9d7c86726e 100644 --- a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadCollapse.spec.ts +++ b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadCollapse.spec.ts @@ -12,15 +12,13 @@ const { mqttClearMessagesMock, mqttGetMessagesMock } = vi.hoisted(() => ({ })); vi.mock("@/lib/backend/api", () => ({ - mqttGetBrokerInfo: vi.fn( - async (): Promise => ({ - brokerUrl: "mqtt://localhost:1883", - clientId: "dbx-test", - connected: true, - protocolVersion: "5.0", - subscriptionCount: 1, - }), - ), + mqttGetBrokerInfo: vi.fn(async (): Promise => ({ + brokerUrl: "mqtt://localhost:1883", + clientId: "dbx-test", + connected: true, + protocolVersion: "5.0", + subscriptionCount: 1, + })), mqttListTopics: vi.fn(async () => [["device/status", "atmostonce"]]), mqttListSavedTopicConfigs: vi.fn(async () => [{ topic: "device/status", qos: "atmostonce", enabled: true, noLocal: false }]), mqttGetMessages: mqttGetMessagesMock, diff --git a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadSearch.spec.ts b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadSearch.spec.ts index 17b613d2e2..015bc8182c 100644 --- a/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadSearch.spec.ts +++ b/apps/desktop/src/components/mqtt/__tests__/MqttAdminConsolePayloadSearch.spec.ts @@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({ })); vi.mock("@/lib/backend/api", () => ({ - mqttGetBrokerInfo: vi.fn( - async (): Promise => ({ - brokerUrl: "mqtt://localhost:1883", - clientId: "dbx-test", - connected: true, - protocolVersion: "5.0", - subscriptionCount: 1, - }), - ), + mqttGetBrokerInfo: vi.fn(async (): Promise => ({ + brokerUrl: "mqtt://localhost:1883", + clientId: "dbx-test", + connected: true, + protocolVersion: "5.0", + subscriptionCount: 1, + })), mqttListTopics: vi.fn(async () => [["device/status", "atmostonce"]]), mqttListSavedTopicConfigs: vi.fn(async () => [{ topic: "device/status", qos: "atmostonce", enabled: true, noLocal: false }]), mqttGetMessages: mqttGetMessagesMock, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 3d4a436d72..b53646f806 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -3291,7 +3291,7 @@ async function prepareLegacyGroupingView() { let anchor = groupedKeyListRef.value?.getViewportAnchor(); let rowIndex = Math.min(anchor?.rowIndex ?? 0, Math.max(0, snapshot.visibleRows.length - 1)); if (anchor?.keyRaw) { - for (let offset = 0; offset < snapshot.visibleRows.length; ) { + for (let offset = 0; offset < snapshot.visibleRows.length;) { if (!current()) return; const end = Math.min(offset + 512, snapshot.visibleRows.length); let found = false; diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 3dfddeefa8..39edb687b0 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -4492,38 +4492,34 @@ function columnIndexContextMenuItems(targets: EditableStructureColumn[]): Contex disabled: () => disabled() || primaryColumns.length > 0 || targets.some(isPrimaryKeyDisabled), action: () => setColumnsPrimaryKey(targets), }, - ...([false, true] as const).map( - (unique): ContextMenuItem => ({ - label: t(unique ? "structureEditor.createUniqueIndex" : "structureEditor.createNormalIndex"), - icon: KeyRound, - iconClass: indexIconClass(unique ? "unique" : "index"), - disabled: () => - disabled() || - !structureCapabilities.value.createIndex || - indexes.value.some( - (index) => - !index.markedForDrop && - !index.isPrimary && - index.isUnique === unique && - !index.filter && - (!index.indexType || index.indexType.toUpperCase() === "BTREE") && - index.columns.length === targets.length && - index.columns.every((name, position) => structureColumnComparisonKey(name) === structureColumnComparisonKey(targets[position].name)), - ), - action: () => createIndexFromColumns(targets, unique), - }), - ), + ...([false, true] as const).map((unique): ContextMenuItem => ({ + label: t(unique ? "structureEditor.createUniqueIndex" : "structureEditor.createNormalIndex"), + icon: KeyRound, + iconClass: indexIconClass(unique ? "unique" : "index"), + disabled: () => + disabled() || + !structureCapabilities.value.createIndex || + indexes.value.some( + (index) => + !index.markedForDrop && + !index.isPrimary && + index.isUnique === unique && + !index.filter && + (!index.indexType || index.indexType.toUpperCase() === "BTREE") && + index.columns.length === targets.length && + index.columns.every((name, position) => structureColumnComparisonKey(name) === structureColumnComparisonKey(targets[position].name)), + ), + action: () => createIndexFromColumns(targets, unique), + })), ...["FULLTEXT", "SPATIAL"] .filter((type) => indexTypeOptions.value.includes(type)) - .map( - (type): ContextMenuItem => ({ - label: t(type === "FULLTEXT" ? "structureEditor.createFulltextIndex" : "structureEditor.createSpatialIndex"), - icon: KeyRound, - iconClass: indexIconClass(type === "FULLTEXT" ? "fulltext" : "spatial"), - disabled: () => disabled() || !structureCapabilities.value.createIndex || !!specialIndexColumnIssue(structureDialect.value, type, targets), - action: () => createIndexFromColumns(targets, false, type), - }), - ), + .map((type): ContextMenuItem => ({ + label: t(type === "FULLTEXT" ? "structureEditor.createFulltextIndex" : "structureEditor.createSpatialIndex"), + icon: KeyRound, + iconClass: indexIconClass(type === "FULLTEXT" ? "fulltext" : "spatial"), + disabled: () => disabled() || !structureCapabilities.value.createIndex || !!specialIndexColumnIssue(structureDialect.value, type, targets), + action: () => createIndexFromColumns(targets, false, type), + })), ]; const addItems: ContextMenuItem[] = indexes.value .filter((index) => !index.markedForDrop && !index.isPrimary) diff --git a/apps/desktop/src/components/transfer/queryResultTransferDdl.ts b/apps/desktop/src/components/transfer/queryResultTransferDdl.ts index 3fb23c061b..907239d696 100644 --- a/apps/desktop/src/components/transfer/queryResultTransferDdl.ts +++ b/apps/desktop/src/components/transfer/queryResultTransferDdl.ts @@ -204,7 +204,7 @@ function sqlServerDescriptionParameters(tokens: SqlSemanticToken[]): Map(); - for (let index = 4; index < tokens.length; ) { + for (let index = 4; index < tokens.length;) { const name = tokens[index++]; if (name?.kind !== "word" || !name.normalized.startsWith("@") || tokens[index++]?.text !== "=" || parameters.has(name.normalized)) return undefined; const valueTokens: SqlSemanticToken[] = []; diff --git a/apps/desktop/src/lib/__tests__/database/jdbcxBuiltinDriver.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcxBuiltinDriver.spec.ts index abafec98dc..a451c809c8 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcxBuiltinDriver.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcxBuiltinDriver.spec.ts @@ -154,29 +154,27 @@ describe("jdbcxBuiltinDriver", () => { it("rejects ambiguous installed JDBCX runtimes until the connection selects one", async () => { const config = jdbcxConfig(); config.jdbc_driver_paths = ["/drivers/mysql-connector-j-9.2.0.jar"]; - const bundles = ["0.8.0", "0.9.0"].map( - (version): JdbcMavenBundleInfo => ({ - id: `jdbcx-${version}`, - coordinate: `io.github.jdbcx:jdbcx-driver:${version}`, - scope: "runtime", - repositories: [], - installed_at: "2026-07-15T00:00:00Z", - path: `/drivers/jdbcx-${version}`, - artifacts: [ - { - group_id: "io.github.jdbcx", - artifact_id: "jdbcx-driver", - version, - classifier: "", - extension: "jar", - file_name: `jdbcx-driver-${version}.jar`, - path: `/drivers/jdbcx-${version}/jdbcx-driver-${version}.jar`, - size: 1, - sha256: version, - }, - ], - }), - ); + const bundles = ["0.8.0", "0.9.0"].map((version): JdbcMavenBundleInfo => ({ + id: `jdbcx-${version}`, + coordinate: `io.github.jdbcx:jdbcx-driver:${version}`, + scope: "runtime", + repositories: [], + installed_at: "2026-07-15T00:00:00Z", + path: `/drivers/jdbcx-${version}`, + artifacts: [ + { + group_id: "io.github.jdbcx", + artifact_id: "jdbcx-driver", + version, + classifier: "", + extension: "jar", + file_name: `jdbcx-driver-${version}.jar`, + path: `/drivers/jdbcx-${version}/jdbcx-driver-${version}.jar`, + size: 1, + sha256: version, + }, + ], + })); await expect(ensureJdbcxRuntimeDrivers(config, runtimeApi([], bundles))).rejects.toThrow("Select exactly one JDBCX bundle/runtime"); }); diff --git a/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts b/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts index 49ccc415a1..a4581c5509 100644 --- a/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts @@ -26,17 +26,15 @@ describe("managedJdbcDrivers", () => { it("marks both managed drivers installed when the offline payload contains every bundle", () => { const coordinates = [PRESTOSQL_JDBC_DRIVER_COORDINATE, ...PHOENIX_MANAGED_MAVEN_COORDINATES]; - const bundles = coordinates.map( - (coordinate): JdbcMavenBundleInfo => ({ - id: coordinate.replace(/[^A-Za-z0-9.-]/g, "_"), - coordinate, - scope: "runtime", - repositories: ["https://repo.maven.apache.org/maven2/"], - installed_at: "2026-08-14T00:00:00Z", - path: `/plugins/jdbc/drivers/maven/${coordinate}`, - artifacts: [], - }), - ); + const bundles = coordinates.map((coordinate): JdbcMavenBundleInfo => ({ + id: coordinate.replace(/[^A-Za-z0-9.-]/g, "_"), + coordinate, + scope: "runtime", + repositories: ["https://repo.maven.apache.org/maven2/"], + installed_at: "2026-08-14T00:00:00Z", + path: `/plugins/jdbc/drivers/maven/${coordinate}`, + artifacts: [], + })); expect(managedJdbcDriverRows(bundles, pluginStatus()).map(({ db_type, installed }) => ({ db_type, installed }))).toEqual([ { db_type: "prestosql", installed: true }, diff --git a/apps/desktop/src/lib/dataGrid/__tests__/gridNewRowPlacement.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/gridNewRowPlacement.spec.ts index 6ffc6bbe83..bd7f1e0aeb 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/gridNewRowPlacement.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/gridNewRowPlacement.spec.ts @@ -159,13 +159,10 @@ describe("buildOrderedGridRows", () => { it("merges the maximum result page without quadratic placement scans", () => { const sourceIndices = Array.from({ length: 100_000 }, (_, index) => index); - const meta = Array.from( - { length: 1_000 }, - (_, index): GridNewRowMeta => ({ - token: index + 1, - placement: { anchorId: index * 100, position: index % 2 === 0 ? "above" : "below" }, - }), - ); + const meta = Array.from({ length: 1_000 }, (_, index): GridNewRowMeta => ({ + token: index + 1, + placement: { anchorId: index * 100, position: index % 2 === 0 ? "above" : "below" }, + })); const startedAt = performance.now(); const result = entries(sourceIndices, meta); diff --git a/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts b/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts index 6e71b2f535..1b72496033 100644 --- a/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts +++ b/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts @@ -38,7 +38,7 @@ export function createShowWhitespaceExtension(view: Pick { - for (let offset = this.consumed; offset < keys.length; ) { + for (let offset = this.consumed; offset < keys.length;) { if (!work.current()) return false; const end = Math.min(offset + CHUNK, keys.length); for (; offset < end; offset++) { diff --git a/apps/desktop/src/lib/sql/insertValueHints.ts b/apps/desktop/src/lib/sql/insertValueHints.ts index 6ac3f41fb0..fe331d3205 100644 --- a/apps/desktop/src/lib/sql/insertValueHints.ts +++ b/apps/desktop/src/lib/sql/insertValueHints.ts @@ -623,7 +623,7 @@ export function findEnclosingDollarQuoteStartInText(doc: SqlTextSlice, position: chunkEnd = Math.min(chunkEnd + DOLLAR_QUOTE_CHUNK_TAIL_EXTENSION, end); } const chunk = doc.sliceString(scanned, chunkEnd); - for (let local = 0; local < chunk.length; ) { + for (let local = 0; local < chunk.length;) { const index = scanned + local; if (index >= end) break; if (chunk[local] === "$") { diff --git a/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts b/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts index f22375fef4..7b93a28643 100644 --- a/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts +++ b/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts @@ -178,7 +178,7 @@ function offsetToPosition(source: string, offset: number): { line: number; colum function tokenizeOracleSyntax(source: string, baseOffset = 0): OracleSyntaxToken[] { const tokens: OracleSyntaxToken[] = []; - for (let index = 0; index < source.length; ) { + for (let index = 0; index < source.length;) { const ignoredEnd = skipIgnorableOracleSyntax(source, index); if (ignoredEnd !== null) { index = ignoredEnd; diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index 56c8bdcbb0..c9a3de3bcf 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -245,7 +245,7 @@ function restoreProtectedEmptyLines(sql: string, markers: readonly string[]): st const markerSet = new Set(markers); const lines = sql.split(/\r\n|\r|\n/); - for (let index = 0; index < lines.length; ) { + for (let index = 0; index < lines.length;) { if (!markerSet.has(lines[index].trim())) { index += 1; continue; diff --git a/apps/desktop/src/lib/sql/sqlSyntaxTreeWindow.ts b/apps/desktop/src/lib/sql/sqlSyntaxTreeWindow.ts index d9f684a189..56a260cc20 100644 --- a/apps/desktop/src/lib/sql/sqlSyntaxTreeWindow.ts +++ b/apps/desktop/src/lib/sql/sqlSyntaxTreeWindow.ts @@ -49,7 +49,7 @@ function hasUnresolvedDollarQuoteBefore(doc: Text, from: number, lookback: numbe const scanStart = Math.max(0, from - lookback); const slice = doc.sliceString(scanStart, from); let openTag: string | null = null; - for (let index = 0; index < slice.length; ) { + for (let index = 0; index < slice.length;) { if (slice[index] === "$") { const marker = matchDollarQuoteTag(slice, index); if (marker) { diff --git a/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts index be4c33af24..31a56076df 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts @@ -25,7 +25,10 @@ describe("update draft recovery", () => { const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation"); const store = useQueryStore(); const id = store.createTab("conn", "db"); - Object.assign(store.tabs.find((tab) => tab.id === id)!, { savedSqlId: "saved", sql, originalSql: "SELECT 1;" }); + Object.assign( + store.tabs.find((tab) => tab.id === id)!, + { savedSqlId: "saved", sql, originalSql: "SELECT 1;" }, + ); await store.flushPendingPersist(); localStorage.setItem(UPDATE_RESTORE_KEY, "1"); setActivePinia(createPinia()); diff --git a/crates/dbx-ai-provider/assets/pi-mcp-bridge.mjs b/crates/dbx-ai-provider/assets/pi-mcp-bridge.mjs index 49c87ddeec..0fb81e6955 100644 --- a/crates/dbx-ai-provider/assets/pi-mcp-bridge.mjs +++ b/crates/dbx-ai-provider/assets/pi-mcp-bridge.mjs @@ -30,11 +30,7 @@ function piContent(content) { for (const item of content ?? []) { if (item?.type === "text" && typeof item.text === "string") { result.push({ type: "text", text: item.text }); - } else if ( - item?.type === "image" && - typeof item.data === "string" && - typeof item.mimeType === "string" - ) { + } else if (item?.type === "image" && typeof item.data === "string" && typeof item.mimeType === "string") { result.push({ type: "image", data: item.data, mimeType: item.mimeType }); } } @@ -81,9 +77,7 @@ export default async function registerDbxMcpBridge(pi) { child.on("exit", (code, signal) => { closed = true; const detail = stderr.trim(); - rejectPending( - `DBX MCP process exited (${signal ?? code ?? "unknown"})${detail ? `: ${detail}` : ""}`, - ); + rejectPending(`DBX MCP process exited (${signal ?? code ?? "unknown"})${detail ? `: ${detail}` : ""}`); }); const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); diff --git a/docs/scripts/preview-server.mjs b/docs/scripts/preview-server.mjs index 009d056598..f1012bcf41 100644 --- a/docs/scripts/preview-server.mjs +++ b/docs/scripts/preview-server.mjs @@ -73,9 +73,7 @@ createServer(async (req, res) => { res.writeHead(400); return res.end(); } - const hit = - (await tryFile(path)) ?? - (path.endsWith("/") ? await tryFile(join(path, "index.html")) : await tryFile(`${path}.html`)); + const hit = (await tryFile(path)) ?? (path.endsWith("/") ? await tryFile(join(path, "index.html")) : await tryFile(`${path}.html`)); if (hit) { res.writeHead(200, { "content-type": hit.type, "cache-control": "no-store" }); return res.end(await readFile(hit.file)); diff --git a/docs/scripts/update-contributors.mjs b/docs/scripts/update-contributors.mjs index c6de67610e..a2fa7b47d3 100644 --- a/docs/scripts/update-contributors.mjs +++ b/docs/scripts/update-contributors.mjs @@ -74,11 +74,7 @@ function ensureContributor(contributors, user) { return contributor; } -const [repositoryInfo, commitContributors, pullRequests] = await Promise.all([ - request(`${apiBase}/repos/${repository}`).then((response) => response.json()), - fetchAll(`/repos/${repository}/contributors?anon=0`), - fetchAll(`/repos/${repository}/pulls?state=closed&sort=created&direction=desc`), -]); +const [repositoryInfo, commitContributors, pullRequests] = await Promise.all([request(`${apiBase}/repos/${repository}`).then((response) => response.json()), fetchAll(`/repos/${repository}/contributors?anon=0`), fetchAll(`/repos/${repository}/pulls?state=closed&sort=created&direction=desc`)]); const contributors = new Map(); @@ -97,9 +93,11 @@ for (const entry of commitContributors) { if (contributor) contributor.commits = entry.contributions || 0; } -const rankedContributors = [...contributors.values()].filter((contributor) => contributor.mergedPullRequests > 0).sort((left, right) => { - return right.commits - left.commits || right.mergedPullRequests - left.mergedPullRequests || left.login.localeCompare(right.login); -}); +const rankedContributors = [...contributors.values()] + .filter((contributor) => contributor.mergedPullRequests > 0) + .sort((left, right) => { + return right.commits - left.commits || right.mergedPullRequests - left.mergedPullRequests || left.login.localeCompare(right.login); + }); const output = { repository, diff --git a/docs/scripts/verify-doc-content.mjs b/docs/scripts/verify-doc-content.mjs index e604ac94b4..77b10d4e62 100644 --- a/docs/scripts/verify-doc-content.mjs +++ b/docs/scripts/verify-doc-content.mjs @@ -78,10 +78,7 @@ for (const file of files) { } const languagePattern = LANGUAGES.join("|"); - const links = [ - ...content.matchAll(new RegExp(`\\]\\(/(${languagePattern})/docs/([^)#?]+)(?:#[^)]*)?\\)`, "g")), - ...content.matchAll(new RegExp(`href="/(${languagePattern})/docs/([^"#?]+)(?:#[^"]*)?"`, "g")), - ]; + const links = [...content.matchAll(new RegExp(`\\]\\(/(${languagePattern})/docs/([^)#?]+)(?:#[^)]*)?\\)`, "g")), ...content.matchAll(new RegExp(`href="/(${languagePattern})/docs/([^"#?]+)(?:#[^"]*)?"`, "g"))]; for (const link of links) { const [, language, slug] = link; @@ -93,18 +90,14 @@ for (const file of files) { const databaseSupportSource = fs.readFileSync(path.join(repositoryDir, "docs/data/databaseSupport.ts"), "utf8"); const connectionProfilesSource = fs.readFileSync(path.join(repositoryDir, "apps/desktop/src/types/generated/connectionProfiles.ts"), "utf8"); const jdbcProductProfilesSource = fs.readFileSync(path.join(repositoryDir, "apps/desktop/src/lib/database/jdbcProductProfiles.ts"), "utf8"); -const connectionOptionsMatch = connectionProfilesSource.match( - /export const CONNECTION_PICKER_OPTIONS = \[([\s\S]*?)\n\] as const satisfies readonly ConnectionPickerOption\[\];/, -); +const connectionOptionsMatch = connectionProfilesSource.match(/export const CONNECTION_PICKER_OPTIONS = \[([\s\S]*?)\n\] as const satisfies readonly ConnectionPickerOption\[\];/); const jdbcProductProfilesMatch = jdbcProductProfilesSource.match(/JDBC_PRODUCT_PROFILES = \[([^\]]*)\]/); if (!connectionOptionsMatch) { errors.push("Unable to read database options from connectionProfiles.ts"); } else { const connectionModeIds = new Set(["custom_mysql", "custom_postgres"]); - const connectionIds = [...connectionOptionsMatch[1].matchAll(/value: "([^"]+)"/g)] - .map((match) => match[1]) - .filter((id) => !connectionModeIds.has(id)); + const connectionIds = [...connectionOptionsMatch[1].matchAll(/value: "([^"]+)"/g)].map((match) => match[1]).filter((id) => !connectionModeIds.has(id)); if (!jdbcProductProfilesMatch) { errors.push("Unable to read registered JDBC product profiles"); @@ -129,9 +122,7 @@ if (!connectionOptionsMatch) { } } - const websiteIds = [...databaseSupportSource.matchAll(/\{ id: "([^"]+)"/g)] - .map((match) => match[1]) - .filter((id) => id !== "request"); + const websiteIds = [...databaseSupportSource.matchAll(/\{ id: "([^"]+)"/g)].map((match) => match[1]).filter((id) => id !== "request"); for (const [label, ids] of [ ["connectionProfiles.ts", connectionIds], diff --git a/docs/scripts/verify-static-export.mjs b/docs/scripts/verify-static-export.mjs index 63f46a16e7..6f5d8d28cb 100644 --- a/docs/scripts/verify-static-export.mjs +++ b/docs/scripts/verify-static-export.mjs @@ -64,13 +64,7 @@ for (const requirement of requiredContent) { console.log("Static export metadata verified for language, software schema, and llms.txt accuracy."); -const localizedHtmlFiles = files.filter( - (file) => - extname(file) === ".html" && - LANGUAGES.some( - (lang) => file === join(outputDirectory, `${lang}.html`) || file.startsWith(`${join(outputDirectory, lang)}${sep}`), - ), -); +const localizedHtmlFiles = files.filter((file) => extname(file) === ".html" && LANGUAGES.some((lang) => file === join(outputDirectory, `${lang}.html`) || file.startsWith(`${join(outputDirectory, lang)}${sep}`))); for (const file of localizedHtmlFiles) { const content = await readFile(file, "utf8"); diff --git a/packages/plugin-cli/scripts/stage-dev.mjs b/packages/plugin-cli/scripts/stage-dev.mjs index 120936d84e..d891f12b9b 100644 --- a/packages/plugin-cli/scripts/stage-dev.mjs +++ b/packages/plugin-cli/scripts/stage-dev.mjs @@ -1,17 +1,17 @@ -import { cpSync, existsSync, rmSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const root = resolve(packageRoot, '../../plugins/sdk/dev-host'); -const destination = resolve(packageRoot, 'dev-runtime'); -if (process.argv.includes('--clean')) { +import { cpSync, existsSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const root = resolve(packageRoot, "../../plugins/sdk/dev-host"); +const destination = resolve(packageRoot, "dev-runtime"); +if (process.argv.includes("--clean")) { rmSync(destination, { recursive: true, force: true }); } else { - if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Building dev runtime requires Node.js 22+'); - if (!existsSync(resolve(root, 'node_modules/vite'))) throw new Error('Run npm ci --prefix plugins/sdk/dev-host before packing the CLI'); - const result = spawnSync(process.execPath, [resolve(root, 'build.mjs')], { stdio: 'inherit' }); - if (result.status !== 0) throw new Error('Development runtime build failed'); + if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("Building dev runtime requires Node.js 22+"); + if (!existsSync(resolve(root, "node_modules/vite"))) throw new Error("Run npm ci --prefix plugins/sdk/dev-host before packing the CLI"); + const result = spawnSync(process.execPath, [resolve(root, "build.mjs")], { stdio: "inherit" }); + if (result.status !== 0) throw new Error("Development runtime build failed"); rmSync(destination, { recursive: true, force: true }); - cpSync(resolve(root, 'dist'), destination, { recursive: true }); + cpSync(resolve(root, "dist"), destination, { recursive: true }); } diff --git a/packages/plugin-cli/scripts/stage-sdk.mjs b/packages/plugin-cli/scripts/stage-sdk.mjs index a0c7c2dfe4..c8b3916a97 100644 --- a/packages/plugin-cli/scripts/stage-sdk.mjs +++ b/packages/plugin-cli/scripts/stage-sdk.mjs @@ -15,9 +15,7 @@ const packageManifest = JSON.parse(readFileSync(join(packageRoot, "package.json" const cliManifest = readFileSync(join(repositoryRoot, "plugins/sdk/cli/Cargo.toml"), "utf8"); const cliVersion = cliManifest.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; if (cliVersion !== packageManifest.version) { - throw new Error( - `@dbx-app/plugin-cli version ${packageManifest.version} does not match dbx-plugin-cli ${cliVersion ?? "unknown"}.`, - ); + throw new Error(`@dbx-app/plugin-cli version ${packageManifest.version} does not match dbx-plugin-cli ${cliVersion ?? "unknown"}.`); } const sources = [ @@ -43,7 +41,4 @@ for (const [sourceRelative, destinationRelative] of sources) { }); } -writeFileSync( - join(destinationRoot, "README.md"), - "# Bundled DBX Plugin SDK\n\nThis directory is bundled for `dbx-plugin package` and is managed by `@dbx-app/plugin-cli`.\n", -); +writeFileSync(join(destinationRoot, "README.md"), "# Bundled DBX Plugin SDK\n\nThis directory is bundled for `dbx-plugin package` and is managed by `@dbx-app/plugin-cli`.\n"); diff --git a/packages/plugin-cli/tests/dev-launcher.test.mjs b/packages/plugin-cli/tests/dev-launcher.test.mjs index 0b082bc2a4..44da5d1a49 100644 --- a/packages/plugin-cli/tests/dev-launcher.test.mjs +++ b/packages/plugin-cli/tests/dev-launcher.test.mjs @@ -1,21 +1,30 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -test('dev selects the bundled runtime and current Node without changing ordinary launcher behavior', () => { - const cwd = mkdtempSync(join(tmpdir(), 'dbx-dev-launcher-')); +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +test("dev selects the bundled runtime and current Node without changing ordinary launcher behavior", () => { + const cwd = mkdtempSync(join(tmpdir(), "dbx-dev-launcher-")); try { - writeFileSync(join(cwd, 'dev'), 'console.log(JSON.stringify({runtime:process.env.DBX_PLUGIN_DEV_RUNTIME,node:process.env.DBX_PLUGIN_NODE,args:process.argv.slice(2)}))'); - const invoke = extra => spawnSync(process.execPath, [join(root, 'bin/dbx-plugin.js'), 'dev', '--port', '5190'], { - cwd, encoding: 'utf8', env: { ...process.env, DBX_PLUGIN_CLI_BINARY: process.execPath, DBX_PLUGIN_NODE: '', DBX_PLUGIN_DEV_RUNTIME: '', ...extra }, - }); - const result = invoke({}); assert.equal(result.status, 0, result.stderr); - const data = JSON.parse(result.stdout); assert.equal(data.runtime, join(root, 'dev-runtime/runtime.mjs')); assert.equal(data.node, process.execPath); assert.deepEqual(data.args, ['--port','5190']); - const explicit = invoke({ DBX_PLUGIN_DEV_RUNTIME: '/custom/runtime.mjs', DBX_PLUGIN_NODE: '/custom/node' }); - assert.equal(JSON.parse(explicit.stdout).runtime, '/custom/runtime.mjs'); - } finally { rmSync(cwd, { recursive: true, force: true }); } + writeFileSync(join(cwd, "dev"), "console.log(JSON.stringify({runtime:process.env.DBX_PLUGIN_DEV_RUNTIME,node:process.env.DBX_PLUGIN_NODE,args:process.argv.slice(2)}))"); + const invoke = (extra) => + spawnSync(process.execPath, [join(root, "bin/dbx-plugin.js"), "dev", "--port", "5190"], { + cwd, + encoding: "utf8", + env: { ...process.env, DBX_PLUGIN_CLI_BINARY: process.execPath, DBX_PLUGIN_NODE: "", DBX_PLUGIN_DEV_RUNTIME: "", ...extra }, + }); + const result = invoke({}); + assert.equal(result.status, 0, result.stderr); + const data = JSON.parse(result.stdout); + assert.equal(data.runtime, join(root, "dev-runtime/runtime.mjs")); + assert.equal(data.node, process.execPath); + assert.deepEqual(data.args, ["--port", "5190"]); + const explicit = invoke({ DBX_PLUGIN_DEV_RUNTIME: "/custom/runtime.mjs", DBX_PLUGIN_NODE: "/custom/node" }); + assert.equal(JSON.parse(explicit.stdout).runtime, "/custom/runtime.mjs"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); diff --git a/packages/plugin-cli/tests/launcher.test.mjs b/packages/plugin-cli/tests/launcher.test.mjs index 3edf722547..de7ff6535a 100644 --- a/packages/plugin-cli/tests/launcher.test.mjs +++ b/packages/plugin-cli/tests/launcher.test.mjs @@ -14,18 +14,14 @@ test("forwards arguments and provides the bundled SDK root", () => { const stage = spawnSync(process.execPath, [stageSdk], { encoding: "utf8" }); assert.equal(stage.status, 0, stage.stderr); try { - const result = spawnSync( - process.execPath, - [launcher, "-e", "process.stdout.write(process.env.DBX_PLUGIN_SDK_ROOT || '')"], - { - encoding: "utf8", - env: { - ...process.env, - DBX_PLUGIN_CLI_BINARY: process.execPath, - DBX_PLUGIN_SDK_ROOT: "", - }, + const result = spawnSync(process.execPath, [launcher, "-e", "process.stdout.write(process.env.DBX_PLUGIN_SDK_ROOT || '')"], { + encoding: "utf8", + env: { + ...process.env, + DBX_PLUGIN_CLI_BINARY: process.execPath, + DBX_PLUGIN_SDK_ROOT: "", }, - ); + }); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, sdkRoot); assert.ok(existsSync(join(sdkRoot, "plugins", "sdk", "rust", "dbx-plugin-sdk", "Cargo.toml"))); @@ -36,18 +32,14 @@ test("forwards arguments and provides the bundled SDK root", () => { }); test("preserves an explicit SDK root", () => { - const result = spawnSync( - process.execPath, - [launcher, "-e", "process.stdout.write(process.env.DBX_PLUGIN_SDK_ROOT || '')"], - { - encoding: "utf8", - env: { - ...process.env, - DBX_PLUGIN_CLI_BINARY: process.execPath, - DBX_PLUGIN_SDK_ROOT: "/custom/dbx-plugin-sdk", - }, + const result = spawnSync(process.execPath, [launcher, "-e", "process.stdout.write(process.env.DBX_PLUGIN_SDK_ROOT || '')"], { + encoding: "utf8", + env: { + ...process.env, + DBX_PLUGIN_CLI_BINARY: process.execPath, + DBX_PLUGIN_SDK_ROOT: "/custom/dbx-plugin-sdk", }, - ); + }); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "/custom/dbx-plugin-sdk"); }); diff --git a/plugins/examples/hello-workbench/package.mjs b/plugins/examples/hello-workbench/package.mjs index 89a7a3a496..27fb859a6d 100644 --- a/plugins/examples/hello-workbench/package.mjs +++ b/plugins/examples/hello-workbench/package.mjs @@ -33,22 +33,7 @@ try { await cp(path.join(root, "assets"), path.join(stage, "assets"), { recursive: true }); await cp(path.join(root, "ui"), path.join(stage, "ui"), { recursive: true }); - const packagerArgs = [ - "run", - "--locked", - "--release", - "--manifest-path", - path.join(root, "..", "..", "sdk", "packager", "Cargo.toml"), - "--", - stage, - output, - "--artifact-metadata", - artifactMetadata, - "--target", - target, - "--artifact-url", - path.basename(output), - ]; + const packagerArgs = ["run", "--locked", "--release", "--manifest-path", path.join(root, "..", "..", "sdk", "packager", "Cargo.toml"), "--", stage, output, "--artifact-metadata", artifactMetadata, "--target", target, "--artifact-url", path.basename(output)]; run("cargo", packagerArgs, { CARGO_TARGET_DIR: packagerTarget }); console.log(`Built unsigned candidate ${output}`); } finally { diff --git a/plugins/examples/hello-workbench/repository-sign.mjs b/plugins/examples/hello-workbench/repository-sign.mjs index a822cf54c5..e3f9a81b5a 100644 --- a/plugins/examples/hello-workbench/repository-sign.mjs +++ b/plugins/examples/hello-workbench/repository-sign.mjs @@ -8,9 +8,7 @@ import { fileURLToPath } from "node:url"; const root = path.dirname(fileURLToPath(import.meta.url)); const target = process.env.DBX_PLUGIN_TARGET || platformTarget(); const candidate = path.resolve(process.argv[2] || path.join(root, "dist", `dbx.example.hello-1.0.0-${target}.dbxp`)); -const signed = path.resolve( - process.argv[3] || path.join(root, "dist", `dbx.example.hello-1.0.0-${target}.signed.dbxp`), -); +const signed = path.resolve(process.argv[3] || path.join(root, "dist", `dbx.example.hello-1.0.0-${target}.signed.dbxp`)); const metadata = signed.replace(/\.dbxp$/, ".artifact.json"); const keyId = process.env.DBX_PLUGIN_SIGNING_KEY_ID; const buildRoot = await mkdtemp(path.join(os.tmpdir(), "dbx-repository-sign-")); @@ -22,25 +20,7 @@ if (!process.env.DBX_PLUGIN_SIGNING_KEY || !keyId) { try { run( "cargo", - [ - "run", - "--locked", - "--release", - "--manifest-path", - path.join(root, "..", "..", "sdk", "packager", "Cargo.toml"), - "--", - "sign", - candidate, - signed, - "--key-id", - keyId, - "--artifact-metadata", - metadata, - "--target", - target, - "--artifact-url", - `../hello-workbench/dist/${path.basename(signed)}`, - ], + ["run", "--locked", "--release", "--manifest-path", path.join(root, "..", "..", "sdk", "packager", "Cargo.toml"), "--", "sign", candidate, signed, "--key-id", keyId, "--artifact-metadata", metadata, "--target", target, "--artifact-url", `../hello-workbench/dist/${path.basename(signed)}`], { CARGO_TARGET_DIR: path.join(buildRoot, "cargo-target") }, ); diff --git a/plugins/examples/hello-workbench/repository-smoke.mjs b/plugins/examples/hello-workbench/repository-smoke.mjs index 357b63d28b..7bf00d0aed 100644 --- a/plugins/examples/hello-workbench/repository-smoke.mjs +++ b/plugins/examples/hello-workbench/repository-smoke.mjs @@ -16,22 +16,7 @@ const signed = path.join(temporary, `dbx.example.hello-1.0.0-${target}.signed.db try { run(process.execPath, [path.join(root, "package.mjs")], { DBX_PLUGIN_OUTPUT_DIR: candidateDirectory }); - run( - "cargo", - [ - "run", - "--quiet", - "--locked", - "--manifest-path", - path.join(repositoryRoot, "plugins", "sdk", "cli", "Cargo.toml"), - "--", - "keygen", - "example-repository-smoke", - "--output", - keyFile, - ], - { CARGO_TARGET_DIR: path.join(temporary, "cli-target") }, - ); + run("cargo", ["run", "--quiet", "--locked", "--manifest-path", path.join(repositoryRoot, "plugins", "sdk", "cli", "Cargo.toml"), "--", "keygen", "example-repository-smoke", "--output", keyFile], { CARGO_TARGET_DIR: path.join(temporary, "cli-target") }); const signingEnvironment = await readEnvironmentFile(keyFile); run(process.execPath, [path.join(root, "repository-sign.mjs"), candidate, signed], { ...signingEnvironment, diff --git a/scripts/ai-html-runtime-canary.mjs b/scripts/ai-html-runtime-canary.mjs index dd5ed3d672..107cdbebf8 100644 --- a/scripts/ai-html-runtime-canary.mjs +++ b/scripts/ai-html-runtime-canary.mjs @@ -35,26 +35,14 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", ".."); const CANARY_SPEC = "apps/desktop/src/lib/ai/richContent/__tests__/aiHtmlPreviewCanary.spec.ts"; -const HEADLESS_ARGS = [ - "--headless=new", - "--disable-gpu", - "--no-first-run", - "--no-default-browser-check", -]; +const HEADLESS_ARGS = ["--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check"]; -const BROWSER_CANDIDATES = process.platform === "win32" - ? [ - "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", - "C:/Program Files/Microsoft/Edge/Application/msedge.exe", - "C:/Program Files/Google/Chrome/Application/chrome.exe", - "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", - ] - : process.platform === "darwin" - ? [ - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - ] - : ["msedge", "google-chrome", "chromium", "chromium-browser"]; +const BROWSER_CANDIDATES = + process.platform === "win32" + ? ["C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"] + : process.platform === "darwin" + ? ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + : ["msedge", "google-chrome", "chromium", "chromium-browser"]; function browserCandidates() { const override = process.env.AI_HTML_CANARY_BROWSER; @@ -116,14 +104,7 @@ function extractWrappedPayload(harness) { function runHeadless(browser, filePath, userDataDir, netlogPath) { const fileUrl = pathToFileURL(filePath).href; const dumpPath = `${netlogPath}.dom.html`; - const args = [ - ...HEADLESS_ARGS, - `--user-data-dir=${userDataDir}`, - `--log-net-log=${netlogPath}`, - "--virtual-time-budget=8000", - "--dump-dom", - fileUrl, - ]; + const args = [...HEADLESS_ARGS, `--user-data-dir=${userDataDir}`, `--log-net-log=${netlogPath}`, "--virtual-time-budget=8000", "--dump-dom", fileUrl]; let dom = ""; try { dom = execFileSync(browser, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); diff --git a/scripts/bench/mongodb-count.mjs b/scripts/bench/mongodb-count.mjs index 3b4c3a42c9..4d779ab979 100644 --- a/scripts/bench/mongodb-count.mjs +++ b/scripts/bench/mongodb-count.mjs @@ -156,20 +156,7 @@ async function ensureMongoContainer(options) { await run("docker", ["inspect", options.container]); await run("docker", ["start", options.container]); } catch { - await run("docker", [ - "run", - "-d", - "--name", - options.container, - "--platform", - "linux/amd64", - "-p", - `${options.host}:${options.port}:27017`, - options.image, - "--nojournal", - "--wiredTigerCacheSizeGB", - "0.5", - ]); + await run("docker", ["run", "-d", "--name", options.container, "--platform", "linux/amd64", "-p", `${options.host}:${options.port}:27017`, options.image, "--nojournal", "--wiredTigerCacheSizeGB", "0.5"]); } const deadline = Date.now() + 30_000; @@ -200,9 +187,7 @@ async function seedMongo(options) { return { skipped: true, count: current, elapsedMs: 0 }; } if (!options.seed && !options.forceSeed) { - throw new Error( - `Collection count is ${current}, expected ${options.expectedCount}. Re-run with --seed to create the benchmark dataset.`, - ); + throw new Error(`Collection count is ${current}, expected ${options.expectedCount}. Re-run with --seed to create the benchmark dataset.`); } const startedAt = performance.now(); @@ -408,9 +393,7 @@ function printReport(result) { console.log("| Case | Value | Payload | Min | P50 | P90 | Max | Avg |"); console.log("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); for (const row of result.measurements) { - console.log( - `| ${row.label} | ${row.value} | ${formatBytes(row.payloadBytes)} | ${formatMs(row.minMs)} | ${formatMs(row.p50Ms)} | ${formatMs(row.p90Ms)} | ${formatMs(row.maxMs)} | ${formatMs(row.avgMs)} |`, - ); + console.log(`| ${row.label} | ${row.value} | ${formatBytes(row.payloadBytes)} | ${formatMs(row.minMs)} | ${formatMs(row.p50Ms)} | ${formatMs(row.p90Ms)} | ${formatMs(row.maxMs)} | ${formatMs(row.avgMs)} |`); } } diff --git a/scripts/bench/redis-key-search.mjs b/scripts/bench/redis-key-search.mjs index 796df9ed3a..1ea81a3a38 100644 --- a/scripts/bench/redis-key-search.mjs +++ b/scripts/bench/redis-key-search.mjs @@ -132,20 +132,7 @@ async function ensureRedisContainer(options) { await run("docker", ["inspect", options.container]); await run("docker", ["start", options.container]); } catch { - await run("docker", [ - "run", - "-d", - "--name", - options.container, - "-p", - `${options.host}:${options.port}:6379`, - options.image, - "redis-server", - "--save", - "", - "--appendonly", - "no", - ]); + await run("docker", ["run", "-d", "--name", options.container, "-p", `${options.host}:${options.port}:6379`, options.image, "redis-server", "--save", "", "--appendonly", "no"]); } const deadline = Date.now() + 15_000; diff --git a/scripts/benchmark-cli.mjs b/scripts/benchmark-cli.mjs index 4f4d11d669..86b5853eb1 100644 --- a/scripts/benchmark-cli.mjs +++ b/scripts/benchmark-cli.mjs @@ -66,14 +66,24 @@ for (const benchmark of cases) { run(legacyBinary, benchmark.args); const samples = { rust: [], legacy: [] }; for (let index = 0; index < benchmark.iterations; index += 1) { - const order = index % 2 === 0 ? [["rust", rustBinary], ["legacy", legacyBinary]] : [["legacy", legacyBinary], ["rust", rustBinary]]; + const order = + index % 2 === 0 + ? [ + ["rust", rustBinary], + ["legacy", legacyBinary], + ] + : [ + ["legacy", legacyBinary], + ["rust", rustBinary], + ]; for (const [runtime, binary] of order) samples[runtime].push(run(binary, benchmark.args)); } - for (const [runtime, binary] of [["rust", rustBinary], ["legacy", legacyBinary]]) { + for (const [runtime, binary] of [ + ["rust", rustBinary], + ["legacy", legacyBinary], + ]) { const stats = summarize(samples[runtime]); const rss = peakRss(binary, benchmark.args); - console.log( - `${benchmark.name}\t${runtime}\t${stats.median.toFixed(1)}\t${stats.p90.toFixed(1)}\t${stats.p95.toFixed(1)}\t${stats.mean.toFixed(1)}\t${rss.toFixed(1)}`, - ); + console.log(`${benchmark.name}\t${runtime}\t${stats.median.toFixed(1)}\t${stats.p90.toFixed(1)}\t${stats.p95.toFixed(1)}\t${stats.mean.toFixed(1)}\t${rss.toFixed(1)}`); } } diff --git a/scripts/build-knowledge-base.mjs b/scripts/build-knowledge-base.mjs index 10aaa97b82..ffc4f9234b 100644 --- a/scripts/build-knowledge-base.mjs +++ b/scripts/build-knowledge-base.mjs @@ -14,9 +14,7 @@ async function main() { const lang = args.lang ?? DEFAULT_LANG; const fileSuffix = lang ? `.${lang}.mdx` : ".mdx"; - const files = (await fs.readdir(inputDir)) - .filter((name) => name.endsWith(fileSuffix)) - .sort((a, b) => a.localeCompare(b)); + const files = (await fs.readdir(inputDir)).filter((name) => name.endsWith(fileSuffix)).sort((a, b) => a.localeCompare(b)); if (files.length === 0) { throw new Error(`No ${fileSuffix} files found in ${inputDir}`); @@ -104,7 +102,10 @@ function parseFrontmatter(source) { const separator = line.indexOf(":"); if (separator === -1) continue; const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, ""); + const value = line + .slice(separator + 1) + .trim() + .replace(/^["']|["']$/g, ""); fields[key] = value; } @@ -234,21 +235,10 @@ function keywordsFor(category, heading, description, text) { } function renderMarkdown(documents) { - const lines = [ - "# DBX 中文知识库", - "", - "> 从 `docs/content/docs/*.cn.mdx` 生成的纯 Markdown 知识库内容。", - "", - "## 来源索引", - "", - "| 来源文件 | 标题 | 描述 | 知识条数 |", - "| --- | --- | --- | ---: |", - ]; + const lines = ["# DBX 中文知识库", "", "> 从 `docs/content/docs/*.cn.mdx` 生成的纯 Markdown 知识库内容。", "", "## 来源索引", "", "| 来源文件 | 标题 | 描述 | 知识条数 |", "| --- | --- | --- | ---: |"]; for (const document of documents) { - lines.push( - `| ${escapeTableCell(document.source)} | ${escapeTableCell(document.title)} | ${escapeTableCell(document.description)} | ${document.sections.length} |`, - ); + lines.push(`| ${escapeTableCell(document.source)} | ${escapeTableCell(document.title)} | ${escapeTableCell(document.description)} | ${document.sections.length} |`); } for (const document of documents) { @@ -266,7 +256,10 @@ function renderMarkdown(documents) { } } - return `${lines.join("\n").replace(/\n{4,}/g, "\n\n\n").trim()}\n`; + return `${lines + .join("\n") + .replace(/\n{4,}/g, "\n\n\n") + .trim()}\n`; } function escapeTableCell(value) { diff --git a/scripts/check-mq-integration.mjs b/scripts/check-mq-integration.mjs index 56ec9464de..eab42d5104 100644 --- a/scripts/check-mq-integration.mjs +++ b/scripts/check-mq-integration.mjs @@ -25,9 +25,15 @@ assertIncludes("apps/desktop/src/stores/queryStore.ts", "openMqAdmin", "Query st const manifest = JSON.parse(read("crates/dbx-core/assets/database-drivers.manifest.json")); const drivers = Array.isArray(manifest) ? manifest : manifest.drivers; -assert.ok(drivers?.some((driver) => driver.dbType === "mq"), "Driver manifest must include dbType=mq."); +assert.ok( + drivers?.some((driver) => driver.dbType === "mq"), + "Driver manifest must include dbType=mq.", +); const mqDriver = drivers.find((driver) => driver.dbType === "mq"); -assert.ok(mqDriver.driverProfiles?.some((profile) => profile.profile === "rabbitmq"), "MQ driver manifest entry must include a rabbitmq driver profile."); +assert.ok( + mqDriver.driverProfiles?.some((profile) => profile.profile === "rabbitmq"), + "MQ driver manifest entry must include a rabbitmq driver profile.", +); const mqHttp = read("apps/desktop/src/lib/backend/mq-http.ts"); assert.ok(!mqHttp.includes('post("/mq/'), "MQ HTTP client must not call unprefixed /mq paths."); @@ -80,16 +86,8 @@ for (const panel of ["PoliciesPanel.vue", "PermissionsPanel.vue", "RawApiPanel.v for (const panel of ["TenantsPanel.vue", "NamespacesPanel.vue", "TopicsPanel.vue", "SubscriptionsPanel.vue"]) { assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`); } -assertIncludes( - "apps/desktop/src/components/mq/TopicsPanel.vue", - "topics-table-hscroll", - "TopicsPanel must keep a shared horizontal scroller so wide RocketMQ action columns stay reachable.", -); -assertIncludes( - "apps/desktop/src/components/mq/TopicsPanel.vue", - "overflow-x: auto", - "TopicsPanel horizontal scroller must allow overflow-x.", -); +assertIncludes("apps/desktop/src/components/mq/TopicsPanel.vue", "topics-table-hscroll", "TopicsPanel must keep a shared horizontal scroller so wide RocketMQ action columns stay reachable."); +assertIncludes("apps/desktop/src/components/mq/TopicsPanel.vue", "overflow-x: auto", "TopicsPanel horizontal scroller must allow overflow-x."); for (const panel of ["ExchangesPanel.vue", "SendMessagePanel.vue", "rabbitmq/RabbitMqClientsPanel.vue", "ProducerConsumerPanel.vue"]) { assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`); } diff --git a/scripts/core-architecture.test.mjs b/scripts/core-architecture.test.mjs index c170bcefae..e158205586 100644 --- a/scripts/core-architecture.test.mjs +++ b/scripts/core-architecture.test.mjs @@ -7,11 +7,13 @@ import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("../", import.meta.url)); const read = (name) => readFileSync(path.join(root, name), "utf8"); -const metadata = JSON.parse(execFileSync("cargo", ["metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], { - cwd: root, - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, -})); +const metadata = JSON.parse( + execFileSync("cargo", ["metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], { + cwd: root, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }), +); const members = new Map(metadata.packages.filter((pkg) => metadata.workspace_members.includes(pkg.id)).map((pkg) => [pkg.name, pkg])); const allowedDependencies = { "dbx-types": [], @@ -32,11 +34,7 @@ const allowedDependencies = { "dbx-driver-postgres": ["dbx-types", "dbx-sql-core", "dbx-driver-support"], "dbx-driver-redis": ["dbx-types", "dbx-driver-support"], "dbx-driver-sqlserver": ["dbx-types", "dbx-sql-core", "dbx-sql-data", "dbx-driver-support"], - "dbx-drivers": [ - "dbx-types", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-platform", "dbx-sqlite-worker", - "dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", - "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", - ], + "dbx-drivers": ["dbx-types", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-platform", "dbx-sqlite-worker", "dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver"], }; const core = members.get("dbx-core"); @@ -58,12 +56,27 @@ for (const [name, allowed] of Object.entries(allowedDependencies)) { test("core owns application orchestration in real business directories", () => { const ownedFiles = [ - "connection/mod.rs", "connection/connection_secrets.rs", "connection/driver_runtime.rs", - "query/mod.rs", "query/query_cancel.rs", "query/document_ops.rs", - "schema/mod.rs", "schema/table_structure_sql/sqlite_rebuild.rs", - "data/transfer.rs", "data/table_import.rs", "data/docs/export.rs", "data/cloudflare_d1/import.rs", - "ai/agent_loop.rs", "ai/agent_tools.rs", "admin/consul/mod.rs", "admin/mq/mod.rs", "admin/mqtt/mod.rs", - "persistence/storage.rs", "persistence/config/mod.rs", "safety/production_safety.rs", "host/update.rs", + "connection/mod.rs", + "connection/connection_secrets.rs", + "connection/driver_runtime.rs", + "query/mod.rs", + "query/query_cancel.rs", + "query/document_ops.rs", + "schema/mod.rs", + "schema/table_structure_sql/sqlite_rebuild.rs", + "data/transfer.rs", + "data/table_import.rs", + "data/docs/export.rs", + "data/cloudflare_d1/import.rs", + "ai/agent_loop.rs", + "ai/agent_tools.rs", + "admin/consul/mod.rs", + "admin/mq/mod.rs", + "admin/mqtt/mod.rs", + "persistence/storage.rs", + "persistence/config/mod.rs", + "safety/production_safety.rs", + "host/update.rs", ]; for (const name of ownedFiles) { assert.ok(existsSync(path.join(root, "crates/dbx-core/src", name)), `missing owned business source: ${name}`); @@ -97,10 +110,7 @@ test("code generators and runtime assets belong to their implementation crates", assert.match(read("crates/dbx-sql-dialect/build.rs"), /plugins\/dialects/); assert.match(read("crates/dbx-ai-provider/src/ai_pi_agent_cli.rs"), /assets\/pi-mcp-bridge\.mjs/); assert.ok(existsSync(path.join(root, "crates/dbx-ai-provider/assets/pi-mcp-bridge.mjs"))); - assert.deepEqual( - JSON.parse(read("crates/dbx-driver-agent/assets/agent-protocol-v2.json")), - JSON.parse(read("agents/common/src/main/resources/agent-protocol-v2.json")), - ); + assert.deepEqual(JSON.parse(read("crates/dbx-driver-agent/assets/agent-protocol-v2.json")), JSON.parse(read("agents/common/src/main/resources/agent-protocol-v2.json"))); assert.match(read("crates/dbx-platform/src/lib.rs"), /#\[cfg\(all\(target_os = "windows", target_env = "gnu"\)\)\]\s*mod nanosleep_stub/); }); @@ -131,9 +141,12 @@ test("CI and release tracking follow the new source owners", () => { }); test("the standalone DuckDB lockfile includes core's internal dependency closure", () => { - const lockedPackages = new Map(read("agents/drivers/duckdb/Cargo.lock") - .split("\n[[package]]\n").slice(1) - .map((block) => [block.match(/^name = "([^"]+)"$/m)[1], block])); + const lockedPackages = new Map( + read("agents/drivers/duckdb/Cargo.lock") + .split("\n[[package]]\n") + .slice(1) + .map((block) => [block.match(/^name = "([^"]+)"$/m)[1], block]), + ); const visited = new Set(); function inspect(name) { if (visited.has(name)) return; diff --git a/scripts/database-env.mjs b/scripts/database-env.mjs index 46637ec1f5..840c60327c 100644 --- a/scripts/database-env.mjs +++ b/scripts/database-env.mjs @@ -1,15 +1,15 @@ #!/usr/bin/env node -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -export const DEFAULT_RECIPES_ROOT = join(REPO_ROOT, 'deploy', 'database'); -const DEFAULT_MAKEFILE_PATH = join(REPO_ROOT, 'Makefile'); -const DEFAULT_PASSWORD = '123456'; -const DEFAULT_DATABASE = 'dbx'; +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const DEFAULT_RECIPES_ROOT = join(REPO_ROOT, "deploy", "database"); +const DEFAULT_MAKEFILE_PATH = join(REPO_ROOT, "Makefile"); +const DEFAULT_PASSWORD = "123456"; +const DEFAULT_DATABASE = "dbx"; const DEFAULT_HOST_PORT_RANGES = { mysql: [10100, 10199], mariadb: [10200, 10299], @@ -28,18 +28,18 @@ const DEFAULT_HOST_PORT_RANGES = { elasticsearch: [11500, 11599], }; const DBX_DEEP_LINK_TYPES = { - clickhouse: 'clickhouse', - consul: 'consul', - elasticsearch: 'elasticsearch', - etcd: 'etcd', - mariadb: 'mariadb', - mongodb: 'mongodb', - mysql: 'mysql', - postgresql: 'postgres', - qdrant: 'qdrant', - redis: 'redis', - rnacos: 'r-nacos', - zookeeper: 'zookeeper', + clickhouse: "clickhouse", + consul: "consul", + elasticsearch: "elasticsearch", + etcd: "etcd", + mariadb: "mariadb", + mongodb: "mongodb", + mysql: "mysql", + postgresql: "postgres", + qdrant: "qdrant", + redis: "redis", + rnacos: "r-nacos", + zookeeper: "zookeeper", }; export function discoverRecipes(root = DEFAULT_RECIPES_ROOT) { @@ -50,13 +50,13 @@ export function discoverRecipes(root = DEFAULT_RECIPES_ROOT) { const databaseDir = join(root, database.name); for (const version of readdirSync(databaseDir, { withFileTypes: true })) { if (!version.isDirectory()) continue; - const recipePath = join(databaseDir, version.name, 'recipe.json'); + const recipePath = join(databaseDir, version.name, "recipe.json"); if (!existsSync(recipePath)) continue; - const recipe = JSON.parse(readFileSync(recipePath, 'utf8')); + const recipe = JSON.parse(readFileSync(recipePath, "utf8")); recipes.push({ ...recipe, directory: dirname(recipePath), recipePath }); } } - return recipes.sort((a, b) => `${a.database}/${a.displayVersion}`.localeCompare(`${b.database}/${b.displayVersion}`, 'en')); + return recipes.sort((a, b) => `${a.database}/${a.displayVersion}`.localeCompare(`${b.database}/${b.displayVersion}`, "en")); } export function recipeSelector(recipe) { @@ -64,37 +64,34 @@ export function recipeSelector(recipe) { } export function formatTable(headers, rows) { - const widths = headers.map((header, index) => Math.max( - header.length, - ...rows.map((row) => String(row[index] ?? '').length), - )); - const renderRow = (row) => row.map((value, index) => { - const text = String(value ?? ''); - return index === row.length - 1 ? text : text.padEnd(widths[index]); - }).join(' '); + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length))); + const renderRow = (row) => + row + .map((value, index) => { + const text = String(value ?? ""); + return index === row.length - 1 ? text : text.padEnd(widths[index]); + }) + .join(" "); - return [headers, ...rows].map(renderRow).join('\n'); + return [headers, ...rows].map(renderRow).join("\n"); } export function formatBorderedTable(headers, rows, groupByFirstColumn = false) { - const widths = headers.map((header, index) => Math.max( - header.length, - ...rows.map((row) => String(row[index] ?? '').length), - )); - const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`; - const renderRow = (row) => `| ${row.map((value, index) => String(value ?? '').padEnd(widths[index])).join(' | ')} |`; + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length))); + const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`; + const renderRow = (row) => `| ${row.map((value, index) => String(value ?? "").padEnd(widths[index])).join(" | ")} |`; const body = []; for (const [index, row] of rows.entries()) { body.push(renderRow(row)); - if (groupByFirstColumn && index < rows.length - 1 && rows[index + 1][0] !== '') body.push(border); + if (groupByFirstColumn && index < rows.length - 1 && rows[index + 1][0] !== "") body.push(border); } - return [border, renderRow(headers), border, ...body, border].join('\n'); + return [border, renderRow(headers), border, ...body, border].join("\n"); } export function discoverMakeTargets(makefilePath = DEFAULT_MAKEFILE_PATH) { if (!existsSync(makefilePath)) return []; - const source = readFileSync(makefilePath, 'utf8').replace(/\\\r?\n/g, ' '); + const source = readFileSync(makefilePath, "utf8").replace(/\\\r?\n/g, " "); const targets = []; for (const line of source.split(/\r?\n/)) { const match = line.match(/^\.PHONY:\s*(.+)$/); @@ -104,54 +101,54 @@ export function discoverMakeTargets(makefilePath = DEFAULT_MAKEFILE_PATH) { } export function platformForArchitecture(architecture = process.arch) { - if (architecture === 'x64') return 'linux/amd64'; - if (architecture === 'arm64') return 'linux/arm64'; + if (architecture === "x64") return "linux/amd64"; + if (architecture === "arm64") return "linux/arm64"; return null; } export function architectureWarning(recipe, architecture = process.arch) { const platform = platformForArchitecture(architecture); if (!platform || recipe.platforms?.includes(platform)) return null; - return `${recipe.image} does not support ${platform}. Docker must use ${recipe.platforms.join(', ')} emulation and startup may be slower.`; + return `${recipe.image} does not support ${platform}. Docker must use ${recipe.platforms.join(", ")} emulation and startup may be slower.`; } function printQuickStart(recipes) { - console.log('Database test environments'); - console.log('Copy one command to start an environment:'); - console.log(''); + console.log("Database test environments"); + console.log("Copy one command to start an environment:"); + console.log(""); for (const recipe of recipes) console.log(` make db DB=${recipeSelector(recipe)}`); - console.log(''); - console.log('Optional parameters:'); - console.log(' DB_BIND_ADDRESS= Override the default bind address (127.0.0.1).'); - console.log(' DB_PORT= Override the default host port.'); - console.log(' DB_PASSWORD= Override the default password (123456).'); - console.log(''); - console.log('Next steps: make db-verify DB=@, make db-down DB=@'); - console.log('Tab completion: make db-completion'); + console.log(""); + console.log("Optional parameters:"); + console.log(" DB_BIND_ADDRESS= Override the default bind address (127.0.0.1)."); + console.log(" DB_PORT= Override the default host port."); + console.log(" DB_PASSWORD= Override the default password (123456)."); + console.log(""); + console.log("Next steps: make db-verify DB=@, make db-down DB=@"); + console.log("Tab completion: make db-completion"); } function printCompletionSetup() { - console.log('Bash: source deploy/database/completion/dbx-make.bash'); - console.log('Zsh: autoload -Uz compinit && compinit && source deploy/database/completion/_dbx-make.zsh'); - console.log('PowerShell: . .\\deploy\\database\\completion\\Dbx.Make.ps1'); + console.log("Bash: source deploy/database/completion/dbx-make.bash"); + console.log("Zsh: autoload -Uz compinit && compinit && source deploy/database/completion/_dbx-make.zsh"); + console.log("PowerShell: . .\\deploy\\database\\completion\\Dbx.Make.ps1"); } export function resolveRecipe(recipes, database, version) { - if (!database) throw new Error('DB is required. Run `make db-list` to see available environments.'); + if (!database) throw new Error("DB is required. Run `make db-list` to see available environments."); const matches = recipes.filter((item) => item.database === database && (!version || item.displayVersion === version || item.version === version)); - if (matches.length === 0) throw new Error(`No database environment found for ${database}${version ? ` ${version}` : ''}.`); - if (matches.length > 1) throw new Error(`${database} has multiple versions (${matches.map((item) => item.displayVersion).join(', ')}). Use DB=${database}@ (or DB_VERSION).`); + if (matches.length === 0) throw new Error(`No database environment found for ${database}${version ? ` ${version}` : ""}.`); + if (matches.length > 1) throw new Error(`${database} has multiple versions (${matches.map((item) => item.displayVersion).join(", ")}). Use DB=${database}@ (or DB_VERSION).`); return matches[0]; } export function parseDatabaseSelection(selection, explicitVersion) { if (!selection) return { database: selection, version: explicitVersion }; - const selectorIndex = selection.lastIndexOf('@'); + const selectorIndex = selection.lastIndexOf("@"); if (selectorIndex === -1) return { database: selection, version: explicitVersion }; const database = selection.slice(0, selectorIndex); const selectorVersion = selection.slice(selectorIndex + 1); - if (!database || !selectorVersion) throw new Error('DB must use the format @, for example DB=mysql@8.4.'); + if (!database || !selectorVersion) throw new Error("DB must use the format @, for example DB=mysql@8.4."); if (explicitVersion && explicitVersion !== selectorVersion) { throw new Error(`DB version ${selectorVersion} conflicts with DB_VERSION=${explicitVersion}.`); } @@ -159,11 +156,11 @@ export function parseDatabaseSelection(selection, explicitVersion) { } export function assertResetConfirmed(value) { - if (value !== '1') throw new Error('Reset deletes the environment volume. Re-run with CONFIRM=1.'); + if (value !== "1") throw new Error("Reset deletes the environment volume. Re-run with CONFIRM=1."); } export function expectedContainerName(recipe) { - return `dbx-${recipe.database}-${recipe.displayVersion}`.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-'); + return `dbx-${recipe.database}-${recipe.displayVersion}`.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-"); } export function defaultHostPortMappings(compose) { @@ -172,48 +169,39 @@ export function defaultHostPortMappings(compose) { for (const line of compose.split(/\r?\n/)) { const match = line.match(pattern); if (!match) continue; - mappings.push({ environment: match[1], hostPort: Number(match[2]), containerPort: Number(match[3]), protocol: match[4] || 'tcp' }); + mappings.push({ environment: match[1], hostPort: Number(match[2]), containerPort: Number(match[3]), protocol: match[4] || "tcp" }); } return mappings; } export function recipePortMappings(recipe) { - const composePath = join(recipe.directory, 'compose.yaml'); - if (!existsSync(composePath)) return ''; - const mappings = defaultHostPortMappings(readFileSync(composePath, 'utf8')).sort((left, right) => ( - left.hostPort - right.hostPort - || left.containerPort - right.containerPort - || left.protocol.localeCompare(right.protocol) - )); - return mappings.map((mapping) => { - const sharesPortWithAnotherProtocol = mappings.some((candidate) => ( - candidate !== mapping - && candidate.containerPort === mapping.containerPort - && candidate.hostPort === mapping.hostPort - && candidate.protocol !== mapping.protocol - )); - const sourcePort = sharesPortWithAnotherProtocol - ? `${mapping.containerPort}/${mapping.protocol}` - : mapping.containerPort; - return `${sourcePort} -> ${mapping.hostPort}`; - }).join(', '); + const composePath = join(recipe.directory, "compose.yaml"); + if (!existsSync(composePath)) return ""; + const mappings = defaultHostPortMappings(readFileSync(composePath, "utf8")).sort((left, right) => left.hostPort - right.hostPort || left.containerPort - right.containerPort || left.protocol.localeCompare(right.protocol)); + return mappings + .map((mapping) => { + const sharesPortWithAnotherProtocol = mappings.some((candidate) => candidate !== mapping && candidate.containerPort === mapping.containerPort && candidate.hostPort === mapping.hostPort && candidate.protocol !== mapping.protocol); + const sourcePort = sharesPortWithAnotherProtocol ? `${mapping.containerPort}/${mapping.protocol}` : mapping.containerPort; + return `${sourcePort} -> ${mapping.hostPort}`; + }) + .join(", "); } export function databaseListRows(recipes) { let previousDatabase = null; return recipes.map((recipe) => { - const database = recipe.database === previousDatabase ? '' : recipe.name; + const database = recipe.database === previousDatabase ? "" : recipe.name; previousDatabase = recipe.database; - return [database, recipe.displayVersion, recipePortMappings(recipe), recipe.image, recipe.platforms.join(',')]; + return [database, recipe.displayVersion, recipePortMappings(recipe), recipe.image, recipe.platforms.join(",")]; }); } function validateHostPorts(recipe) { const errors = []; const hostPorts = recipe.hostPorts; - if (!hostPorts || typeof hostPorts !== 'object' || Array.isArray(hostPorts)) return ['hostPorts must be a non-empty object']; + if (!hostPorts || typeof hostPorts !== "object" || Array.isArray(hostPorts)) return ["hostPorts must be a non-empty object"]; const entries = Object.entries(hostPorts); - if (entries.length === 0) return ['hostPorts must be a non-empty object']; + if (entries.length === 0) return ["hostPorts must be a non-empty object"]; const [start, end] = DEFAULT_HOST_PORT_RANGES[recipe.database] ?? []; if (!start) { errors.push(`database ${recipe.database} has no allocated default host port range`); @@ -224,8 +212,8 @@ function validateHostPorts(recipe) { errors.push(`hostPorts.${environment} must be an integer in the ${start}-${end} range`); } } - if (new Set(entries.map(([, port]) => port)).size !== entries.length) errors.push('hostPorts must not reuse a port within a recipe'); - if (recipe.connection?.port !== hostPorts.DB_PORT) errors.push('connection.port must match hostPorts.DB_PORT'); + if (new Set(entries.map(([, port]) => port)).size !== entries.length) errors.push("hostPorts must not reuse a port within a recipe"); + if (recipe.connection?.port !== hostPorts.DB_PORT) errors.push("connection.port must match hostPorts.DB_PORT"); return errors; } @@ -246,46 +234,46 @@ export function validateHostPortAllocation(recipes) { export function validateRecipe(recipe) { const errors = []; - for (const field of ['database', 'version', 'displayVersion', 'service', 'connection', 'hostPorts', 'smoke', 'shell', 'defaultPort']) { + for (const field of ["database", "version", "displayVersion", "service", "connection", "hostPorts", "smoke", "shell", "defaultPort"]) { if (recipe[field] === undefined) errors.push(`missing ${field}`); } - if (!recipe.image || !/^[^\s]+:[^\s]+$/.test(recipe.image)) errors.push('image must be a pinned image reference'); - if (recipe.connection?.host !== '127.0.0.1') errors.push('connection.host must be 127.0.0.1'); + if (!recipe.image || !/^[^\s]+:[^\s]+$/.test(recipe.image)) errors.push("image must be a pinned image reference"); + if (recipe.connection?.host !== "127.0.0.1") errors.push("connection.host must be 127.0.0.1"); if (!Number.isInteger(recipe.defaultPort) || recipe.defaultPort < 1 || recipe.defaultPort > 65534) { - errors.push('defaultPort must be an integer between 1 and 65534'); + errors.push("defaultPort must be an integer between 1 and 65534"); } errors.push(...validateHostPorts(recipe)); - if (recipe.connection?.authentication === 'none') { - if (recipe.connection.password !== undefined) errors.push('unauthenticated recipes must not declare connection.password'); + if (recipe.connection?.authentication === "none") { + if (recipe.connection.password !== undefined) errors.push("unauthenticated recipes must not declare connection.password"); } else if (recipe.connection?.password !== DEFAULT_PASSWORD) errors.push(`connection.password must be ${DEFAULT_PASSWORD}`); - if (recipe.database === 'redis' ? recipe.connection?.database !== 0 : recipe.connection?.database !== DEFAULT_DATABASE) { - errors.push(`connection.database must be ${recipe.database === 'redis' ? '0 for Redis' : DEFAULT_DATABASE}`); + if (recipe.database === "redis" ? recipe.connection?.database !== 0 : recipe.connection?.database !== DEFAULT_DATABASE) { + errors.push(`connection.database must be ${recipe.database === "redis" ? "0 for Redis" : DEFAULT_DATABASE}`); } - if (!Array.isArray(recipe.platforms) || recipe.platforms.length === 0) errors.push('platforms must not be empty'); - else if (recipe.platforms.some((platform) => !['linux/amd64', 'linux/arm64'].includes(platform))) { - errors.push('platforms contains an unsupported platform'); + if (!Array.isArray(recipe.platforms) || recipe.platforms.length === 0) errors.push("platforms must not be empty"); + else if (recipe.platforms.some((platform) => !["linux/amd64", "linux/arm64"].includes(platform))) { + errors.push("platforms contains an unsupported platform"); } if (basename(recipe.directory) !== recipe.displayVersion || basename(dirname(recipe.directory)) !== recipe.database) { - errors.push('recipe directory must match database/displayVersion'); + errors.push("recipe directory must match database/displayVersion"); } - const composePath = join(recipe.directory, 'compose.yaml'); - if (!existsSync(composePath)) errors.push('missing compose.yaml'); + const composePath = join(recipe.directory, "compose.yaml"); + if (!existsSync(composePath)) errors.push("missing compose.yaml"); else { - const compose = readFileSync(composePath, 'utf8'); + const compose = readFileSync(composePath, "utf8"); if (!compose.includes(`container_name: ${expectedContainerName(recipe)}`)) { errors.push(`compose.yaml container_name must be ${expectedContainerName(recipe)}`); } - if (/image:\s*[^\n]*:latest(?:\s|$)/.test(compose)) errors.push('compose.yaml must not use latest'); - if (!/healthcheck\s*:/.test(compose)) errors.push('compose.yaml must define a healthcheck'); - if (!/^volumes\s*:/m.test(compose)) errors.push('compose.yaml must define a named volume'); - if (!compose.includes(`image: ${recipe.image}`)) errors.push('compose.yaml image must match recipe.image'); + if (/image:\s*[^\n]*:latest(?:\s|$)/.test(compose)) errors.push("compose.yaml must not use latest"); + if (!/healthcheck\s*:/.test(compose)) errors.push("compose.yaml must define a healthcheck"); + if (!/^volumes\s*:/m.test(compose)) errors.push("compose.yaml must define a named volume"); + if (!compose.includes(`image: ${recipe.image}`)) errors.push("compose.yaml image must match recipe.image"); if (recipe.platforms?.length === 1 && !compose.includes(`platform: ${recipe.platforms[0]}`)) { errors.push(`single-platform image must set compose platform to ${recipe.platforms[0]}`); } if (!allPortMappingsDefaultToLoopback(compose)) { - errors.push('compose.yaml ports must default every mapping to 127.0.0.1 via DB_BIND_ADDRESS'); + errors.push("compose.yaml ports must default every mapping to 127.0.0.1 via DB_BIND_ADDRESS"); } - if (!serviceHasNamedVolume(compose, recipe.service)) errors.push('target service must mount a named volume'); + if (!serviceHasNamedVolume(compose, recipe.service)) errors.push("target service must mount a named volume"); const portMappings = defaultHostPortMappings(compose); const mappedPorts = new Map(); for (const mapping of portMappings) { @@ -297,26 +285,26 @@ export function validateRecipe(recipe) { } const configuredPorts = Object.entries(recipe.hostPorts ?? {}); if (mappedPorts.size !== configuredPorts.length || configuredPorts.some(([environment, port]) => mappedPorts.get(environment) !== port)) { - errors.push('compose.yaml port defaults must match recipe hostPorts'); + errors.push("compose.yaml port defaults must match recipe hostPorts"); } } - const initDirectory = join(recipe.directory, 'init'); - if (!existsSync(initDirectory) || readdirSync(initDirectory).length === 0) errors.push('init directory must contain a file'); - if (!Array.isArray(recipe.smoke?.steps) || recipe.smoke.steps.length === 0) errors.push('smoke.steps must not be empty'); + const initDirectory = join(recipe.directory, "init"); + if (!existsSync(initDirectory) || readdirSync(initDirectory).length === 0) errors.push("init directory must contain a file"); + if (!Array.isArray(recipe.smoke?.steps) || recipe.smoke.steps.length === 0) errors.push("smoke.steps must not be empty"); for (const step of recipe.smoke?.steps ?? []) { - if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push('every smoke step needs a name and command'); - if (typeof step.expect !== 'string' || step.expect.length === 0) errors.push('every smoke step needs an expected output'); + if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push("every smoke step needs a name and command"); + if (typeof step.expect !== "string" || step.expect.length === 0) errors.push("every smoke step needs an expected output"); } - if (!Array.isArray(recipe.shell) || recipe.shell.length === 0) errors.push('shell must be a non-empty command array'); + if (!Array.isArray(recipe.shell) || recipe.shell.length === 0) errors.push("shell must be a non-empty command array"); if (recipe.bootstrap !== undefined) { - if (!recipe.bootstrap || typeof recipe.bootstrap !== 'object') errors.push('bootstrap must be an object'); - if (!recipe.bootstrap?.check || !Array.isArray(recipe.bootstrap.check.command) || recipe.bootstrap.check.command.length === 0 || typeof recipe.bootstrap.check.expect !== 'string' || recipe.bootstrap.check.expect.length === 0) { - errors.push('bootstrap.check needs a command and expected output'); + if (!recipe.bootstrap || typeof recipe.bootstrap !== "object") errors.push("bootstrap must be an object"); + if (!recipe.bootstrap?.check || !Array.isArray(recipe.bootstrap.check.command) || recipe.bootstrap.check.command.length === 0 || typeof recipe.bootstrap.check.expect !== "string" || recipe.bootstrap.check.expect.length === 0) { + errors.push("bootstrap.check needs a command and expected output"); } - if (!Array.isArray(recipe.bootstrap?.steps) || recipe.bootstrap.steps.length === 0) errors.push('bootstrap.steps must not be empty'); + if (!Array.isArray(recipe.bootstrap?.steps) || recipe.bootstrap.steps.length === 0) errors.push("bootstrap.steps must not be empty"); for (const step of recipe.bootstrap?.steps ?? []) { - if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push('every bootstrap step needs a name and command'); - if (typeof step.expect !== 'string' || step.expect.length === 0) errors.push('every bootstrap step needs an expected output'); + if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push("every bootstrap step needs a name and command"); + if (typeof step.expect !== "string" || step.expect.length === 0) errors.push("every bootstrap step needs an expected output"); } } return errors; @@ -328,7 +316,7 @@ function indentation(line) { function serviceBlock(compose, service) { const lines = compose.split(/\r?\n/); - const start = lines.findIndex((line) => new RegExp(`^\\s{2}${service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*$`).test(line)); + const start = lines.findIndex((line) => new RegExp(`^\\s{2}${service.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*$`).test(line)); if (start === -1) return []; const block = []; for (let index = start + 1; index < lines.length; index += 1) { @@ -351,7 +339,7 @@ export function allPortMappingsDefaultToLoopback(compose) { const inlineMappings = trimmed.match(/['\"]([^'\"]+)['\"]/g) ?? []; if (inlineMappings.length > 0) { foundMapping = true; - if (inlineMappings.some((mapping) => !mapping.slice(1, -1).startsWith('${DB_BIND_ADDRESS:-127.0.0.1}:'))) return false; + if (inlineMappings.some((mapping) => !mapping.slice(1, -1).startsWith("${DB_BIND_ADDRESS:-127.0.0.1}:"))) return false; } continue; } @@ -360,10 +348,13 @@ export function allPortMappingsDefaultToLoopback(compose) { portsIndent = null; continue; } - if (!trimmed.startsWith('-')) continue; + if (!trimmed.startsWith("-")) continue; foundMapping = true; - const mapping = trimmed.slice(1).trim().replace(/^['\"]|['\"]$/g, ''); - if (!mapping.startsWith('${DB_BIND_ADDRESS:-127.0.0.1}:')) return false; + const mapping = trimmed + .slice(1) + .trim() + .replace(/^['\"]|['\"]$/g, ""); + if (!mapping.startsWith("${DB_BIND_ADDRESS:-127.0.0.1}:")) return false; } return foundMapping; } @@ -383,36 +374,39 @@ export function serviceHasNamedVolume(compose, service) { volumesIndent = null; continue; } - if (!trimmed.startsWith('-')) continue; - const mount = trimmed.slice(1).trim().replace(/^['\"]|['\"]$/g, ''); - if (/^[A-Za-z0-9][A-Za-z0-9_.-]*:/.test(mount) && !mount.startsWith('./') && !mount.startsWith('../')) return true; + if (!trimmed.startsWith("-")) continue; + const mount = trimmed + .slice(1) + .trim() + .replace(/^['\"]|['\"]$/g, ""); + if (/^[A-Za-z0-9][A-Za-z0-9_.-]*:/.test(mount) && !mount.startsWith("./") && !mount.startsWith("../")) return true; } return false; } function composeArgs(recipe, ...args) { - const project = (process.env.DB_PROJECT || `dbx-${basename(REPO_ROOT)}-${recipe.database}-${recipe.displayVersion}`).toLowerCase().replace(/[^a-z0-9_-]+/g, '-'); - return ['compose', '--project-name', project, '--file', join(recipe.directory, 'compose.yaml'), ...args]; + const project = (process.env.DB_PROJECT || `dbx-${basename(REPO_ROOT)}-${recipe.database}-${recipe.displayVersion}`).toLowerCase().replace(/[^a-z0-9_-]+/g, "-"); + return ["compose", "--project-name", project, "--file", join(recipe.directory, "compose.yaml"), ...args]; } function run(command, args, options = {}) { - const result = spawnSync(command, args, { cwd: options.cwd ?? REPO_ROOT, env: process.env, encoding: 'utf8', stdio: options.capture ? 'pipe' : 'inherit' }); + const result = spawnSync(command, args, { cwd: options.cwd ?? REPO_ROOT, env: process.env, encoding: "utf8", stdio: options.capture ? "pipe" : "inherit" }); if (result.error) throw result.error; if (result.status !== 0) { - const detail = options.capture ? `\n${result.stderr || result.stdout}` : ''; - throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}.${detail}`); + const detail = options.capture ? `\n${result.stderr || result.stdout}` : ""; + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}.${detail}`); } - return options.capture ? `${result.stdout ?? ''}${result.stderr ?? ''}` : ''; + return options.capture ? `${result.stdout ?? ""}${result.stderr ?? ""}` : ""; } function runCompose(recipe, args, options) { - return run('docker', composeArgs(recipe, ...args), options); + return run("docker", composeArgs(recipe, ...args), options); } function tryRunCompose(recipe, args) { - const result = spawnSync('docker', composeArgs(recipe, ...args), { cwd: REPO_ROOT, env: process.env, encoding: 'utf8', stdio: 'pipe' }); + const result = spawnSync("docker", composeArgs(recipe, ...args), { cwd: REPO_ROOT, env: process.env, encoding: "utf8", stdio: "pipe" }); if (result.error) throw result.error; - return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}` }; + return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}` }; } export function expandSmokeCommand(command, recipe, environment = process.env) { @@ -435,9 +429,9 @@ export function dbxConnectionDeepLink(recipe, environment = process.env) { port: environment.DB_PORT || String(connection.port), database: String(connection.database), }); - if (connection.username) params.set('user', connection.username); - if (connection.password) params.set('password', environment.DB_PASSWORD || connection.password); - if (connection.urlParams) params.set('url_params', connection.urlParams); + if (connection.username) params.set("user", connection.username); + if (connection.password) params.set("password", environment.DB_PASSWORD || connection.password); + if (connection.urlParams) params.set("url_params", connection.urlParams); return `dbx://connection/new?${params}`; } @@ -445,12 +439,12 @@ function ensureBootstrap(recipe) { if (!recipe.bootstrap) return; // Keep one-shot setup synchronous after Compose health checks so --wait cannot return before credentials exist. const checkCommand = expandSmokeCommand(recipe.bootstrap.check.command, recipe); - const check = tryRunCompose(recipe, ['exec', '-T', recipe.service, ...checkCommand]); + const check = tryRunCompose(recipe, ["exec", "-T", recipe.service, ...checkCommand]); if (check.ok && check.output.includes(recipe.bootstrap.check.expect)) return; for (const step of recipe.bootstrap.steps) { const command = expandSmokeCommand(step.command, recipe); - const output = runCompose(recipe, ['exec', '-T', recipe.service, ...command], { capture: true }); + const output = runCompose(recipe, ["exec", "-T", recipe.service, ...command], { capture: true }); if (!output.includes(step.expect)) throw new Error(`Bootstrap check did not contain expected text: ${step.expect}\n${output}`); console.log(`OK ${step.name}`); } @@ -481,14 +475,14 @@ function checkRecipes(recipes) { const errors = validateRecipe(recipe); if (errors.length) { failed = true; - console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${errors.join('; ')}`); + console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${errors.join("; ")}`); continue; } - const result = spawnSync('docker', composeArgs(recipe, 'config', '--format', 'json'), { - encoding: 'utf8', - env: { ...process.env, DB_BIND_ADDRESS: '' }, + const result = spawnSync("docker", composeArgs(recipe, "config", "--format", "json"), { + encoding: "utf8", + env: { ...process.env, DB_BIND_ADDRESS: "" }, }); - if (result.error?.code === 'ENOENT') { + if (result.error?.code === "ENOENT") { console.log(`OK ${recipe.database}/${recipe.displayVersion} (static checks; Docker unavailable)`); } else if (result.status !== 0) { failed = true; @@ -503,90 +497,86 @@ function checkRecipes(recipes) { } } } - if (failed) throw new Error('Database environment checks failed.'); + if (failed) throw new Error("Database environment checks failed."); } export function validateRenderedCompose(recipe, rendered) { const service = rendered.services?.[recipe.service]; - if (!service) throw new Error('rendered Compose config is missing the target service'); + if (!service) throw new Error("rendered Compose config is missing the target service"); if (service.container_name !== expectedContainerName(recipe)) { throw new Error(`rendered target service container_name must be ${expectedContainerName(recipe)}`); } - if (!service.healthcheck?.test) throw new Error('rendered target service is missing a healthcheck'); + if (!service.healthcheck?.test) throw new Error("rendered target service is missing a healthcheck"); const ports = service.ports ?? []; - if (ports.length === 0 || ports.some((port) => typeof port !== 'object' || port.host_ip !== '127.0.0.1')) { - throw new Error('rendered target service has a port mapping that does not default to 127.0.0.1'); + if (ports.length === 0 || ports.some((port) => typeof port !== "object" || port.host_ip !== "127.0.0.1")) { + throw new Error("rendered target service has a port mapping that does not default to 127.0.0.1"); } const volumes = service.volumes ?? []; - if (!volumes.some((volume) => typeof volume === 'object' && volume.type === 'volume')) { - throw new Error('rendered target service does not mount a named volume'); + if (!volumes.some((volume) => typeof volume === "object" && volume.type === "volume")) { + throw new Error("rendered target service does not mount a named volume"); } } export function main(argv = process.argv.slice(2)) { - const [command = 'list', positionalDatabase, positionalVersion] = argv.filter((argument) => argument && argument !== '--'); + const [command = "list", positionalDatabase, positionalVersion] = argv.filter((argument) => argument && argument !== "--"); const recipes = discoverRecipes(); - if (command === 'list') { - console.log(formatBorderedTable( - ['DATABASE', 'VERSION', 'PORTS (CONTAINER -> HOST)', 'IMAGE', 'PLATFORMS'], - databaseListRows(recipes), - true, - )); + if (command === "list") { + console.log(formatBorderedTable(["DATABASE", "VERSION", "PORTS (CONTAINER -> HOST)", "IMAGE", "PLATFORMS"], databaseListRows(recipes), true)); return; } - if (command === 'quick-start') return printQuickStart(recipes); - if (command === 'completion') return printCompletionSetup(); - if (command === 'selectors') { + if (command === "quick-start") return printQuickStart(recipes); + if (command === "completion") return printCompletionSetup(); + if (command === "selectors") { for (const recipe of recipes) console.log(recipeSelector(recipe)); return; } - if (command === 'make-targets') { + if (command === "make-targets") { for (const target of discoverMakeTargets()) console.log(target); return; } - if (command === 'check') return checkRecipes(recipes); + if (command === "check") return checkRecipes(recipes); const { database, version } = parseDatabaseSelection(process.env.DB || positionalDatabase, process.env.DB_VERSION || positionalVersion); - if (command === 'start' && !database) return printQuickStart(recipes); + if (command === "start" && !database) return printQuickStart(recipes); const recipe = resolveRecipe(recipes, database, version); switch (command) { - case 'info': + case "info": printConnection(recipe); break; - case 'start': - case 'up': - runCompose(recipe, ['up', '-d', '--wait']); + case "start": + case "up": + runCompose(recipe, ["up", "-d", "--wait"]); ensureBootstrap(recipe); printConnection(recipe); break; - case 'status': - runCompose(recipe, ['ps']); + case "status": + runCompose(recipe, ["ps"]); break; - case 'logs': - runCompose(recipe, process.env.FOLLOW === '1' ? ['logs', '--follow'] : ['logs']); + case "logs": + runCompose(recipe, process.env.FOLLOW === "1" ? ["logs", "--follow"] : ["logs"]); break; - case 'shell': - runCompose(recipe, ['exec', recipe.service, ...expandSmokeCommand(recipe.shell, recipe)]); + case "shell": + runCompose(recipe, ["exec", recipe.service, ...expandSmokeCommand(recipe.shell, recipe)]); break; - case 'verify': - runCompose(recipe, ['up', '-d', '--wait']); + case "verify": + runCompose(recipe, ["up", "-d", "--wait"]); ensureBootstrap(recipe); for (const step of recipe.smoke.steps) { const command = expandSmokeCommand(step.command, recipe); - const output = runCompose(recipe, ['exec', '-T', recipe.service, ...command], { capture: true }); + const output = runCompose(recipe, ["exec", "-T", recipe.service, ...command], { capture: true }); if (step.expect && !output.includes(step.expect)) throw new Error(`Smoke check did not contain expected text: ${step.expect}\n${output}`); console.log(`OK ${step.name}`); } break; - case 'down': - runCompose(recipe, ['down', '--remove-orphans']); + case "down": + runCompose(recipe, ["down", "--remove-orphans"]); break; - case 'reset': + case "reset": assertResetConfirmed(process.env.CONFIRM); - runCompose(recipe, ['down', '--volumes', '--remove-orphans']); + runCompose(recipe, ["down", "--volumes", "--remove-orphans"]); break; default: throw new Error(`Unknown command: ${command}`); diff --git a/scripts/database-env.test.mjs b/scripts/database-env.test.mjs index 540518d838..85d989b9ab 100644 --- a/scripts/database-env.test.mjs +++ b/scripts/database-env.test.mjs @@ -1,115 +1,149 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawnSync } from 'node:child_process'; -import { DEFAULT_RECIPES_ROOT, allPortMappingsDefaultToLoopback, architectureWarning, assertResetConfirmed, databaseListRows, dbxConnectionDeepLink, defaultHostPortMappings, discoverMakeTargets, discoverRecipes, expandSmokeCommand, expectedContainerName, formatBorderedTable, formatTable, parseDatabaseSelection, platformForArchitecture, recipeSelector, resolveRecipe, serviceHasNamedVolume, validateHostPortAllocation, validateRecipe, validateRenderedCompose } from './database-env.mjs'; +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { + DEFAULT_RECIPES_ROOT, + allPortMappingsDefaultToLoopback, + architectureWarning, + assertResetConfirmed, + databaseListRows, + dbxConnectionDeepLink, + defaultHostPortMappings, + discoverMakeTargets, + discoverRecipes, + expandSmokeCommand, + expectedContainerName, + formatBorderedTable, + formatTable, + parseDatabaseSelection, + platformForArchitecture, + recipeSelector, + resolveRecipe, + serviceHasNamedVolume, + validateHostPortAllocation, + validateRecipe, + validateRenderedCompose, +} from "./database-env.mjs"; -const bashAvailable = spawnSync('bash', ['--version'], { stdio: 'ignore' }).status === 0; -const zshAvailable = spawnSync('zsh', ['--version'], { stdio: 'ignore' }).status === 0; -const dockerComposeAvailable = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' }).status === 0; +const bashAvailable = spawnSync("bash", ["--version"], { stdio: "ignore" }).status === 0; +const zshAvailable = spawnSync("zsh", ["--version"], { stdio: "ignore" }).status === 0; +const dockerComposeAvailable = spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0; function fixture(database, version) { const hostPort = { mysql: 10100, postgresql: 10300, redis: 10500 }[database] ?? 10100; - const root = mkdtempSync(join(tmpdir(), 'dbx-db-env-')); + const root = mkdtempSync(join(tmpdir(), "dbx-db-env-")); const directory = join(root, database, version); - mkdirSync(join(directory, 'init'), { recursive: true }); - writeFileSync(join(directory, 'init', '001-smoke.txt'), 'fixture initialization'); - writeFileSync(join(directory, 'recipe.json'), JSON.stringify({ database, version, displayVersion: version, name: database, image: 'test:1', platforms: ['linux/amd64'], service: 'database', defaultPort: 1234, connection: { host: '127.0.0.1', port: hostPort, password: '123456', database: database === 'redis' ? 0 : 'dbx' }, hostPorts: { DB_PORT: hostPort }, smoke: { steps: [{ name: 'smoke', command: ['true'], expect: 'true' }] }, shell: ['true'] })); - writeFileSync(join(directory, 'compose.yaml'), `services:\n database:\n image: test:1\n platform: linux/amd64\n container_name: dbx-${database}-${version}\n ports:\n - "\${DB_BIND_ADDRESS:-127.0.0.1}:\${DB_PORT:-${hostPort}}:1234"\n volumes:\n - data:/var/lib/database\n healthcheck:\n test: ["CMD", "true"]\nvolumes:\n data:\n`); + mkdirSync(join(directory, "init"), { recursive: true }); + writeFileSync(join(directory, "init", "001-smoke.txt"), "fixture initialization"); + writeFileSync( + join(directory, "recipe.json"), + JSON.stringify({ + database, + version, + displayVersion: version, + name: database, + image: "test:1", + platforms: ["linux/amd64"], + service: "database", + defaultPort: 1234, + connection: { host: "127.0.0.1", port: hostPort, password: "123456", database: database === "redis" ? 0 : "dbx" }, + hostPorts: { DB_PORT: hostPort }, + smoke: { steps: [{ name: "smoke", command: ["true"], expect: "true" }] }, + shell: ["true"], + }), + ); + writeFileSync( + join(directory, "compose.yaml"), + `services:\n database:\n image: test:1\n platform: linux/amd64\n container_name: dbx-${database}-${version}\n ports:\n - "\${DB_BIND_ADDRESS:-127.0.0.1}:\${DB_PORT:-${hostPort}}:1234"\n volumes:\n - data:/var/lib/database\n healthcheck:\n test: ["CMD", "true"]\nvolumes:\n data:\n`, + ); return root; } -test('discovers versioned recipes', () => { - const recipes = discoverRecipes(fixture('mysql', '5.7')); +test("discovers versioned recipes", () => { + const recipes = discoverRecipes(fixture("mysql", "5.7")); assert.equal(recipes.length, 1); - assert.equal(recipes[0].database, 'mysql'); + assert.equal(recipes[0].database, "mysql"); }); -test('requires DB and rejects ambiguous versions', () => { - assert.throws(() => resolveRecipe([], ''), /DB is required/); - const recipes = [{ database: 'mysql', displayVersion: '5.7' }, { database: 'mysql', displayVersion: '8.4' }]; - assert.throws(() => resolveRecipe(recipes, 'mysql'), /multiple versions/); - assert.equal(resolveRecipe(recipes, 'mysql', '8.4').displayVersion, '8.4'); +test("requires DB and rejects ambiguous versions", () => { + assert.throws(() => resolveRecipe([], ""), /DB is required/); + const recipes = [ + { database: "mysql", displayVersion: "5.7" }, + { database: "mysql", displayVersion: "8.4" }, + ]; + assert.throws(() => resolveRecipe(recipes, "mysql"), /multiple versions/); + assert.equal(resolveRecipe(recipes, "mysql", "8.4").displayVersion, "8.4"); }); -test('accepts the compact product@version database selector', () => { - assert.deepEqual(parseDatabaseSelection('mysql@8.4'), { database: 'mysql', version: '8.4' }); - assert.deepEqual(parseDatabaseSelection('mysql@8.4', '8.4'), { database: 'mysql', version: '8.4' }); - assert.throws(() => parseDatabaseSelection('mysql@8.4', '5.7'), /conflicts/); - assert.throws(() => parseDatabaseSelection('mysql@'), /format/); +test("accepts the compact product@version database selector", () => { + assert.deepEqual(parseDatabaseSelection("mysql@8.4"), { database: "mysql", version: "8.4" }); + assert.deepEqual(parseDatabaseSelection("mysql@8.4", "8.4"), { database: "mysql", version: "8.4" }); + assert.throws(() => parseDatabaseSelection("mysql@8.4", "5.7"), /conflicts/); + assert.throws(() => parseDatabaseSelection("mysql@"), /format/); }); -test('formats a recipe as a copyable compact selector', () => { - assert.equal(recipeSelector({ database: 'mysql', displayVersion: '8.4' }), 'mysql@8.4'); +test("formats a recipe as a copyable compact selector", () => { + assert.equal(recipeSelector({ database: "mysql", displayVersion: "8.4" }), "mysql@8.4"); }); -test('formats lists as left-aligned tables with dynamic column widths', () => { +test("formats lists as left-aligned tables with dynamic column widths", () => { assert.equal( formatTable( - ['DATABASE', 'VERSION', 'IMAGE'], + ["DATABASE", "VERSION", "IMAGE"], [ - ['etcd', '3.7', 'etcd:v3.7.0'], - ['clickhouse', '24.8', 'clickhouse-server:24.8.14.39'], + ["etcd", "3.7", "etcd:v3.7.0"], + ["clickhouse", "24.8", "clickhouse-server:24.8.14.39"], ], ), - 'DATABASE VERSION IMAGE\n' - + 'etcd 3.7 etcd:v3.7.0\n' - + 'clickhouse 24.8 clickhouse-server:24.8.14.39', + "DATABASE VERSION IMAGE\n" + "etcd 3.7 etcd:v3.7.0\n" + "clickhouse 24.8 clickhouse-server:24.8.14.39", ); }); -test('formats bordered tables with header and group separators', () => { +test("formats bordered tables with header and group separators", () => { assert.equal( formatBorderedTable( - ['DATABASE', 'VERSION'], - [['MySQL', '5.7'], ['', '8.4'], ['PostgreSQL', '17.4']], + ["DATABASE", "VERSION"], + [ + ["MySQL", "5.7"], + ["", "8.4"], + ["PostgreSQL", "17.4"], + ], true, ), - '+------------+---------+\n' - + '| DATABASE | VERSION |\n' - + '+------------+---------+\n' - + '| MySQL | 5.7 |\n' - + '| | 8.4 |\n' - + '+------------+---------+\n' - + '| PostgreSQL | 17.4 |\n' - + '+------------+---------+', + "+------------+---------+\n" + "| DATABASE | VERSION |\n" + "+------------+---------+\n" + "| MySQL | 5.7 |\n" + "| | 8.4 |\n" + "+------------+---------+\n" + "| PostgreSQL | 17.4 |\n" + "+------------+---------+", ); }); -test('groups database versions and shows every container-to-host port mapping', () => { +test("groups database versions and shows every container-to-host port mapping", () => { const rows = databaseListRows(discoverRecipes()); - const mysql57 = rows.find((row) => row[1] === '5.7'); - const mysql84 = rows.find((row) => row[1] === '8.4'); - const consul = rows.find((row) => row[0] === 'Consul'); + const mysql57 = rows.find((row) => row[1] === "5.7"); + const mysql84 = rows.find((row) => row[1] === "8.4"); + const consul = rows.find((row) => row[0] === "Consul"); - assert.deepEqual(mysql57.slice(0, 3), ['MySQL', '5.7', '3306 -> 10100']); - assert.deepEqual(mysql84.slice(0, 3), ['', '8.4', '3306 -> 10101']); - assert.equal(consul[2], '8500 -> 10900, 8502 -> 10901, 8600/tcp -> 10902, 8600/udp -> 10902'); + assert.deepEqual(mysql57.slice(0, 3), ["MySQL", "5.7", "3306 -> 10100"]); + assert.deepEqual(mysql84.slice(0, 3), ["", "8.4", "3306 -> 10101"]); + assert.equal(consul[2], "8500 -> 10900, 8502 -> 10901, 8600/tcp -> 10902, 8600/udp -> 10902"); }); -test('discovers all public Make targets for shell completion', () => { +test("discovers all public Make targets for shell completion", () => { const targets = discoverMakeTargets(); - for (const target of ['help', 'build', 'test', 'docs', 'db', 'db-verify', 'db-completion']) { + for (const target of ["help", "build", "test", "docs", "db", "db-verify", "db-completion"]) { assert.ok(targets.includes(target), `missing Make target: ${target}`); } assert.equal(new Set(targets).size, targets.length); }); -test('Bash completion preserves all Make targets and completes database selectors', { skip: !bashAvailable }, () => { - const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..'); - const completionScript = join(DEFAULT_RECIPES_ROOT, 'completion', 'dbx-make.bash'); - const result = spawnSync( - 'bash', - [ - '--noprofile', - '--norc', - '-c', - 'source "$COMPLETION_SCRIPT"; COMP_WORDS=(make ""); COMP_CWORD=1; _dbx_make; printf "TARGET:%s\\n" "${COMPREPLY[@]}"; COMP_WORDS=(make db DB=); COMP_CWORD=2; _dbx_make; printf "SELECTOR:%s\\n" "${COMPREPLY[@]}"', - ], - { cwd: repoRoot, env: { ...process.env, COMPLETION_SCRIPT: completionScript }, encoding: 'utf8' }, - ); +test("Bash completion preserves all Make targets and completes database selectors", { skip: !bashAvailable }, () => { + const repoRoot = join(DEFAULT_RECIPES_ROOT, "..", ".."); + const completionScript = join(DEFAULT_RECIPES_ROOT, "completion", "dbx-make.bash"); + const result = spawnSync("bash", ["--noprofile", "--norc", "-c", 'source "$COMPLETION_SCRIPT"; COMP_WORDS=(make ""); COMP_CWORD=1; _dbx_make; printf "TARGET:%s\\n" "${COMPREPLY[@]}"; COMP_WORDS=(make db DB=); COMP_CWORD=2; _dbx_make; printf "SELECTOR:%s\\n" "${COMPREPLY[@]}"'], { + cwd: repoRoot, + env: { ...process.env, COMPLETION_SCRIPT: completionScript }, + encoding: "utf8", + }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /^TARGET:build$/m); assert.match(result.stdout, /^TARGET:test$/m); @@ -117,164 +151,156 @@ test('Bash completion preserves all Make targets and completes database selector assert.match(result.stdout, /^SELECTOR:DB=mysql@8\.4$/m); }); -test('Bash completion delegates to the previous completer outside the repository', { skip: !bashAvailable }, () => { - const completionScript = join(DEFAULT_RECIPES_ROOT, 'completion', 'dbx-make.bash'); - const result = spawnSync( - 'bash', - [ - '--noprofile', - '--norc', - '-c', - '_original_make_completion() { COMPREPLY=(ORIGINAL); }; complete -F _original_make_completion make; source "$COMPLETION_SCRIPT"; cd "$TMPDIR"; COMP_WORDS=(make db DB=); COMP_CWORD=2; _dbx_make; printf "%s\\n" "${COMPREPLY[@]}"', - ], - { env: { ...process.env, COMPLETION_SCRIPT: completionScript, TMPDIR: tmpdir() }, encoding: 'utf8' }, - ); +test("Bash completion delegates to the previous completer outside the repository", { skip: !bashAvailable }, () => { + const completionScript = join(DEFAULT_RECIPES_ROOT, "completion", "dbx-make.bash"); + const result = spawnSync("bash", ["--noprofile", "--norc", "-c", '_original_make_completion() { COMPREPLY=(ORIGINAL); }; complete -F _original_make_completion make; source "$COMPLETION_SCRIPT"; cd "$TMPDIR"; COMP_WORDS=(make db DB=); COMP_CWORD=2; _dbx_make; printf "%s\\n" "${COMPREPLY[@]}"'], { + env: { ...process.env, COMPLETION_SCRIPT: completionScript, TMPDIR: tmpdir() }, + encoding: "utf8", + }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout.trim(), 'ORIGINAL'); + assert.equal(result.stdout.trim(), "ORIGINAL"); }); -test('Zsh completion delegates ordinary Make completion and handles DB selectors', { skip: !zshAvailable }, () => { - const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..'); - const completionScript = join(DEFAULT_RECIPES_ROOT, 'completion', '_dbx-make.zsh'); +test("Zsh completion delegates ordinary Make completion and handles DB selectors", { skip: !zshAvailable }, () => { + const repoRoot = join(DEFAULT_RECIPES_ROOT, "..", ".."); + const completionScript = join(DEFAULT_RECIPES_ROOT, "completion", "_dbx-make.zsh"); const result = spawnSync( - 'zsh', + "zsh", [ - '-f', - '-c', + "-f", + "-c", 'autoload -Uz compinit && compinit -D; _original_make_completion() { print ORIGINAL; }; compdef _original_make_completion make; source "$COMPLETION_SCRIPT"; words=(make ""); CURRENT=2; _dbx_make; _describe() { local values_name="$2"; print -l -- "${(@P)values_name}"; }; compset() { return 0; }; words=(make db DB=); CURRENT=3; _dbx_make', ], - { cwd: repoRoot, env: { ...process.env, COMPLETION_SCRIPT: completionScript }, encoding: 'utf8' }, + { cwd: repoRoot, env: { ...process.env, COMPLETION_SCRIPT: completionScript }, encoding: "utf8" }, ); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /^ORIGINAL$/m); assert.match(result.stdout, /^mysql@8\.4$/m); }); -test('records the MySQL 5.7 CNB mirror as a multi-architecture image', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'mysql@5.7'); +test("records the MySQL 5.7 CNB mirror as a multi-architecture image", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "mysql@5.7"); assert.ok(recipe); - assert.deepEqual(recipe.platforms, ['linux/amd64', 'linux/arm64']); + assert.deepEqual(recipe.platforms, ["linux/amd64", "linux/arm64"]); assert.equal(recipe.notes, undefined); }); -test('warns only when the current architecture is unsupported', () => { - const recipe = { image: 'mirror/redis:3', platforms: ['linux/amd64'] }; - assert.equal(platformForArchitecture('arm64'), 'linux/arm64'); - assert.equal(architectureWarning(recipe, 'x64'), null); - assert.match(architectureWarning(recipe, 'arm64'), /does not support linux\/arm64/); +test("warns only when the current architecture is unsupported", () => { + const recipe = { image: "mirror/redis:3", platforms: ["linux/amd64"] }; + assert.equal(platformForArchitecture("arm64"), "linux/arm64"); + assert.equal(architectureWarning(recipe, "x64"), null); + assert.match(architectureWarning(recipe, "arm64"), /does not support linux\/arm64/); }); -test('uses the approved CNB mirror images and matching recipe versions', () => { - const recipes = Object.fromEntries( - discoverRecipes().map((recipe) => [recipeSelector(recipe), { version: recipe.version, image: recipe.image, platforms: recipe.platforms }]), - ); +test("uses the approved CNB mirror images and matching recipe versions", () => { + const recipes = Object.fromEntries(discoverRecipes().map((recipe) => [recipeSelector(recipe), { version: recipe.version, image: recipe.image, platforms: recipe.platforms }])); assert.deepEqual(recipes, { - 'clickhouse@24.8': { version: '24.8.14.39', image: 'docker.cnb.cool/znb/images/clickhouse-server:24.8.14.39', platforms: ['linux/amd64', 'linux/arm64'] }, - 'consul@2.0.2': { version: '2.0.2', image: 'docker.cnb.cool/znb/images/consul:2.0.2', platforms: ['linux/amd64', 'linux/arm64'] }, - 'elasticsearch@6.8': { version: '6.8', image: 'docker.cnb.cool/znb/images/elasticsearch:6.8', platforms: ['linux/amd64', 'linux/arm64'] }, - 'etcd@3.5': { version: '3.5.21', image: 'quay.io/coreos/etcd:v3.5.21', platforms: ['linux/amd64', 'linux/arm64'] }, - 'etcd@3.7': { version: '3.7.0', image: 'docker.cnb.cool/znb/images/etcd:v3.7.0', platforms: ['linux/amd64', 'linux/arm64'] }, - 'kafka@4.3': { version: '4.3.1', image: 'docker.cnb.cool/znb/images/kafka:4.3.1', platforms: ['linux/amd64', 'linux/arm64'] }, - 'mariadb@10.11': { version: '10.11.11', image: 'docker.cnb.cool/znb/images/mariadb:10.11.11', platforms: ['linux/amd64', 'linux/arm64'] }, - 'mongodb@5.0': { version: '5.0.5', image: 'docker.cnb.cool/znb/images/mongo:5.0.5', platforms: ['linux/amd64', 'linux/arm64'] }, - 'mongodb@8.2': { version: '8.2.3', image: 'docker.cnb.cool/znb/images/mongo:8.2.3-noble', platforms: ['linux/amd64', 'linux/arm64'] }, - 'mysql@5.7': { version: '5.7.44', image: 'docker.cnb.cool/znb/images/mysql:5.7.44', platforms: ['linux/amd64', 'linux/arm64'] }, - 'mysql@8.4': { version: '8.4.6', image: 'docker.cnb.cool/znb/images/mysql:8.4.6', platforms: ['linux/amd64', 'linux/arm64'] }, - 'nacos@2.5': { version: '2.5.4', image: 'docker.cnb.cool/znb/images/nacos-server:v2.5.4', platforms: ['linux/amd64', 'linux/arm64'] }, - 'nacos@3.2': { version: '3.2.3', image: 'docker.cnb.cool/znb/images/nacos-server:v3.2.3', platforms: ['linux/amd64', 'linux/arm64'] }, - 'postgresql@14.23': { version: '14.23', image: 'docker.cnb.cool/znb/images/postgres:14.23', platforms: ['linux/amd64', 'linux/arm64'] }, - 'postgresql@17.4': { version: '17.4', image: 'docker.cnb.cool/znb/images/postgres:17.4', platforms: ['linux/amd64', 'linux/arm64'] }, - 'pulsar@4.2': { version: '4.2.3', image: 'docker.cnb.cool/znb/images/pulsar:4.2.3', platforms: ['linux/amd64', 'linux/arm64'] }, - 'qdrant@1.8': { version: '1.8.3', image: 'docker.cnb.cool/znb/images/qdrant:v1.8.3', platforms: ['linux/amd64', 'linux/arm64'] }, - 'redis@3.0.7': { version: '3.0.7', image: 'docker.cnb.cool/znb/images/redis:3.0.7-alpine', platforms: ['linux/amd64'] }, - 'redis@7.4': { version: '7.4.9', image: 'docker.cnb.cool/znb/images/redis:7.4.9-alpine', platforms: ['linux/amd64', 'linux/arm64'] }, - 'rnacos@0.8': { version: '0.8.7', image: 'docker.cnb.cool/znb/images/rnacos:v0.8.7', platforms: ['linux/amd64', 'linux/arm64'] }, - 'zookeeper@3.9': { version: '3.9.5', image: 'docker.cnb.cool/znb/images/zookeeper:3.9.5', platforms: ['linux/amd64', 'linux/arm64'] }, + "clickhouse@24.8": { version: "24.8.14.39", image: "docker.cnb.cool/znb/images/clickhouse-server:24.8.14.39", platforms: ["linux/amd64", "linux/arm64"] }, + "consul@2.0.2": { version: "2.0.2", image: "docker.cnb.cool/znb/images/consul:2.0.2", platforms: ["linux/amd64", "linux/arm64"] }, + "elasticsearch@6.8": { version: "6.8", image: "docker.cnb.cool/znb/images/elasticsearch:6.8", platforms: ["linux/amd64", "linux/arm64"] }, + "etcd@3.5": { version: "3.5.21", image: "quay.io/coreos/etcd:v3.5.21", platforms: ["linux/amd64", "linux/arm64"] }, + "etcd@3.7": { version: "3.7.0", image: "docker.cnb.cool/znb/images/etcd:v3.7.0", platforms: ["linux/amd64", "linux/arm64"] }, + "kafka@4.3": { version: "4.3.1", image: "docker.cnb.cool/znb/images/kafka:4.3.1", platforms: ["linux/amd64", "linux/arm64"] }, + "mariadb@10.11": { version: "10.11.11", image: "docker.cnb.cool/znb/images/mariadb:10.11.11", platforms: ["linux/amd64", "linux/arm64"] }, + "mongodb@5.0": { version: "5.0.5", image: "docker.cnb.cool/znb/images/mongo:5.0.5", platforms: ["linux/amd64", "linux/arm64"] }, + "mongodb@8.2": { version: "8.2.3", image: "docker.cnb.cool/znb/images/mongo:8.2.3-noble", platforms: ["linux/amd64", "linux/arm64"] }, + "mysql@5.7": { version: "5.7.44", image: "docker.cnb.cool/znb/images/mysql:5.7.44", platforms: ["linux/amd64", "linux/arm64"] }, + "mysql@8.4": { version: "8.4.6", image: "docker.cnb.cool/znb/images/mysql:8.4.6", platforms: ["linux/amd64", "linux/arm64"] }, + "nacos@2.5": { version: "2.5.4", image: "docker.cnb.cool/znb/images/nacos-server:v2.5.4", platforms: ["linux/amd64", "linux/arm64"] }, + "nacos@3.2": { version: "3.2.3", image: "docker.cnb.cool/znb/images/nacos-server:v3.2.3", platforms: ["linux/amd64", "linux/arm64"] }, + "postgresql@14.23": { version: "14.23", image: "docker.cnb.cool/znb/images/postgres:14.23", platforms: ["linux/amd64", "linux/arm64"] }, + "postgresql@17.4": { version: "17.4", image: "docker.cnb.cool/znb/images/postgres:17.4", platforms: ["linux/amd64", "linux/arm64"] }, + "pulsar@4.2": { version: "4.2.3", image: "docker.cnb.cool/znb/images/pulsar:4.2.3", platforms: ["linux/amd64", "linux/arm64"] }, + "qdrant@1.8": { version: "1.8.3", image: "docker.cnb.cool/znb/images/qdrant:v1.8.3", platforms: ["linux/amd64", "linux/arm64"] }, + "redis@3.0.7": { version: "3.0.7", image: "docker.cnb.cool/znb/images/redis:3.0.7-alpine", platforms: ["linux/amd64"] }, + "redis@7.4": { version: "7.4.9", image: "docker.cnb.cool/znb/images/redis:7.4.9-alpine", platforms: ["linux/amd64", "linux/arm64"] }, + "rnacos@0.8": { version: "0.8.7", image: "docker.cnb.cool/znb/images/rnacos:v0.8.7", platforms: ["linux/amd64", "linux/arm64"] }, + "zookeeper@3.9": { version: "3.9.5", image: "docker.cnb.cool/znb/images/zookeeper:3.9.5", platforms: ["linux/amd64", "linux/arm64"] }, }); - assert.equal(discoverRecipes().find((recipe) => recipeSelector(recipe) === 'redis@3.0.7').connection.username, undefined); + assert.equal(discoverRecipes().find((recipe) => recipeSelector(recipe) === "redis@3.0.7").connection.username, undefined); }); -test('initializes both Nacos versions with the shared administrator credentials', () => { - for (const recipe of discoverRecipes().filter((item) => item.database === 'nacos')) { - assert.equal(recipe.connection.username, 'nacos'); - assert.equal(recipe.connection.password, '123456'); - assert.equal(recipe.connection.namespace, 'public'); - assert.equal(recipe.bootstrap.check.expect, 'accessToken'); +test("initializes both Nacos versions with the shared administrator credentials", () => { + for (const recipe of discoverRecipes().filter((item) => item.database === "nacos")) { + assert.equal(recipe.connection.username, "nacos"); + assert.equal(recipe.connection.password, "123456"); + assert.equal(recipe.connection.namespace, "public"); + assert.equal(recipe.bootstrap.check.expect, "accessToken"); assert.equal(recipe.bootstrap.steps.length, 1); - assert.match(recipe.bootstrap.steps[0].command.join(' '), /administrator initialized/); + assert.match(recipe.bootstrap.steps[0].command.join(" "), /administrator initialized/); - const compose = readFileSync(join(recipe.directory, 'compose.yaml'), 'utf8'); + const compose = readFileSync(join(recipe.directory, "compose.yaml"), "utf8"); assert.doesNotMatch(compose, /^ initialize:/m); assert.doesNotMatch(compose, /tail -f \/dev\/null/); assert.deepEqual(validateRecipe(recipe), []); } }); -test('verifies Nacos 2.5 rejects unauthenticated configuration reads', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'nacos@2.5'); - const authenticationStep = recipe.smoke.steps.find((step) => step.name === 'reject unauthenticated configuration reads'); +test("verifies Nacos 2.5 rejects unauthenticated configuration reads", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "nacos@2.5"); + const authenticationStep = recipe.smoke.steps.find((step) => step.name === "reject unauthenticated configuration reads"); assert.ok(authenticationStep); - assert.match(authenticationStep.command.join(' '), /nacos\/v1\/cs\/configs/); - assert.match(authenticationStep.command.join(' '), /403/); - assert.equal(authenticationStep.expect, 'unauthenticated access rejected'); + assert.match(authenticationStep.command.join(" "), /nacos\/v1\/cs\/configs/); + assert.match(authenticationStep.command.join(" "), /403/); + assert.equal(authenticationStep.expect, "unauthenticated access rejected"); }); -test('configures r-nacos with its admin account and console port', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'rnacos@0.8'); - assert.equal(recipe.connection.username, 'admin'); - assert.equal(recipe.connection.password, '123456'); +test("configures r-nacos with its admin account and console port", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "rnacos@0.8"); + assert.equal(recipe.connection.username, "admin"); + assert.equal(recipe.connection.password, "123456"); assert.equal(recipe.connection.grpcPort, 11101); assert.equal(recipe.connection.consolePort, 11102); - const compose = readFileSync(join(recipe.directory, 'compose.yaml'), 'utf8'); + const compose = readFileSync(join(recipe.directory, "compose.yaml"), "utf8"); assert.match(compose, /RNACOS_CONSOLE_PORT/); assert.doesNotMatch(compose, /curl/); assert.deepEqual(validateRecipe(recipe), []); }); -test('configures Qdrant with the shared API key', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'qdrant@1.8'); - assert.equal(recipe.connection.username, ''); - assert.equal(recipe.connection.password, '123456'); +test("configures Qdrant with the shared API key", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "qdrant@1.8"); + assert.equal(recipe.connection.username, ""); + assert.equal(recipe.connection.password, "123456"); assert.equal(recipe.connection.apiKey, undefined); }); -test('bootstraps etcd with the shared root credentials', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'etcd@3.7'); - assert.equal(recipe.connection.username, 'root'); - assert.equal(recipe.connection.password, '123456'); +test("bootstraps etcd with the shared root credentials", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "etcd@3.7"); + assert.equal(recipe.connection.username, "root"); + assert.equal(recipe.connection.password, "123456"); assert.ok(recipe.bootstrap); }); -test('bootstraps ZooKeeper with the shared Digest credentials', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'zookeeper@3.9'); - assert.equal(recipe.connection.username, 'root'); - assert.equal(recipe.connection.password, '123456'); - assert.equal(recipe.connection.authScheme, 'digest'); +test("bootstraps ZooKeeper with the shared Digest credentials", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "zookeeper@3.9"); + assert.equal(recipe.connection.username, "root"); + assert.equal(recipe.connection.password, "123456"); + assert.equal(recipe.connection.authScheme, "digest"); }); -test('keeps existing authenticated bootstrap recipes valid', () => { - for (const selector of ['etcd@3.7', 'zookeeper@3.9']) { +test("keeps existing authenticated bootstrap recipes valid", () => { + for (const selector of ["etcd@3.7", "zookeeper@3.9"]) { const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector); assert.deepEqual(validateRecipe(recipe), []); } }); -test('marks the unauthenticated Kafka and Pulsar development recipes explicitly', () => { - for (const selector of ['kafka@4.3', 'pulsar@4.2']) { +test("marks the unauthenticated Kafka and Pulsar development recipes explicitly", () => { + for (const selector of ["kafka@4.3", "pulsar@4.2"]) { const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector); - assert.equal(recipe.connection.authentication, 'none'); + assert.equal(recipe.connection.authentication, "none"); assert.equal(recipe.connection.password, undefined); } }); -test('start without DB prints every copyable database command', () => { - const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..'); - const result = spawnSync(process.execPath, ['scripts/database-env.mjs', 'start'], { +test("start without DB prints every copyable database command", () => { + const repoRoot = join(DEFAULT_RECIPES_ROOT, "..", ".."); + const result = spawnSync(process.execPath, ["scripts/database-env.mjs", "start"], { cwd: repoRoot, - env: { ...process.env, DB: '', DB_VERSION: '' }, - encoding: 'utf8', + env: { ...process.env, DB: "", DB_VERSION: "" }, + encoding: "utf8", }); assert.equal(result.status, 0, result.stderr); for (const recipe of discoverRecipes()) { @@ -282,136 +308,139 @@ test('start without DB prints every copyable database command', () => { } }); -test('reset requires explicit confirmation', () => { - assert.throws(() => assertResetConfirmed(''), /CONFIRM=1/); - assert.doesNotThrow(() => assertResetConfirmed('1')); +test("reset requires explicit confirmation", () => { + assert.throws(() => assertResetConfirmed(""), /CONFIRM=1/); + assert.doesNotThrow(() => assertResetConfirmed("1")); }); -test('static validation accepts a minimal safe recipe', () => { - const [recipe] = discoverRecipes(fixture('redis', '7.4')); +test("static validation accepts a minimal safe recipe", () => { + const [recipe] = discoverRecipes(fixture("redis", "7.4")); assert.deepEqual(validateRecipe(recipe), []); }); -test('smoke commands use password and port overrides without invoking a shell', () => { - const recipe = { connection: { password: 'default-password', port: 3306 } }; - assert.deepEqual( - expandSmokeCommand(['client', '--password=${DB_PASSWORD}', '--port=${DB_PORT}'], recipe, { DB_PASSWORD: 'override', DB_PORT: '13306' }), - ['client', '--password=override', '--port=13306'], - ); +test("smoke commands use password and port overrides without invoking a shell", () => { + const recipe = { connection: { password: "default-password", port: 3306 } }; + assert.deepEqual(expandSmokeCommand(["client", "--password=${DB_PASSWORD}", "--port=${DB_PORT}"], recipe, { DB_PASSWORD: "override", DB_PORT: "13306" }), ["client", "--password=override", "--port=13306"]); }); -test('generates DBX deep links from effective recipe connection values', () => { - const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'postgresql@17.4'); +test("generates DBX deep links from effective recipe connection values", () => { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === "postgresql@17.4"); assert.ok(recipe); - const link = dbxConnectionDeepLink(recipe, { DB_PORT: '15433', DB_PASSWORD: 'p@ss & word' }); + const link = dbxConnectionDeepLink(recipe, { DB_PORT: "15433", DB_PASSWORD: "p@ss & word" }); assert.ok(link); const params = new URL(link).searchParams; - assert.equal(params.get('type'), 'postgres'); - assert.equal(params.get('name'), 'PostgreSQL 17.4 (local)'); - assert.equal(params.get('host'), '127.0.0.1'); - assert.equal(params.get('port'), '15433'); - assert.equal(params.get('user'), 'postgres'); - assert.equal(params.get('password'), 'p@ss & word'); - assert.equal(params.get('database'), 'dbx'); + assert.equal(params.get("type"), "postgres"); + assert.equal(params.get("name"), "PostgreSQL 17.4 (local)"); + assert.equal(params.get("host"), "127.0.0.1"); + assert.equal(params.get("port"), "15433"); + assert.equal(params.get("user"), "postgres"); + assert.equal(params.get("password"), "p@ss & word"); + assert.equal(params.get("database"), "dbx"); }); -test('prints DBX deep links for compatible connection types', () => { - const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..'); - const postgres = spawnSync(process.execPath, ['scripts/database-env.mjs', 'info', 'postgresql', '17.4'], { +test("prints DBX deep links for compatible connection types", () => { + const repoRoot = join(DEFAULT_RECIPES_ROOT, "..", ".."); + const postgres = spawnSync(process.execPath, ["scripts/database-env.mjs", "info", "postgresql", "17.4"], { cwd: repoRoot, - encoding: 'utf8', + encoding: "utf8", }); assert.equal(postgres.status, 0, postgres.stderr); assert.match(postgres.stdout, /DBX connection link: dbx:\/\/connection\/new\?type=postgres&/); - const elasticsearch = spawnSync(process.execPath, ['scripts/database-env.mjs', 'info', 'elasticsearch', '6.8'], { + const elasticsearch = spawnSync(process.execPath, ["scripts/database-env.mjs", "info", "elasticsearch", "6.8"], { cwd: repoRoot, - encoding: 'utf8', + encoding: "utf8", }); assert.equal(elasticsearch.status, 0, elasticsearch.stderr); assert.match(elasticsearch.stdout, /DBX connection link: dbx:\/\/connection\/new\?type=elasticsearch&/); - const consul = spawnSync(process.execPath, ['scripts/database-env.mjs', 'info', 'consul', '2.0.2'], { + const consul = spawnSync(process.execPath, ["scripts/database-env.mjs", "info", "consul", "2.0.2"], { cwd: repoRoot, - encoding: 'utf8', + encoding: "utf8", }); assert.equal(consul.status, 0, consul.stderr); assert.match(consul.stdout, /DBX connection link: dbx:\/\/connection\/new\?type=consul&/); }); -test('generates canonical service deep-link types from recipes', () => { +test("generates canonical service deep-link types from recipes", () => { const expected = { - 'etcd@3.7': 'etcd', - 'consul@2.0.2': 'consul', - 'nacos@2.5': 'nacos-v2', - 'nacos@3.2': 'nacos-v3', - 'rnacos@0.8': 'r-nacos', + "etcd@3.7": "etcd", + "consul@2.0.2": "consul", + "nacos@2.5": "nacos-v2", + "nacos@3.2": "nacos-v3", + "rnacos@0.8": "r-nacos", }; for (const [selector, type] of Object.entries(expected)) { const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector); assert.ok(recipe, `missing ${selector}`); const params = new URL(dbxConnectionDeepLink(recipe)).searchParams; - assert.equal(params.get('type'), type); - assert.equal(params.get('port'), String(recipe.connection.port)); + assert.equal(params.get("type"), type); + assert.equal(params.get("port"), String(recipe.connection.port)); } }); -test('Nacos recipes declare their version-specific deep-link type', () => { - const recipes = discoverRecipes().filter((item) => item.database === 'nacos'); - assert.deepEqual(recipes.map((recipe) => [recipe.displayVersion, recipe.deepLinkType]), [ - ['2.5', 'nacos-v2'], - ['3.2', 'nacos-v3'], - ]); - assert.notEqual(recipes.find((recipe) => recipe.displayVersion === '3.2').connection.port, recipes.find((recipe) => recipe.displayVersion === '3.2').connection.consolePort); +test("Nacos recipes declare their version-specific deep-link type", () => { + const recipes = discoverRecipes().filter((item) => item.database === "nacos"); + assert.deepEqual( + recipes.map((recipe) => [recipe.displayVersion, recipe.deepLinkType]), + [ + ["2.5", "nacos-v2"], + ["3.2", "nacos-v3"], + ], + ); + assert.notEqual(recipes.find((recipe) => recipe.displayVersion === "3.2").connection.port, recipes.find((recipe) => recipe.displayVersion === "3.2").connection.consolePort); }); -test('MongoDB commands pass reserved-character passwords as a distinct argument', () => { - const password = 'p@ss/word#with?reserved&characters'; - for (const recipe of discoverRecipes().filter((item) => item.database === 'mongodb')) { +test("MongoDB commands pass reserved-character passwords as a distinct argument", () => { + const password = "p@ss/word#with?reserved&characters"; + for (const recipe of discoverRecipes().filter((item) => item.database === "mongodb")) { for (const command of [recipe.shell, ...recipe.smoke.steps.map((step) => step.command)]) { const expanded = expandSmokeCommand(command, recipe, { DB_PASSWORD: password }); - const passwordIndex = expanded.indexOf('--password'); + const passwordIndex = expanded.indexOf("--password"); assert.notEqual(passwordIndex, -1); assert.equal(expanded[passwordIndex + 1], password); - assert.equal(expanded.some((argument) => argument.includes('mongodb://')), false); + assert.equal( + expanded.some((argument) => argument.includes("mongodb://")), + false, + ); } } }); -test('requires every port mapping to default to loopback with an explicit override', () => { +test("requires every port mapping to default to loopback with an explicit override", () => { assert.equal(allPortMappingsDefaultToLoopback('services:\n database:\n ports:\n - "127.0.0.1:3306:3306"'), false); assert.equal(allPortMappingsDefaultToLoopback('services:\n database:\n ports:\n - "${DB_BIND_ADDRESS:-127.0.0.1}:3306:3306"'), true); assert.equal(allPortMappingsDefaultToLoopback('services:\n database:\n ports:\n - "${DB_BIND_ADDRESS:-0.0.0.0}:3306:3306"'), false); }); -test('rendered Compose recipes bind to loopback by default', { skip: !dockerComposeAvailable }, () => { +test("rendered Compose recipes bind to loopback by default", { skip: !dockerComposeAvailable }, () => { const recipes = discoverRecipes(); for (const recipe of recipes) { - const result = spawnSync('docker', ['compose', '--file', join(recipe.directory, 'compose.yaml'), 'config', '--format', 'json'], { - encoding: 'utf8', - env: { ...process.env, DB_BIND_ADDRESS: '' }, + const result = spawnSync("docker", ["compose", "--file", join(recipe.directory, "compose.yaml"), "config", "--format", "json"], { + encoding: "utf8", + env: { ...process.env, DB_BIND_ADDRESS: "" }, }); assert.equal(result.status, 0, result.stderr); assert.doesNotThrow(() => validateRenderedCompose(recipe, JSON.parse(result.stdout))); } - const remoteResult = spawnSync('docker', ['compose', '--file', join(recipes[0].directory, 'compose.yaml'), 'config', '--format', 'json'], { - encoding: 'utf8', - env: { ...process.env, DB_BIND_ADDRESS: '0.0.0.0' }, + const remoteResult = spawnSync("docker", ["compose", "--file", join(recipes[0].directory, "compose.yaml"), "config", "--format", "json"], { + encoding: "utf8", + env: { ...process.env, DB_BIND_ADDRESS: "0.0.0.0" }, }); assert.equal(remoteResult.status, 0, remoteResult.stderr); const remoteService = JSON.parse(remoteResult.stdout).services[recipes[0].service]; - assert.ok(remoteService.ports.every((port) => port.host_ip === '0.0.0.0')); + assert.ok(remoteService.ports.every((port) => port.host_ip === "0.0.0.0")); }); -test('healthchecks safely reference configurable passwords', { skip: !dockerComposeAvailable }, () => { +test("healthchecks safely reference configurable passwords", { skip: !dockerComposeAvailable }, () => { const password = "space ' password"; - const recipes = discoverRecipes().filter((recipe) => ['mariadb', 'mongodb', 'mysql', 'redis'].includes(recipe.database)); + const recipes = discoverRecipes().filter((recipe) => ["mariadb", "mongodb", "mysql", "redis"].includes(recipe.database)); for (const recipe of recipes) { - const result = spawnSync('docker', ['compose', '--file', join(recipe.directory, 'compose.yaml'), 'config', '--format', 'json'], { - encoding: 'utf8', + const result = spawnSync("docker", ["compose", "--file", join(recipe.directory, "compose.yaml"), "config", "--format", "json"], { + encoding: "utf8", env: { ...process.env, DB_PASSWORD: password }, }); assert.equal(result.status, 0, result.stderr); @@ -420,101 +449,101 @@ test('healthchecks safely reference configurable passwords', { skip: !dockerComp const command = service.healthcheck.test[1]; assert.equal(command.includes(password), false, `${recipeSelector(recipe)} interpolates the password into CMD-SHELL`); - if (recipe.database === 'mongodb') assert.match(command, /--password "\$\$MONGO_INITDB_ROOT_PASSWORD"/); - if (recipe.database === 'mysql') assert.match(command, /-p"\$\$MYSQL_ROOT_PASSWORD"/); - if (recipe.database === 'mariadb') assert.match(command, /-p"\$\$MARIADB_ROOT_PASSWORD"/); - if (recipe.database === 'redis') { + if (recipe.database === "mongodb") assert.match(command, /--password "\$\$MONGO_INITDB_ROOT_PASSWORD"/); + if (recipe.database === "mysql") assert.match(command, /-p"\$\$MYSQL_ROOT_PASSWORD"/); + if (recipe.database === "mariadb") assert.match(command, /-p"\$\$MARIADB_ROOT_PASSWORD"/); + if (recipe.database === "redis") { assert.equal(service.environment.REDIS_PASSWORD, password); assert.match(command, /-a "\$\$REDIS_PASSWORD"/); } } }); -test('requires the target service to mount a named volume', () => { - const bindOnly = 'services:\n database:\n volumes:\n - ./init:/docker-entrypoint-initdb.d:ro\nvolumes:\n data:\n'; - const namedVolume = 'services:\n database:\n volumes:\n - data:/var/lib/database\nvolumes:\n data:\n'; - assert.equal(serviceHasNamedVolume(bindOnly, 'database'), false); - assert.equal(serviceHasNamedVolume(namedVolume, 'database'), true); +test("requires the target service to mount a named volume", () => { + const bindOnly = "services:\n database:\n volumes:\n - ./init:/docker-entrypoint-initdb.d:ro\nvolumes:\n data:\n"; + const namedVolume = "services:\n database:\n volumes:\n - data:/var/lib/database\nvolumes:\n data:\n"; + assert.equal(serviceHasNamedVolume(bindOnly, "database"), false); + assert.equal(serviceHasNamedVolume(namedVolume, "database"), true); }); -test('derives the required dbx-prefixed container name', () => { - assert.equal(expectedContainerName({ database: 'mysql', displayVersion: '5.7' }), 'dbx-mysql-5.7'); - assert.equal(expectedContainerName({ database: 'PostgreSQL', displayVersion: '17.4' }), 'dbx-postgresql-17.4'); +test("derives the required dbx-prefixed container name", () => { + assert.equal(expectedContainerName({ database: "mysql", displayVersion: "5.7" }), "dbx-mysql-5.7"); + assert.equal(expectedContainerName({ database: "PostgreSQL", displayVersion: "17.4" }), "dbx-postgresql-17.4"); }); -test('rejects a recipe whose Compose container name is not standardized', () => { - const [recipe] = discoverRecipes(fixture('mysql', '8.4')); - const composePath = join(recipe.directory, 'compose.yaml'); - writeFileSync(composePath, readFileSync(composePath, 'utf8').replace('container_name: dbx-mysql-8.4', 'container_name: custom-mysql')); - assert.match(validateRecipe(recipe).join('; '), /container_name must be dbx-mysql-8.4/); +test("rejects a recipe whose Compose container name is not standardized", () => { + const [recipe] = discoverRecipes(fixture("mysql", "8.4")); + const composePath = join(recipe.directory, "compose.yaml"); + writeFileSync(composePath, readFileSync(composePath, "utf8").replace("container_name: dbx-mysql-8.4", "container_name: custom-mysql")); + assert.match(validateRecipe(recipe).join("; "), /container_name must be dbx-mysql-8.4/); }); -test('requires the shared default password and database name', () => { - const [recipe] = discoverRecipes(fixture('postgresql', '17.4')); - recipe.connection.password = 'different'; - recipe.connection.database = 'other'; - assert.match(validateRecipe(recipe).join('; '), /connection.password must be 123456/); - assert.match(validateRecipe(recipe).join('; '), /connection.database must be dbx/); +test("requires the shared default password and database name", () => { + const [recipe] = discoverRecipes(fixture("postgresql", "17.4")); + recipe.connection.password = "different"; + recipe.connection.database = "other"; + assert.match(validateRecipe(recipe).join("; "), /connection.password must be 123456/); + assert.match(validateRecipe(recipe).join("; "), /connection.database must be dbx/); }); -test('allows recipes that explicitly declare no authentication', () => { - const [recipe] = discoverRecipes(fixture('postgresql', '17.4')); - recipe.connection.authentication = 'none'; +test("allows recipes that explicitly declare no authentication", () => { + const [recipe] = discoverRecipes(fixture("postgresql", "17.4")); + recipe.connection.authentication = "none"; delete recipe.connection.password; assert.deepEqual(validateRecipe(recipe), []); }); -test('requires the primary connection port to match the allocated DB_PORT', () => { - const [recipe] = discoverRecipes(fixture('postgresql', '17.4')); +test("requires the primary connection port to match the allocated DB_PORT", () => { + const [recipe] = discoverRecipes(fixture("postgresql", "17.4")); recipe.connection.port = 10301; - assert.match(validateRecipe(recipe).join('; '), /connection.port must match hostPorts.DB_PORT/); + assert.match(validateRecipe(recipe).join("; "), /connection.port must match hostPorts.DB_PORT/); }); -test('requires Compose port defaults to match the declared host-port allocation', () => { - const [recipe] = discoverRecipes(fixture('postgresql', '17.4')); - const composePath = join(recipe.directory, 'compose.yaml'); - writeFileSync(composePath, readFileSync(composePath, 'utf8').replace('DB_PORT:-10300', 'DB_PORT:-10301')); - assert.match(validateRecipe(recipe).join('; '), /compose.yaml port defaults must match recipe hostPorts/); +test("requires Compose port defaults to match the declared host-port allocation", () => { + const [recipe] = discoverRecipes(fixture("postgresql", "17.4")); + const composePath = join(recipe.directory, "compose.yaml"); + writeFileSync(composePath, readFileSync(composePath, "utf8").replace("DB_PORT:-10300", "DB_PORT:-10301")); + assert.match(validateRecipe(recipe).join("; "), /compose.yaml port defaults must match recipe hostPorts/); }); -test('allocates unique default host ports across checked-in recipes', () => { +test("allocates unique default host ports across checked-in recipes", () => { const recipes = discoverRecipes(); assert.deepEqual(validateHostPortAllocation(recipes), []); const duplicate = structuredClone(recipes[0]); duplicate.hostPorts = { DB_PORT: recipes[1].hostPorts.DB_PORT }; - assert.match(validateHostPortAllocation([recipes[1], duplicate]).join('; '), /host port .* is allocated to both/); + assert.match(validateHostPortAllocation([recipes[1], duplicate]).join("; "), /host port .* is allocated to both/); }); -test('parses every declared default host-port mapping, including shared UDP and TCP DNS', () => { - const consul = discoverRecipes().find((recipe) => recipeSelector(recipe) === 'consul@2.0.2'); +test("parses every declared default host-port mapping, including shared UDP and TCP DNS", () => { + const consul = discoverRecipes().find((recipe) => recipeSelector(recipe) === "consul@2.0.2"); assert.ok(consul); - const mappings = defaultHostPortMappings(readFileSync(join(consul.directory, 'compose.yaml'), 'utf8')); - assert.deepEqual(mappings.filter((mapping) => mapping.environment === 'CONSUL_DNS_PORT'), [ - { environment: 'CONSUL_DNS_PORT', hostPort: 10902, containerPort: 8600, protocol: 'tcp' }, - { environment: 'CONSUL_DNS_PORT', hostPort: 10902, containerPort: 8600, protocol: 'udp' }, - ]); + const mappings = defaultHostPortMappings(readFileSync(join(consul.directory, "compose.yaml"), "utf8")); + assert.deepEqual( + mappings.filter((mapping) => mapping.environment === "CONSUL_DNS_PORT"), + [ + { environment: "CONSUL_DNS_PORT", hostPort: 10902, containerPort: 8600, protocol: "tcp" }, + { environment: "CONSUL_DNS_PORT", hostPort: 10902, containerPort: 8600, protocol: "udp" }, + ], + ); }); -test('reports missing smoke structures without throwing a TypeError', () => { - const [recipe] = discoverRecipes(fixture('postgresql', '17.4')); +test("reports missing smoke structures without throwing a TypeError", () => { + const [recipe] = discoverRecipes(fixture("postgresql", "17.4")); delete recipe.smoke; let errors; assert.doesNotThrow(() => { errors = validateRecipe(recipe); }); - assert.match(errors.join('; '), /missing smoke/); - assert.match(errors.join('; '), /smoke\.steps must not be empty/); + assert.match(errors.join("; "), /missing smoke/); + assert.match(errors.join("; "), /smoke\.steps must not be empty/); recipe.smoke = {}; assert.doesNotThrow(() => validateRecipe(recipe)); - assert.match(validateRecipe(recipe).join('; '), /smoke\.steps must not be empty/); + assert.match(validateRecipe(recipe).join("; "), /smoke\.steps must not be empty/); }); -test('documents every checked-in recipe on both website pages', () => { - const pages = [ - join(DEFAULT_RECIPES_ROOT, '..', '..', 'docs', 'content', 'docs', 'database-lab.mdx'), - join(DEFAULT_RECIPES_ROOT, '..', '..', 'docs', 'content', 'docs', 'database-lab.cn.mdx'), - ].map((path) => readFileSync(path, 'utf8')); +test("documents every checked-in recipe on both website pages", () => { + const pages = [join(DEFAULT_RECIPES_ROOT, "..", "..", "docs", "content", "docs", "database-lab.mdx"), join(DEFAULT_RECIPES_ROOT, "..", "..", "docs", "content", "docs", "database-lab.cn.mdx")].map((path) => readFileSync(path, "utf8")); for (const recipe of discoverRecipes()) { const entry = `\`${recipe.database}/${recipe.displayVersion}\``; diff --git a/scripts/dev-backend.mjs b/scripts/dev-backend.mjs index 3c0d6250d2..5945da9b7e 100644 --- a/scripts/dev-backend.mjs +++ b/scripts/dev-backend.mjs @@ -8,9 +8,7 @@ const env = { const hasCargoWatch = await commandSucceeds("cargo", ["watch", "--version"]); if (!hasCargoWatch) { - console.warn( - "cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch", - ); + console.warn("cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch"); } const args = hasCargoWatch ? ["watch", "-x", "run -p dbx-web"] : ["run", "-p", "dbx-web"]; diff --git a/scripts/release.mjs b/scripts/release.mjs index 3f558ee344..e26d8a9d0e 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -3,13 +3,7 @@ import { readFileSync } from "node:fs"; import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { isDesktopVersionOnlyCargoLockChange } from "./release-lock.mjs"; -import { - evaluateAgentVersionBump, - getAgentVersionChanges, - isAgentPublishRelevantFile, - parseLegacyStandaloneProjects, - resolveAgentReleaseBaseline, -} from "../.github/scripts/bump-agent-versions.mjs"; +import { evaluateAgentVersionBump, getAgentVersionChanges, isAgentPublishRelevantFile, parseLegacyStandaloneProjects, resolveAgentReleaseBaseline } from "../.github/scripts/bump-agent-versions.mjs"; const REPO = "t8y2/dbx"; const PACKAGES_WORKFLOW = "mcp-release.yml"; @@ -54,15 +48,7 @@ const PACKAGE_RELEASE_PATHS = [ "Cargo.lock", ".github/workflows/mcp-release.yml", ]; -const AGENT_RELEASE_PATHS = [ - "agents/build.gradle", - "agents/settings.gradle", - "agents/versions.json", - "agents/common/build.gradle", - "agents/common/src/main/", - "agents/drivers/", - ".github/workflows/agents-release.yml", -]; +const AGENT_RELEASE_PATHS = ["agents/build.gradle", "agents/settings.gradle", "agents/versions.json", "agents/common/build.gradle", "agents/common/src/main/", "agents/drivers/", ".github/workflows/agents-release.yml"]; const args = process.argv.slice(2); let target = null; @@ -371,17 +357,9 @@ function validateRollbackRelease(rollbackRelease, latestRelease) { const version = formatVersion(rollbackVersion); // Rollback targets may predate the aarch64 -> arm64 dmg rename, so either // asset name satisfies the Apple Silicon requirement. - const requiredAssets = [ - ["latest.json"], - [`DBX_${version}_arm64.dmg`, `DBX_${version}_aarch64.dmg`], - [`DBX_${version}_x64.dmg`], - [`DBX_${version}_x64-setup.exe`], - [`DBX_${version}_arm64-setup.exe`], - ]; + const requiredAssets = [["latest.json"], [`DBX_${version}_arm64.dmg`, `DBX_${version}_aarch64.dmg`], [`DBX_${version}_x64.dmg`], [`DBX_${version}_x64-setup.exe`], [`DBX_${version}_arm64-setup.exe`]]; const assetNames = new Set(rollbackRelease.assets.map((asset) => asset.name)); - const missingAssets = requiredAssets - .filter((candidates) => !candidates.some((asset) => assetNames.has(asset))) - .map((candidates) => candidates.join(" or ")); + const missingAssets = requiredAssets.filter((candidates) => !candidates.some((asset) => assetNames.has(asset))).map((candidates) => candidates.join(" or ")); if (missingAssets.length > 0) { fail(`Rollback target ${rollbackRelease.tagName} is missing required distribution assets: ${missingAssets.join(", ")}`); } @@ -458,8 +436,8 @@ function getLatestAgentTag() { }); if (legacyRepoCheck.status === 0) { - const legacyTags = run("git", ["-C", legacyRepo, "tag", "--list", "v*"]).stdout - .split(/\r?\n/) + const legacyTags = run("git", ["-C", legacyRepo, "tag", "--list", "v*"]) + .stdout.split(/\r?\n/) .map((legacyTag) => legacyTag.trim()) .filter(Boolean) .map((legacyTag) => ({ legacyTag, version: parseVersion(legacyTag.replace(/^v/, "")) })) @@ -488,17 +466,13 @@ function getAgentReleaseStatus(latest = getLatestAgentTag()) { } const effectiveBaseline = resolveAgentReleaseBaseline({ prevTag: latest.tag }); - const changedFiles = effectiveBaseline.changedFiles - .filter((file) => AGENT_RELEASE_PATHS.some((path) => file === path || file.startsWith(path))) - .filter(isAgentPublishRelevantFile); + const changedFiles = effectiveBaseline.changedFiles.filter((file) => AGENT_RELEASE_PATHS.some((path) => file === path || file.startsWith(path))).filter(isAgentPublishRelevantFile); return { needed: changedFiles.length > 0, baseline: latest.tag, changedFiles, previousVersions: effectiveBaseline.versions, - versionBaseline: effectiveBaseline.syncCommit - ? `${effectiveBaseline.syncCommit.slice(0, 10)} (post-release version sync)` - : latest.tag, + versionBaseline: effectiveBaseline.syncCommit ? `${effectiveBaseline.syncCommit.slice(0, 10)} (post-release version sync)` : latest.tag, }; } @@ -548,8 +522,7 @@ function printAgentVersionChanges(status) { legacyStandaloneModules, manualVersionsChanged: status.changedFiles.includes("agents/versions.json"), }); - const changes = getAgentVersionChanges(status.previousVersions, result.versions) - .map(({ moduleName, previousVersion, nextVersion }) => `${moduleName}: ${previousVersion ?? "new"} -> ${nextVersion}`); + const changes = getAgentVersionChanges(status.previousVersions, result.versions).map(({ moduleName, previousVersion, nextVersion }) => `${moduleName}: ${previousVersion ?? "new"} -> ${nextVersion}`); if (changes.length === 0) { console.log(dim("No automatic driver version changes detected.")); @@ -571,8 +544,8 @@ function formatStatusSummary(status) { } function getLatestSemverTag(prefix) { - const tags = run("git", ["tag", "--list", `${prefix}*`]).stdout - .split(/\r?\n/) + const tags = run("git", ["tag", "--list", `${prefix}*`]) + .stdout.split(/\r?\n/) .map((tag) => tag.trim()) .filter(Boolean) .map((tag) => ({ tag, version: parseVersion(tag.replace(prefix, "")) })) @@ -632,7 +605,10 @@ function resolveReleaseVersion(bump, latestVersion, explicitTagPrefix) { } function normalizeExplicitVersion(value, explicitTagPrefix) { - const trimmed = value.trim().replace(new RegExp(`^${escapeRegExp(explicitTagPrefix)}`), "").replace(/^v/, ""); + const trimmed = value + .trim() + .replace(new RegExp(`^${escapeRegExp(explicitTagPrefix)}`), "") + .replace(/^v/, ""); const version = parseVersion(trimmed); return version ? formatVersion(version) : null; } @@ -684,14 +660,7 @@ function ensureGhWorkflowsReady(workflows) { } function fetchReleaseTags() { - run("git", [ - "fetch", - "--quiet", - "origin", - "+refs/tags/v*:refs/tags/v*", - "+refs/tags/packages-v*:refs/tags/packages-v*", - "+refs/tags/agents-v*:refs/tags/agents-v*", - ]); + run("git", ["fetch", "--quiet", "origin", "+refs/tags/v*:refs/tags/v*", "+refs/tags/packages-v*:refs/tags/packages-v*", "+refs/tags/agents-v*:refs/tags/agents-v*"]); } function run(command, commandArgs, options = {}) { diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 827f37249f..17cf4594bf 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -83,11 +83,7 @@ test("rollback dispatches each distribution workflow after validation", () => { assert.equal(result.status, 0, result.stderr); const commands = readFileSync(logPath, "utf8").trim().split("\n"); - assert.deepEqual(commands, [ - "workflow run publish-packages.yml --repo t8y2/dbx -f tag=v0.5.63 -f notify=false", - "workflow run sync-cnb-release-assets.yml --repo t8y2/dbx -f tag=v0.5.63", - "workflow run rollback-docker-latest.yml --repo t8y2/dbx -f tag=v0.5.63", - ]); + assert.deepEqual(commands, ["workflow run publish-packages.yml --repo t8y2/dbx -f tag=v0.5.63 -f notify=false", "workflow run sync-cnb-release-assets.yml --repo t8y2/dbx -f tag=v0.5.63", "workflow run rollback-docker-latest.yml --repo t8y2/dbx -f tag=v0.5.63"]); }); test("rollback rejects a target that is not older than latest", () => { @@ -185,10 +181,7 @@ test("Linux releases pin the Wayland-capable AppImage bundler before upload", () assert.ok(prepareStep < buildStep); assert.ok(buildStep < verifyStep); assert.match(workflow, /XDG_CACHE_HOME: \$\{\{ runner\.temp \}\}\/dbx-tauri-cache/); - assert.match( - workflow, - /LINUXDEPLOY_EXCLUDED_LIBRARIES: "libwayland-\*\.so\*;libxkbcommon\.so\*;libxcb-randr\.so\*;libxcb-render\.so\*;libxcb-shm\.so\*;libXau\.so\*;libXdmcp\.so\*"/, - ); + assert.match(workflow, /LINUXDEPLOY_EXCLUDED_LIBRARIES: "libwayland-\*\.so\*;libxkbcommon\.so\*;libxcb-randr\.so\*;libxcb-render\.so\*;libxcb-shm\.so\*;libXau\.so\*;libXdmcp\.so\*"/); assert.match(setup, /tauri_fix_commit=8e7028331ad37ac2db74d4ec20e66be5cacf2c40/); assert.match(setup, /linuxdeploy_revision=07333c6/); assert.match(setup, /36a2d7e274d12e1050d0e9ecfe11d339ed54720b2bec464c286d53f8b07f5c62/); @@ -209,19 +202,11 @@ test("AppImage GTK wrapper removes bundled display-stack libraries", () => { const nestedLibDir = join(libDir, "nested"); copyFileSync(appImageGtkWrapper, wrapper); - writeFileSync( - upstream, - '#!/usr/bin/env bash\nif [[ "$1" == "--plugin-api-version" ]]; then printf "0\\n"; fi\n', - ); + writeFileSync(upstream, '#!/usr/bin/env bash\nif [[ "$1" == "--plugin-api-version" ]]; then printf "0\\n"; fi\n'); chmodSync(wrapper, 0o755); chmodSync(upstream, 0o755); mkdirSync(nestedLibDir, { recursive: true }); - for (const path of [ - join(libDir, "libwayland-client.so.0"), - join(libDir, "libxkbcommon.so.0"), - join(nestedLibDir, "libXau.so.6"), - join(libDir, "libsafe.so.1"), - ]) { + for (const path of [join(libDir, "libwayland-client.so.0"), join(libDir, "libxkbcommon.so.0"), join(nestedLibDir, "libXau.so.6"), join(libDir, "libsafe.so.1")]) { writeFileSync(path, ""); } diff --git a/scripts/sync-connection-types.mjs b/scripts/sync-connection-types.mjs index 290b493cf9..cdb4be00ec 100644 --- a/scripts/sync-connection-types.mjs +++ b/scripts/sync-connection-types.mjs @@ -46,24 +46,7 @@ const profileCatalogKeys = new Set(["schemaVersion", "profiles"]); const connectionProfileKeys = new Set(["id", "dbType", "label", "pickerLabel", "icon", "pickerIcon", "port", "user", "host", "urlParams", "category"]); const driverProfileKeys = new Set(["profile", "agentKey", "packageKey", "label", "storeVisible", "storeOrder"]); const managedDriverKeys = new Set(["key", "label", "storeVisible", "storeOrder"]); -const capabilityKeys = new Set([ - "queryExecution", - "metadataBrowse", - "objectBrowser", - "objectSource", - "schemaSearch", - "diagram", - "tableDataEdit", - "tableStructureEdit", - "tableImport", - "dataTransfer", - "sqlFileExecution", - "databaseCreate", - "fieldLineage", - "sqlExplain", - "userAdmin", - "driverManagement", -]); +const capabilityKeys = new Set(["queryExecution", "metadataBrowse", "objectBrowser", "objectSource", "schemaSearch", "diagram", "tableDataEdit", "tableStructureEdit", "tableImport", "dataTransfer", "sqlFileExecution", "databaseCreate", "fieldLineage", "sqlExplain", "userAdmin", "driverManagement"]); const traitKeys = new Set(["schemaAware", "databaseSchemaQualified", "singleDatabase", "clearableQuerySchema", "fetchFirst", "treeSchema", "databaseObjectTree", "pgVacuum", "pgLikeStructure", "diagramSql"]); function connectionTypeFiles() { diff --git a/scripts/verify-cli-migration.mjs b/scripts/verify-cli-migration.mjs index 3226c5788d..05246b510c 100644 --- a/scripts/verify-cli-migration.mjs +++ b/scripts/verify-cli-migration.mjs @@ -130,9 +130,7 @@ assert.equal(csv.status, 0, csv.stderr); assert.match(csv.stdout, /^csv_value\n"a,b"$/); console.log("PASS csv escaping"); -const limited = jsonOutput( - run(rustBinary, ["query", connection, "select 1 as n union all select 2 union all select 3", "--limit", "2", "--json"]), -); +const limited = jsonOutput(run(rustBinary, ["query", connection, "select 1 as n union all select 2 union all select 3", "--limit", "2", "--json"])); assert.equal(limited.rows.length, 2); assert.equal(limited.row_count, 2); console.log("PASS query limit"); diff --git a/scripts/verify-nacos-replace-history.mjs b/scripts/verify-nacos-replace-history.mjs index a0da37b8ad..1fa5c2e685 100644 --- a/scripts/verify-nacos-replace-history.mjs +++ b/scripts/verify-nacos-replace-history.mjs @@ -18,12 +18,12 @@ const runId = randomUUID(); const group = `DBX_VERIFY_ONLY_${runId}`; const oldValue = `mysql-old-${runId}.invalid:3306`; const newValue = `mysql-new-${runId}.invalid:3306`; -const keys = ["one.yaml", "two.properties"].map(name => ({ namespace: "", group, dataId: `${runId}-${name}` })); +const keys = ["one.yaml", "two.properties"].map((name) => ({ namespace: "", group, dataId: `${runId}-${name}` })); const created = []; const outputDir = process.env.DBX_TEST_OUTPUT || path.resolve("outputs/nacos-history-verification"); await mkdir(outputDir, { recursive: true }); const metadata = JSON.parse(await readFile("node_modules/.vite/deps/_metadata.json", "utf8")); -const dependencyUrl = name => `/@fs/${path.resolve("node_modules/.vite/deps", metadata.optimized[name].file).replaceAll("\\", "/")}?v=${metadata.browserHash}`; +const dependencyUrl = (name) => `/@fs/${path.resolve("node_modules/.vite/deps", metadata.optimized[name].file).replaceAll("\\", "/")}?v=${metadata.browserHash}`; async function request(endpoint, body) { const response = await fetch(`${backend}${endpoint}`, body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : undefined); @@ -31,12 +31,14 @@ async function request(endpoint, body) { return response.json(); } function checksum() { - return execFileSync("docker", ["exec", "dbx-nacos-mysql", "sh", "-c", - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --batch --skip-column-names -e "SET SESSION group_concat_max_len=16777216; SELECT COUNT(*), MD5(GROUP_CONCAT(CONCAT(id, CHAR(58), MD5(content)) ORDER BY id)) FROM nacos.config_info;"', - ], { encoding: "utf8" }).trim(); + return execFileSync( + "docker", + ["exec", "dbx-nacos-mysql", "sh", "-c", 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --batch --skip-column-names -e "SET SESSION group_concat_max_len=16777216; SELECT COUNT(*), MD5(GROUP_CONCAT(CONCAT(id, CHAR(58), MD5(content)) ORDER BY id)) FROM nacos.config_info;"'], + { encoding: "utf8" }, + ).trim(); } const connections = await request("/api/connection/list"); -const connection = connections.find(item => item.id === connectionId); +const connection = connections.find((item) => item.id === connectionId); assert(connection?.db_type === "nacos"); const addr = new URL(connection.external_config.serverAddr); assert(["host.docker.internal", "127.0.0.1", "localhost", "database"].includes(addr.hostname), "Only local Docker Nacos is allowed"); @@ -51,11 +53,17 @@ try { const context = await browser.newContext({ locale: "zh-CN", viewport: { width: 1366, height: 900 } }); const page = await context.newPage(); const pageErrors = []; - page.on("pageerror", error => { pageErrors.push(error.message); console.error("Browser module error:", error.message); }); - page.on("requestfailed", req => console.error("Browser module request failed:", new URL(req.url()).pathname)); + page.on("pageerror", (error) => { + pageErrors.push(error.message); + console.error("Browser module error:", error.message); + }); + page.on("requestfailed", (req) => console.error("Browser module request failed:", new URL(req.url()).pathname)); // Mount the real component in an isolated harness without touching user tabs // or the normal app's saved connections, layout and editor state. - await page.route(`${frontend}/`, route => route.fulfill({ contentType: "text/html", body: `
` })); - await page.route("**/api/nacos/configs/publish", async route => { + `, + }), + ); + await page.route("**/api/nacos/configs/publish", async (route) => { const { req } = route.request().postDataJSON(); - assert(keys.some(key => req.group === key.group && req.dataId === key.dataId && !req.namespace), "Browser tried to publish outside the fixtures"); + assert( + keys.some((key) => req.group === key.group && req.dataId === key.dataId && !req.namespace), + "Browser tried to publish outside the fixtures", + ); assert([`url=${oldValue}\nreplica=${oldValue}`, `url=${newValue}\nreplica=${newValue}`].includes(req.content)); await route.continue(); }); @@ -94,21 +107,32 @@ try { await page.screenshot({ path: path.join(outputDir, "history-desktop.png") }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(outputDir, "history-mobile.png") }); - const overflow = await page.locator('[role="dialog"]').evaluate(element => element.scrollWidth > element.clientWidth + 2); + const overflow = await page.locator('[role="dialog"]').evaluate((element) => element.scrollWidth > element.clientWidth + 2); assert.equal(overflow, false, "History dialog has horizontal overflow on mobile"); await page.setViewportSize({ width: 1366, height: 900 }); - const secureStorage = await page.evaluate(async ({ connectionId, oldValue, newValue }) => { - const storage = await import('/src/lib/nacos/nacosReplaceHistoryStorage.ts'); - const entries = await storage.listNacosReplaceHistory(connectionId); - const db = await new Promise((resolve, reject) => { const req = indexedDB.open('dbx-nacos-replace-history', 1); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); - const get = req => new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); - const rows = await get(db.transaction('entries').objectStore('entries').getAll()); - const key = await get(db.transaction('keys').objectStore('keys').get('encryption-key')); - db.close(); - const raw = JSON.stringify(rows); - const foreign = await storage.getNacosReplaceHistory(entries[0].id, 'other-connection'); - return { count: entries.length, extractable: key.extractable, plaintext: raw.includes(oldValue) || raw.includes(newValue), foreign: !!foreign }; - }, { connectionId, oldValue, newValue }); + const secureStorage = await page.evaluate( + async ({ connectionId, oldValue, newValue }) => { + const storage = await import("/src/lib/nacos/nacosReplaceHistoryStorage.ts"); + const entries = await storage.listNacosReplaceHistory(connectionId); + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("dbx-nacos-replace-history", 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const get = (req) => + new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const rows = await get(db.transaction("entries").objectStore("entries").getAll()); + const key = await get(db.transaction("keys").objectStore("keys").get("encryption-key")); + db.close(); + const raw = JSON.stringify(rows); + const foreign = await storage.getNacosReplaceHistory(entries[0].id, "other-connection"); + return { count: entries.length, extractable: key.extractable, plaintext: raw.includes(oldValue) || raw.includes(newValue), foreign: !!foreign }; + }, + { connectionId, oldValue, newValue }, + ); assert.deepEqual(secureStorage, { count: 1, extractable: false, plaintext: false, foreign: false }); await page.getByTestId("nacos-replace-history-rollback").click(); await page.getByRole("button", { name: "回滚已替换配置", exact: true }).last().waitFor(); @@ -117,7 +141,8 @@ try { await page.getByRole("button", { name: "回滚已替换配置", exact: true }).last().click(); await page.getByText("回滚结果:已恢复 2 个,冲突 0 个,失败 0 个", { exact: true }).waitFor({ timeout: 60000 }); for (const key of keys) assert.equal((await request("/api/nacos/configs/get", { connectionId, key })).content, `url=${oldValue}\nreplica=${oldValue}`); - await page.reload(); await page.waitForFunction(() => window.testReady); + await page.reload(); + await page.waitForFunction(() => window.testReady); await page.getByTestId("nacos-replace-history-tab").click(); await page.getByText("回滚结果:已恢复 2 个,冲突 0 个,失败 0 个", { exact: true }).waitFor(); await page.getByTestId("nacos-replace-history-details").click(); diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index 317410a2f7..71244e6bbd 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -4,34 +4,21 @@ import { basename, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packageDirectory = resolve(process.argv[2] ?? "/tmp/dbx-pack-check"); -const expectedPackages = [ - "@dbx-app/cli", - "@dbx-app/mcp-server", - "@dbx-app/plugin-cli", -]; +const expectedPackages = ["@dbx-app/cli", "@dbx-app/mcp-server", "@dbx-app/plugin-cli"]; const tarballs = readdirSync(packageDirectory) .filter((file) => file.endsWith(".tgz")) .map((file) => join(packageDirectory, file)) .sort(); if (tarballs.length !== expectedPackages.length) { - throw new Error( - `Expected ${expectedPackages.length} package tarballs, found ${tarballs.length}: ${tarballs.map(basename).join(", ")}`, - ); + throw new Error(`Expected ${expectedPackages.length} package tarballs, found ${tarballs.length}: ${tarballs.map(basename).join(", ")}`); } const installDirectory = mkdtempSync(join(tmpdir(), "dbx-package-install-")); -writeFileSync( - join(installDirectory, "package.json"), - `${JSON.stringify({ name: "dbx-package-install-check", private: true }, null, 2)}\n`, -); +writeFileSync(join(installDirectory, "package.json"), `${JSON.stringify({ name: "dbx-package-install-check", private: true }, null, 2)}\n`); // Installing every release tarball together verifies workspace dependencies are publishable and semver-compatible. -const install = spawnSync( - "npm", - ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], - { cwd: installDirectory, stdio: "inherit" }, -); +const install = spawnSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], { cwd: installDirectory, stdio: "inherit" }); if (install.status !== 0) { process.exit(install.status ?? 1); } diff --git a/scripts/verify-plugin-cli-package.mjs b/scripts/verify-plugin-cli-package.mjs index a25e19bc2a..c3320519eb 100644 --- a/scripts/verify-plugin-cli-package.mjs +++ b/scripts/verify-plugin-cli-package.mjs @@ -1,16 +1,5 @@ import { spawnSync, spawn } from "node:child_process"; -import { - chmodSync, - copyFileSync, - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -36,17 +25,7 @@ const installDirectory = join(temporaryRoot, "install"); const stagedPlatformDirectory = join(temporaryRoot, platformPackage[0]); try { - run( - "cargo", - [ - "build", - "--locked", - "--release", - "--manifest-path", - join(repositoryRoot, "plugins/sdk/cli/Cargo.toml"), - ], - { env: { ...process.env, CARGO_TARGET_DIR: cargoTarget } }, - ); + run("cargo", ["build", "--locked", "--release", "--manifest-path", join(repositoryRoot, "plugins/sdk/cli/Cargo.toml")], { env: { ...process.env, CARGO_TARGET_DIR: cargoTarget } }); cpSync(join(repositoryRoot, "packages", platformPackage[0]), stagedPlatformDirectory, { recursive: true }); const stagedBinaryDirectory = join(stagedPlatformDirectory, "bin"); @@ -68,18 +47,10 @@ try { } mkdirSync(installDirectory, { recursive: true }); - writeFileSync( - join(installDirectory, "package.json"), - `${JSON.stringify({ name: "dbx-plugin-cli-package-smoke", private: true }, null, 2)}\n`, - ); + writeFileSync(join(installDirectory, "package.json"), `${JSON.stringify({ name: "dbx-plugin-cli-package-smoke", private: true }, null, 2)}\n`); run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], { cwd: installDirectory }); - const command = join( - installDirectory, - "node_modules", - ".bin", - process.platform === "win32" ? "dbx-plugin.cmd" : "dbx-plugin", - ); + const command = join(installDirectory, "node_modules", ".bin", process.platform === "win32" ? "dbx-plugin.cmd" : "dbx-plugin"); const version = run(command, ["--version"], { cwd: installDirectory, capture: true }); const expectedVersion = JSON.parse(readFileSync(join(repositoryRoot, "packages/plugin-cli/package.json"), "utf8")).version; if (version.stdout.trim() !== `dbx-plugin ${expectedVersion}`) { @@ -92,9 +63,7 @@ try { if (commandAvailable("go")) await verifyTemplate(command, installDirectory, "go"); } - const installedManifest = JSON.parse( - readFileSync(join(installDirectory, "node_modules/@dbx-app/plugin-cli/package.json"), "utf8"), - ); + const installedManifest = JSON.parse(readFileSync(join(installDirectory, "node_modules/@dbx-app/plugin-cli/package.json"), "utf8")); if (installedManifest.name !== "@dbx-app/plugin-cli") { throw new Error(`Installed unexpected npm package ${installedManifest.name}.`); } @@ -106,21 +75,7 @@ try { async function verifyTemplate(command, workingDirectory, template) { const project = join(workingDirectory, `${template}-plugin`); - run(command, [ - "create", - project, - "--template", - template, - "--yes", - "--id", - `com.example.npm-${template}`, - "--name", - `NPM ${template} smoke`, - "--publisher", - "example", - "--description", - `NPM ${template} package smoke`, - ]); + run(command, ["create", project, "--template", template, "--yes", "--id", `com.example.npm-${template}`, "--name", `NPM ${template} smoke`, "--publisher", "example", "--description", `NPM ${template} package smoke`]); run(command, ["package", project]); const packages = readdirSync(join(project, "dist")).filter((file) => file.endsWith(".dbxp")); if (packages.length !== 1 || !existsSync(join(project, "dist", packages[0]))) { @@ -130,53 +85,85 @@ async function verifyTemplate(command, workingDirectory, template) { } async function verifyDev(workingDirectory, project, template) { - const packageRoot = join(workingDirectory, 'node_modules/@dbx-app/plugin-cli'); - if (!existsSync(join(packageRoot, 'dev-runtime/runtime.mjs')) || !existsSync(join(packageRoot, 'dev-runtime/ui/index.html'))) throw new Error('Packed CLI has no dev runtime'); - const child = spawn(process.execPath, [join(packageRoot, 'bin/dbx-plugin.js'), 'dev', '--path', project, '--port', '0'], { - cwd: workingDirectory, env: { ...process.env, DBX_PLUGIN_DEV_RUNTIME: '', DBX_PLUGIN_NODE: '', DBX_PLUGIN_SDK_ROOT: '' }, - stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', + const packageRoot = join(workingDirectory, "node_modules/@dbx-app/plugin-cli"); + if (!existsSync(join(packageRoot, "dev-runtime/runtime.mjs")) || !existsSync(join(packageRoot, "dev-runtime/ui/index.html"))) throw new Error("Packed CLI has no dev runtime"); + const child = spawn(process.execPath, [join(packageRoot, "bin/dbx-plugin.js"), "dev", "--path", project, "--port", "0"], { + cwd: workingDirectory, + env: { ...process.env, DBX_PLUGIN_DEV_RUNTIME: "", DBX_PLUGIN_NODE: "", DBX_PLUGIN_SDK_ROOT: "" }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + let output = ""; + child.stderr.on("data", (chunk) => { + output = (output + chunk).slice(-64000); }); - let output = ''; - child.stderr.on('data', chunk => { output = (output + chunk).slice(-64000); }); try { const origin = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Installed dev runtime did not start')), 180000); - child.on('error', error => { clearTimeout(timer); reject(error); }); - child.on('exit', code => { clearTimeout(timer); reject(new Error(`Installed dev exited ${code}: ${output}`)); }); - child.stdout.on('data', chunk => { output = (output + chunk).slice(-64000); const url = /Plugin dev host: (http:\/\/127\.0\.0\.1:\d+)/.exec(output)?.[1]; if (url) { clearTimeout(timer); resolve(url); } }); + const timer = setTimeout(() => reject(new Error("Installed dev runtime did not start")), 180000); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Installed dev exited ${code}: ${output}`)); + }); + child.stdout.on("data", (chunk) => { + output = (output + chunk).slice(-64000); + const url = /Plugin dev host: (http:\/\/127\.0\.0\.1:\d+)/.exec(output)?.[1]; + if (url) { + clearTimeout(timer); + resolve(url); + } + }); }); - const bootstrap = await fetch(`${origin}/api/bootstrap`), boot = await bootstrap.json(); - const cookie = bootstrap.headers.get('set-cookie').split(';')[0]; + const bootstrap = await fetch(`${origin}/api/bootstrap`), + boot = await bootstrap.json(); + const cookie = bootstrap.headers.get("set-cookie").split(";")[0]; const post = async (path, body) => { - const response = await fetch(`${origin}/api/${path}`, { method: 'POST', headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json', 'X-Mock-Csrf': boot.csrf }, body: JSON.stringify(body) }); - const result = await response.json(); if (!response.ok) throw new Error(JSON.stringify(result)); return result.value; + const response = await fetch(`${origin}/api/${path}`, { method: "POST", headers: { Cookie: cookie, Origin: origin, "Content-Type": "application/json", "X-Mock-Csrf": boot.csrf }, body: JSON.stringify(body) }); + const result = await response.json(); + if (!response.ok) throw new Error(JSON.stringify(result)); + return result.value; }; - const workbench = boot.manifest.contributions.find(c => c.type === 'workbench'); + const workbench = boot.manifest.contributions.find((c) => c.type === "workbench"); let opened; - if (template === 'frontend') opened = await post('workbenches/open', { contributionId: workbench.id }); + if (template === "frontend") opened = await post("workbenches/open", { contributionId: workbench.id }); else { - const provider = boot.manifest.contributions.find(c => c.type === 'connection-provider'); - const record = { providerId: provider.id, values: Object.fromEntries(provider.fields.filter(f => f.default !== undefined).map(f => [f.key, f.default])) }; - await post('connections/test', record); - const saved = await post('connections/save', record); opened = await post('connections/connect', { id: saved.id }); + const provider = boot.manifest.contributions.find((c) => c.type === "connection-provider"); + const record = { providerId: provider.id, values: Object.fromEntries(provider.fields.filter((f) => f.default !== undefined).map((f) => [f.key, f.default])) }; + await post("connections/test", record); + const saved = await post("connections/save", record); + opened = await post("connections/connect", { id: saved.id }); } - const frame = await post('frame-document', { frameId: opened.frame.id }); - const call = (method, params) => post('bridge', { frameId: frame.id, channel: frame.channel, method, params }); - await call('host.getContext', {}); - if (template !== 'frontend') { - const method = readFileSync(join(project, 'ui/index.html'), 'utf8').match(/\.invoke\("([^\"]+)"/)?.[1]; - if (!method) throw new Error('Native template has no invoke example'); - const result = await call('backend.invoke', { method, params: { connectionId: frame.context.connectionId } }); - if (result.language !== template || !result.ok) throw new Error('Native dev RPC returned unexpected result'); + const frame = await post("frame-document", { frameId: opened.frame.id }); + const call = (method, params) => post("bridge", { frameId: frame.id, channel: frame.channel, method, params }); + await call("host.getContext", {}); + if (template !== "frontend") { + const method = readFileSync(join(project, "ui/index.html"), "utf8").match(/\.invoke\("([^\"]+)"/)?.[1]; + if (!method) throw new Error("Native template has no invoke example"); + const result = await call("backend.invoke", { method, params: { connectionId: frame.context.connectionId } }); + if (result.language !== template || !result.ok) throw new Error("Native dev RPC returned unexpected result"); } - await post('frames/close', { id: frame.id }); + await post("frames/close", { id: frame.id }); console.log(`Verified installed ${template} dev runtime without source-tree dependencies.`); } finally { if (child.exitCode === null && !child.signalCode) { - await new Promise(resolve => { - const timer = setTimeout(() => { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']); else { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }, 7000); - child.once('close', () => { clearTimeout(timer); resolve(); }); - if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']); else process.kill(-child.pid, 'SIGTERM'); + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"]); + else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } + }, 7000); + child.once("close", () => { + clearTimeout(timer); + resolve(); + }); + if (process.platform === "win32") spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"]); + else process.kill(-child.pid, "SIGTERM"); }); } } diff --git a/scripts/windows-installer.integration.mjs b/scripts/windows-installer.integration.mjs index e1296043e5..87d6885044 100644 --- a/scripts/windows-installer.integration.mjs +++ b/scripts/windows-installer.integration.mjs @@ -3,59 +3,62 @@ // Run: node scripts/windows-installer.integration.mjs // Uses real runas elevation; approve UAC if Windows prompts. The full production // installer template installs a disposable test app, never the user's DBX. -import fs from 'node:fs'; -import path from 'node:path'; -import {execFileSync, spawn} from 'node:child_process'; -import assert from 'node:assert/strict'; -import {createRequire} from 'node:module'; -import {fileURLToPath} from 'node:url'; -assert.equal(process.platform, 'win32', 'This integration test requires Windows'); -assert.ok(process.argv[2], 'Pass an isolated fixture directory containing handlebars'); +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync, spawn } from "node:child_process"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +assert.equal(process.platform, "win32", "This integration test requires Windows"); +assert.ok(process.argv[2], "Pass an isolated fixture directory containing handlebars"); const root = path.resolve(process.argv[2]); -const hb = createRequire(path.join(root, 'package.json'))('handlebars'); -const repo = fileURLToPath(new URL('..', import.meta.url)); -const compiler = path.join(process.env.LOCALAPPDATA, 'tauri/NSIS/makensis.exe'); +const hb = createRequire(path.join(root, "package.json"))("handlebars"); +const repo = fileURLToPath(new URL("..", import.meta.url)); +const compiler = path.join(process.env.LOCALAPPDATA, "tauri/NSIS/makensis.exe"); const product = `DBX Issue10895 Test ${Date.now()}`; -const manufacturer = 'DBXInstallerIntegrationTest'; -const target = path.join(root, 'protected DBX folder'); -const report = path.join(root, 'report.ini'); -const mainName = 'dbx-10895-test-app'; +const manufacturer = "DBXInstallerIntegrationTest"; +const target = path.join(root, "protected DBX folder"); +const report = path.join(root, "report.ini"); +const mainName = "dbx-10895-test-app"; const registry = `Software\\${manufacturer}\\${product}`; const uninstallRegistry = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${product}`; -const ref = '9b17a7aeae9a83222ffe829aa4e2d8a5ba6bed8c'; +const ref = "9b17a7aeae9a83222ffe829aa4e2d8a5ba6bed8c"; // Refuse to overwrite existing fixture installs. Cleanup only these exact paths // and the unique test registry keys, leaving source files and reports for review. -assert.equal(fs.existsSync(target), false, 'Fixture destination already exists'); -assert.equal(fs.existsSync(path.join(root, 'writable DBX folder')), false, 'Writable fixture destination already exists'); -fs.rmSync(report, {force:true}); +assert.equal(fs.existsSync(target), false, "Fixture destination already exists"); +assert.equal(fs.existsSync(path.join(root, "writable DBX folder")), false, "Writable fixture destination already exists"); +fs.rmSync(report, { force: true }); function compile(name, source) { const file = path.join(root, `${name}.nsi`); fs.writeFileSync(file, source); - const out = execFileSync(compiler, ['/V1', '/INPUTCHARSET', 'UTF8', file], {cwd:root, encoding:'utf8', timeout:30000}); - if(out.trim()) console.log(out.trim()); + const out = execFileSync(compiler, ["/V1", "/INPUTCHARSET", "UTF8", file], { cwd: root, encoding: "utf8", timeout: 30000 }); + if (out.trim()) console.log(out.trim()); } function run(name, args = []) { const exe = path.join(root, `${name}.exe`); - execFileSync(exe,args,{cwd:root,argv0:`"${exe}"`,windowsVerbatimArguments:true,timeout:30000}); + execFileSync(exe, args, { cwd: root, argv0: `"${exe}"`, windowsVerbatimArguments: true, timeout: 30000 }); } -function base(name, level='user') { +function base(name, level = "user") { return `Unicode true\n!include MUI2.nsh\n!include FileFunc.nsh\nName "${name}"\nOutFile "${root}\\${name}.exe"\nRequestExecutionLevel ${level}\nSilentInstall silent\n`; } async function waitFor(check) { - const end = Date.now()+30000; - while(Date.now()setTimeout(r,200)); + const end = Date.now() + 30000; + while (Date.now() < end) { + if (check()) return; + await new Promise((r) => setTimeout(r, 200)); } - throw new Error('Timed out waiting for elevated installer: '+(fs.existsSync(report)?fs.readFileSync(report,'utf8'):'')); + throw new Error("Timed out waiting for elevated installer: " + (fs.existsSync(report) ? fs.readFileSync(report, "utf8") : "")); } async function main() { - for(const file of ['utils.nsh','FileAssociation.nsh','languages/English.nsh','languages/SimpChinese.nsh','languages/TradChinese.nsh']) { - const data=JSON.parse(execFileSync('gh',['api',`repos/tauri-apps/tauri/contents/crates/tauri-bundler/src/bundle/windows/nsis/${file}?ref=${ref}`],{encoding:'utf8'})); - fs.writeFileSync(path.join(root,path.basename(file)),Buffer.from(data.content,'base64')); + for (const file of ["utils.nsh", "FileAssociation.nsh", "languages/English.nsh", "languages/SimpChinese.nsh", "languages/TradChinese.nsh"]) { + const data = JSON.parse(execFileSync("gh", ["api", `repos/tauri-apps/tauri/contents/crates/tauri-bundler/src/bundle/windows/nsis/${file}?ref=${ref}`], { encoding: "utf8" })); + fs.writeFileSync(path.join(root, path.basename(file)), Buffer.from(data.content, "base64")); } - for(const version of ['old','new']) { - compile(`${mainName}-${version}`,base(`${mainName}-${version}`)+` + for (const version of ["old", "new"]) { + compile( + `${mainName}-${version}`, + base(`${mainName}-${version}`) + + ` Function .onInit WriteINIStr "${report}" "app" "version" "${version}" WriteINIStr "${report}" "app" "cmdline" "$CMDLINE" @@ -71,11 +74,14 @@ Function .onInit FunctionEnd Section SectionEnd -`); +`, + ); } - fs.copyFileSync(path.join(root,`${mainName}-new.exe`),path.join(root,`${mainName}.exe`)); - fs.writeFileSync(path.join(root,'payload.txt'),'updated resource payload'); - fs.writeFileSync(path.join(root,'hooks.nsh'),` + fs.copyFileSync(path.join(root, `${mainName}-new.exe`), path.join(root, `${mainName}.exe`)); + fs.writeFileSync(path.join(root, "payload.txt"), "updated resource payload"); + fs.writeFileSync( + path.join(root, "hooks.nsh"), + ` !macro NSIS_HOOK_PREINSTALL System::Call 'shell32::IsUserAnAdmin() i .r0' WriteINIStr "${report}" "installer" "admin" "$0" @@ -89,12 +95,39 @@ SectionEnd !macro NSIS_HOOK_POSTINSTALL WriteINIStr "${report}" "installer" "complete" "1" !macroend -`); - const data={compression:'lzma',installer_hooks:path.join(root,'hooks.nsh'),manufacturer,product_name:product,version:'1.0.1',version_with_build:'1.0.1.0',install_mode:'currentUser',main_binary_name:mainName,main_binary_path:path.join(root,`${mainName}.exe`),bundle_id:'com.dbx.integration10895',copyright:'DBX installer test',out_file:path.join(root,'installer.exe'),arch:'x64',additional_plugins_path:path.join(process.env.LOCALAPPDATA,'tauri/NSIS/Plugins/x86-unicode/additional'),allow_downgrades:'true',display_language_selector:'false',install_webview2_mode:'',estimated_size:1024,start_menu_folder:'',languages:['English','SimpChinese','TradChinese'],language_files:['English.nsh','SimpChinese.nsh','TradChinese.nsh'],resources:{[path.join(root,'payload.txt')]:[path.join(root,'payload.txt'),'payload.txt']}}; - hb.registerHelper('no-escape',value=>new hb.SafeString(value)); - const template = fs.readFileSync(path.join(repo,'src-tauri/windows/nsis/installer.nsi'),'utf8'); - compile('installer',hb.compile(template,{noEscape:true})(data)); - compile('seed',base('seed','admin')+` +`, + ); + const data = { + compression: "lzma", + installer_hooks: path.join(root, "hooks.nsh"), + manufacturer, + product_name: product, + version: "1.0.1", + version_with_build: "1.0.1.0", + install_mode: "currentUser", + main_binary_name: mainName, + main_binary_path: path.join(root, `${mainName}.exe`), + bundle_id: "com.dbx.integration10895", + copyright: "DBX installer test", + out_file: path.join(root, "installer.exe"), + arch: "x64", + additional_plugins_path: path.join(process.env.LOCALAPPDATA, "tauri/NSIS/Plugins/x86-unicode/additional"), + allow_downgrades: "true", + display_language_selector: "false", + install_webview2_mode: "", + estimated_size: 1024, + start_menu_folder: "", + languages: ["English", "SimpChinese", "TradChinese"], + language_files: ["English.nsh", "SimpChinese.nsh", "TradChinese.nsh"], + resources: { [path.join(root, "payload.txt")]: [path.join(root, "payload.txt"), "payload.txt"] }, + }; + hb.registerHelper("no-escape", (value) => new hb.SafeString(value)); + const template = fs.readFileSync(path.join(repo, "src-tauri/windows/nsis/installer.nsi"), "utf8"); + compile("installer", hb.compile(template, { noEscape: true })(data)); + compile( + "seed", + base("seed", "admin") + + ` Section CreateDirectory "${target}" SetOutPath "${target}" @@ -111,8 +144,12 @@ Section System::Call 'shell32::IsUserAnAdmin() i .r0' WriteINIStr "${report}" "seed" "admin" "$0" SectionEnd -`); - compile('driver',base('driver')+` +`, + ); + compile( + "driver", + base("driver") + + ` Function .onInit System::Call 'shell32::IsUserAnAdmin() i .r0' WriteINIStr "${report}" "driver" "admin" "$0" @@ -122,8 +159,12 @@ Function .onInit FunctionEnd Section SectionEnd -`); - compile('cleanup',base('cleanup','admin')+` +`, + ); + compile( + "cleanup", + base("cleanup", "admin") + + ` Section RMDir /r "${target}" RMDir /r "${root}\\writable DBX folder" @@ -131,8 +172,12 @@ Section DeleteRegKey /ifempty HKCU "Software\\${manufacturer}" DeleteRegKey HKCU "${uninstallRegistry}" SectionEnd -`); - compile('cleanup-driver',base('cleanup-driver')+` +`, + ); + compile( + "cleanup-driver", + base("cleanup-driver") + + ` Function .onInit ExecShellWait "runas" "${root}\\cleanup.exe" SetErrorLevel 0 @@ -140,48 +185,54 @@ Function .onInit FunctionEnd Section SectionEnd -`); - run('driver'); - console.log(fs.readFileSync(report,'utf8')); - assert.throws(()=>fs.openSync(path.join(target,`${mainName}.exe`),'r+'),/EACCES|EPERM/); - const oldApp=spawn(path.join(target,`${mainName}.exe`),['/HOLD'],{windowsHide:true,stdio:'ignore'}); - await waitFor(()=>fs.readFileSync(report,'utf8').includes('holding=1')); - assert.equal(oldApp.exitCode,null); - run('installer',['/UPDATE','/P','/NS','/R','/ARGS','"argument with spaces"']); - await waitFor(()=>fs.existsSync(report)&&fs.readFileSync(report,'utf8').includes('version=new')); - const output=fs.readFileSync(report,'utf8'); +`, + ); + run("driver"); + console.log(fs.readFileSync(report, "utf8")); + assert.throws(() => fs.openSync(path.join(target, `${mainName}.exe`), "r+"), /EACCES|EPERM/); + const oldApp = spawn(path.join(target, `${mainName}.exe`), ["/HOLD"], { windowsHide: true, stdio: "ignore" }); + await waitFor(() => fs.readFileSync(report, "utf8").includes("holding=1")); + assert.equal(oldApp.exitCode, null); + run("installer", ["/UPDATE", "/P", "/NS", "/R", "/ARGS", '"argument with spaces"']); + await waitFor(() => fs.existsSync(report) && fs.readFileSync(report, "utf8").includes("version=new")); + const output = fs.readFileSync(report, "utf8"); console.log(output); - assert.match(output,/\[driver\]\r?\nadmin=0/); - assert.match(output,/\[installer\]\r?\nadmin=1/); - assert.match(output,/elevation=2/); - assert.match(output,/complete=1/); - assert.match(output,/update=1/); - assert.match(output,/passive=1/); - assert.match(output,/noShortcut=1/); - assert.match(output,/\/DBX_ELEVATED/); - assert.match(output,/\[app\][\s\S]*version=new[\s\S]*argument with spaces/); - assert.deepEqual(fs.readFileSync(path.join(target,`${mainName}.exe`)),fs.readFileSync(path.join(root,`${mainName}.exe`))); - assert.equal(fs.readFileSync(path.join(target,'payload.txt'),'utf8'),'updated resource payload'); - assert.ok(fs.existsSync(path.join(target,'uninstall.exe'))); - assert.notEqual(oldApp.exitCode,null,'old application was stopped before overwrite'); - console.log('REAL ELEVATION AND FULL TEMPLATE UPDATE WITH RUNNING OLD APP: PASS'); - fs.copyFileSync(report,path.join(root,'protected-update-report.ini')); + assert.match(output, /\[driver\]\r?\nadmin=0/); + assert.match(output, /\[installer\]\r?\nadmin=1/); + assert.match(output, /elevation=2/); + assert.match(output, /complete=1/); + assert.match(output, /update=1/); + assert.match(output, /passive=1/); + assert.match(output, /noShortcut=1/); + assert.match(output, /\/DBX_ELEVATED/); + assert.match(output, /\[app\][\s\S]*version=new[\s\S]*argument with spaces/); + assert.deepEqual(fs.readFileSync(path.join(target, `${mainName}.exe`)), fs.readFileSync(path.join(root, `${mainName}.exe`))); + assert.equal(fs.readFileSync(path.join(target, "payload.txt"), "utf8"), "updated resource payload"); + assert.ok(fs.existsSync(path.join(target, "uninstall.exe"))); + assert.notEqual(oldApp.exitCode, null, "old application was stopped before overwrite"); + console.log("REAL ELEVATION AND FULL TEMPLATE UPDATE WITH RUNNING OLD APP: PASS"); + fs.copyFileSync(report, path.join(root, "protected-update-report.ini")); fs.unlinkSync(report); - const writableTarget=path.join(root,'writable DBX folder'); - run('installer',['/S','/UPDATE','/NS','/R','/ARGS','"writable update argument"',`/D=${writableTarget}`]); - await waitFor(()=>fs.existsSync(report)&&fs.readFileSync(report,'utf8').includes('version=new')); - const writableOutput=fs.readFileSync(report,'utf8'); - assert.match(writableOutput,/elevation=0/); + const writableTarget = path.join(root, "writable DBX folder"); + run("installer", ["/S", "/UPDATE", "/NS", "/R", "/ARGS", '"writable update argument"', `/D=${writableTarget}`]); + await waitFor(() => fs.existsSync(report) && fs.readFileSync(report, "utf8").includes("version=new")); + const writableOutput = fs.readFileSync(report, "utf8"); + assert.match(writableOutput, /elevation=0/); console.log(writableOutput); - assert.match(writableOutput,/\[installer\]\r?\nadmin=0/); - assert.doesNotMatch(writableOutput,/\/DBX_ELEVATED/); - assert.equal(fs.readFileSync(path.join(writableTarget,'payload.txt'),'utf8'),'updated resource payload'); - console.log('FULL TEMPLATE INSTALL TO WRITABLE FOLDER WITHOUT ELEVATION: PASS'); + assert.match(writableOutput, /\[installer\]\r?\nadmin=0/); + assert.doesNotMatch(writableOutput, /\/DBX_ELEVATED/); + assert.equal(fs.readFileSync(path.join(writableTarget, "payload.txt"), "utf8"), "updated resource payload"); + console.log("FULL TEMPLATE INSTALL TO WRITABLE FOLDER WITHOUT ELEVATION: PASS"); } -main().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>{ - if(fs.existsSync(path.join(root,'cleanup-driver.exe'))) { - run('cleanup-driver'); - assert.equal(fs.existsSync(target),false,'Protected fixture was not removed'); - assert.equal(fs.existsSync(path.join(root,'writable DBX folder')),false,'Writable fixture was not removed'); - } -}); +main() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => { + if (fs.existsSync(path.join(root, "cleanup-driver.exe"))) { + run("cleanup-driver"); + assert.equal(fs.existsSync(target), false, "Protected fixture was not removed"); + assert.equal(fs.existsSync(path.join(root, "writable DBX folder")), false, "Writable fixture was not removed"); + } + }); diff --git a/scripts/windows-installer.test.mjs b/scripts/windows-installer.test.mjs index 024a0763e2..48fe0b0d91 100644 --- a/scripts/windows-installer.test.mjs +++ b/scripts/windows-installer.test.mjs @@ -1,43 +1,44 @@ -import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { test } from 'node:test' +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; -const compiler = process.env.NSIS_MAKENSIS || (process.platform === 'win32' - ? path.join(process.env.LOCALAPPDATA || '', 'tauri', 'NSIS', 'makensis.exe') - : 'makensis') -let compilerAvailable = false +const compiler = process.env.NSIS_MAKENSIS || (process.platform === "win32" ? path.join(process.env.LOCALAPPDATA || "", "tauri", "NSIS", "makensis.exe") : "makensis"); +let compilerAvailable = false; try { - execFileSync(compiler, [process.platform === 'win32' ? '/VERSION' : '-VERSION'], { timeout: 5000 }) - compilerAvailable = true + execFileSync(compiler, [process.platform === "win32" ? "/VERSION" : "-VERSION"], { timeout: 5000 }); + compilerAvailable = true; } catch {} -const available = process.platform === 'win32' && compilerAvailable -const template = readFileSync(new URL('../src-tauri/windows/nsis/installer.nsi', import.meta.url), 'utf8') -const diagnosticVariables = template.match(/^Var Dbx.*$/gm).join('\n') -const diagnostics = template.slice(template.indexOf('AllowSkipFiles off'), template.indexOf('; Probe without truncating')) -const elevationInit = template.slice(template.indexOf('Function .onInit\n') + 'Function .onInit\n'.length, template.indexOf(' \u0024{GetOptions} $CMDLINE "/P" $PassiveMode')) -const languages = ['English', 'SimpChinese', 'TradChinese'].map(language => `!insertmacro MUI_LANGUAGE "${language}"`).join('\n') +const available = process.platform === "win32" && compilerAvailable; +const template = readFileSync(new URL("../src-tauri/windows/nsis/installer.nsi", import.meta.url), "utf8"); +const diagnosticVariables = template.match(/^Var Dbx.*$/gm).join("\n"); +const diagnostics = template.slice(template.indexOf("AllowSkipFiles off"), template.indexOf("; Probe without truncating")); +const elevationInit = template.slice(template.indexOf("Function .onInit\n") + "Function .onInit\n".length, template.indexOf(' \u0024{GetOptions} $CMDLINE "/P" $PassiveMode')); +const languages = ["English", "SimpChinese", "TradChinese"].map((language) => `!insertmacro MUI_LANGUAGE "${language}"`).join("\n"); -test('Windows installer access checks and elevation handoff', { skip: !available }, () => { - const dir = mkdtempSync(path.join(tmpdir(), 'dbx installer test ')) - const helper = template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0] - assert.ok(elevationInit.includes('/DBX_PROFILE=')) - const launch = `ExecShell "runas" "$EXEPATH" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR'` - assert.ok(helper.includes(launch)) +test("Windows installer access checks and elevation handoff", { skip: !available }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "dbx installer test ")); + const helper = template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0]; + assert.ok(elevationInit.includes("/DBX_PROFILE=")); + const launch = `ExecShell "runas" "$EXEPATH" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR'`; + assert.ok(helper.includes(launch)); // Execute the production function, replacing only the OS UAC launch with a // recorder. Tests must never trigger an interactive prompt or install DBX. - const fixtureHelper = helper.replace(launch, `WriteINIStr "\u0024EXEDIR\\result.ini" "handoff" "args" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR' + const fixtureHelper = helper.replace( + launch, + `WriteINIStr "\u0024EXEDIR\\result.ini" "handoff" "args" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR' \u0024{If} \u0024DbxTestCancel = 1 SetErrors - \u0024{EndIf}`) - const exe = path.join(dir, 'probe.exe') - const result = path.join(dir, 'result.ini') - const destination = path.join(dir, 'existing DBX') - mkdirSync(destination) - const binary = path.join(destination, 'dbx.exe') - writeFileSync(binary, 'original executable contents') + \u0024{EndIf}`, + ); + const exe = path.join(dir, "probe.exe"); + const result = path.join(dir, "result.ini"); + const destination = path.join(dir, "existing DBX"); + mkdirSync(destination); + const binary = path.join(destination, "dbx.exe"); + writeFileSync(binary, "original executable contents"); const script = `Unicode true XPStyle on !include MUI2.nsh @@ -89,107 +90,106 @@ Section SetErrorLevel 0 Quit SectionEnd -` - const source = path.join(dir, 'probe.nsi') - writeFileSync(source, script) +`; + const source = path.join(dir, "probe.nsi"); + writeFileSync(source, script); try { // Also compile the unmodified production launch instruction and init code. - writeFileSync(source, script.replace(fixtureHelper, helper)) - execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) - writeFileSync(source, script) - execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) + writeFileSync(source, script.replace(fixtureHelper, helper)); + execFileSync(compiler, ["/V2", "/INPUTCHARSET", "UTF8", source], { encoding: "utf8", timeout: 30_000 }); + writeFileSync(source, script); + execFileSync(compiler, ["/V2", "/INPUTCHARSET", "UTF8", source], { encoding: "utf8", timeout: 30_000 }); const run = (args, target = destination) => { - rmSync(result, { force: true }) - let status = 0 + rmSync(result, { force: true }); + let status = 0; try { - execFileSync(exe, [...args, ...(target === null ? [] : [`/D=${target}`])], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` }) + execFileSync(exe, [...args, ...(target === null ? [] : [`/D=${target}`])], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` }); } catch (error) { - if (typeof error.status !== 'number') throw error - status = error.status + if (typeof error.status !== "number") throw error; + status = error.status; } - return { status, output: existsSync(result) ? readFileSync(result, 'utf8') : '' } - } - const writable = run([]) - assert.equal(writable.status, 0, writable.output) - assert.match(writable.output, /register zero\|register one\|register two\|register three/) - assert.doesNotMatch(writable.output, /handoff/) - assert.match(writable.output, /elevation=0/) - assert.match(writable.output, /fileError=0/) - assert.match(writable.output, /folderError=0/) - assert.equal(readFileSync(binary, 'utf8'), 'original executable contents') + return { status, output: existsSync(result) ? readFileSync(result, "utf8") : "" }; + }; + const writable = run([]); + assert.equal(writable.status, 0, writable.output); + assert.match(writable.output, /register zero\|register one\|register two\|register three/); + assert.doesNotMatch(writable.output, /handoff/); + assert.match(writable.output, /elevation=0/); + assert.match(writable.output, /fileError=0/); + assert.match(writable.output, /folderError=0/); + assert.equal(readFileSync(binary, "utf8"), "original executable contents"); - const locked = run(['/TEST_LOCK']) - assert.equal(locked.status, 0) - assert.doesNotMatch(locked.output, /handoff/) - assert.match(locked.output, /fileError=32/) - assert.match(locked.output, /folderError=0/) - assert.equal(readFileSync(binary, 'utf8'), 'original executable contents') + const locked = run(["/TEST_LOCK"]); + assert.equal(locked.status, 0); + assert.doesNotMatch(locked.output, /handoff/); + assert.match(locked.output, /fileError=32/); + assert.match(locked.output, /folderError=0/); + assert.equal(readFileSync(binary, "utf8"), "original executable contents"); - const nested = run([], path.join(destination, 'new', 'nested DBX')) - assert.equal(nested.status, 0) - assert.doesNotMatch(nested.output, /handoff/) - assert.equal(existsSync(path.join(destination, 'new')), false) + const nested = run([], path.join(destination, "new", "nested DBX")); + assert.equal(nested.status, 0); + assert.doesNotMatch(nested.output, /handoff/); + assert.equal(existsSync(path.join(destination, "new")), false); // The read-only attribute yields ERROR_ACCESS_DENIED without changing ACLs. - execFileSync('attrib.exe', ['+R', binary]) - const update = run(['/UPDATE', '/P', '/NS', '/R', '/ARGS', '"argument with spaces"']) - assert.equal(update.status, 0) - assert.match(update.output, /\/DBX_ELEVATED \/DBX_PROFILE="[^"]+" \/DBX_LANG=1033 \/UPDATE \/P \/NS \/R \/ARGS "argument with spaces" \/D=/) - assert.ok(update.output.includes(`/D=${destination}`)) - assert.doesNotMatch(update.output, /registers=/) - assert.equal(readFileSync(binary, 'utf8'), 'original executable contents') + execFileSync("attrib.exe", ["+R", binary]); + const update = run(["/UPDATE", "/P", "/NS", "/R", "/ARGS", '"argument with spaces"']); + assert.equal(update.status, 0); + assert.match(update.output, /\/DBX_ELEVATED \/DBX_PROFILE="[^"]+" \/DBX_LANG=1033 \/UPDATE \/P \/NS \/R \/ARGS "argument with spaces" \/D=/); + assert.ok(update.output.includes(`/D=${destination}`)); + assert.doesNotMatch(update.output, /registers=/); + assert.equal(readFileSync(binary, "utf8"), "original executable contents"); - const resumed = run([update.output.match(/args=(.*)/)[1]], null) - assert.equal(resumed.status, 0) - assert.doesNotMatch(resumed.output, /handoff/) - assert.ok(resumed.output.includes(`destination=${destination}`)) - assert.match(resumed.output, /language=1033/) + const resumed = run([update.output.match(/args=(.*)/)[1]], null); + assert.equal(resumed.status, 0); + assert.doesNotMatch(resumed.output, /handoff/); + assert.ok(resumed.output.includes(`destination=${destination}`)); + assert.match(resumed.output, /language=1033/); - const cancelled = run(['/TEST_CANCEL']) - assert.equal(cancelled.status, 740) - assert.doesNotMatch(cancelled.output, /registers=/) + const cancelled = run(["/TEST_CANCEL"]); + assert.equal(cancelled.status, 740); + assert.doesNotMatch(cancelled.output, /registers=/); - const elevated = run(['/TEST_ELEVATED']) - assert.equal(elevated.status, 0) - assert.doesNotMatch(elevated.output, /handoff/) - assert.match(elevated.output, /elevation=3/) - assert.match(elevated.output, /register zero\|register one\|register two\|register three/) + const elevated = run(["/TEST_ELEVATED"]); + assert.equal(elevated.status, 0); + assert.doesNotMatch(elevated.output, /handoff/); + assert.match(elevated.output, /elevation=3/); + assert.match(elevated.output, /register zero\|register one\|register two\|register three/); - const wrongUser = run(['/DBX_ELEVATED', '/DBX_LANG=1033', '/DBX_PROFILE="different Windows account"']) - assert.equal(wrongUser.status, 740) - assert.equal(wrongUser.output, '') + const wrongUser = run(["/DBX_ELEVATED", "/DBX_LANG=1033", '/DBX_PROFILE="different Windows account"']); + assert.equal(wrongUser.status, 740); + assert.equal(wrongUser.output, ""); // A machine-protected directory also requests elevation, including a // destination that does not exist yet. No files are installed there. - const protectedPath = path.join(process.env.ProgramFiles, 'DBX access probe', 'nested') - const protectedFolder = run(['/UPDATE'], protectedPath) - assert.match(protectedFolder.output, /handoff/) - assert.equal(existsSync(protectedPath), false) + const protectedPath = path.join(process.env.ProgramFiles, "DBX access probe", "nested"); + const protectedFolder = run(["/UPDATE"], protectedPath); + assert.match(protectedFolder.output, /handoff/); + assert.equal(existsSync(protectedPath), false); } finally { - if (existsSync(binary)) execFileSync('attrib.exe', ['-R', binary]) - rmSync(dir, { recursive: true, force: true }) + if (existsSync(binary)) execFileSync("attrib.exe", ["-R", binary]); + rmSync(dir, { recursive: true, force: true }); } -}) +}); // Compile the actual diagnostic strings and write policy on any host with NSIS. // On Windows also execute a locked-file extraction: it must stop before the // completion marker, and its expanded dialog must contain usable diagnostics. -test('Installer write failure diagnostics compile', { skip: !compilerAvailable }, async (t) => { - const dir = mkdtempSync(path.join(tmpdir(), 'dbx write failure ')) - const payload = path.join(dir, 'payload.txt') - const source = path.join(dir, 'failure.nsi') - const exe = path.join(dir, 'failure.exe') - const target = path.join(dir, 'destination') - const report = path.join(dir, 'result.ini') - mkdirSync(target) - writeFileSync(payload, 'new executable contents') +test("Installer write failure diagnostics compile", { skip: !compilerAvailable }, async (t) => { + const dir = mkdtempSync(path.join(tmpdir(), "dbx write failure ")); + const payload = path.join(dir, "payload.txt"); + const source = path.join(dir, "failure.nsi"); + const exe = path.join(dir, "failure.exe"); + const target = path.join(dir, "destination"); + const report = path.join(dir, "result.ini"); + mkdirSync(target); + writeFileSync(payload, "new executable contents"); // Simulate only the user's Manual installation selection and Explorer launch. // Keep the production extraction policy, dialog code and exit handler intact. const fixtureDiagnostics = diagnostics - .replace('Function DbxShowWriteError', 'Function DbxShowWriteErrorActual') - .replace(' Call DbxShowWriteError\n', ' Call DbxShowWriteError\n WriteINIStr "$EXEDIR\\result.ini" "trace" "action" "$DbxWriteFailureAction"\n') - .replace(`ExecShell "open" "$WINDIR\\explorer.exe" '/select,"$EXEPATH"'`, - `WriteINIStr "$EXEDIR\\result.ini" "manual" "args" '/select,"$EXEPATH"'`) + .replace("Function DbxShowWriteError", "Function DbxShowWriteErrorActual") + .replace(" Call DbxShowWriteError\n", ' Call DbxShowWriteError\n WriteINIStr "$EXEDIR\\result.ini" "trace" "action" "$DbxWriteFailureAction"\n') + .replace(`ExecShell "open" "$WINDIR\\explorer.exe" '/select,"$EXEPATH"'`, `WriteINIStr "$EXEDIR\\result.ini" "manual" "args" '/select,"$EXEPATH"'`); const dialogSelection = `Function DbxShowWriteError \u0024{If} $TestManual = 1 StrCpy $DbxWriteFailureAction \u0024{DBX_WRITE_MANUAL} @@ -200,7 +200,7 @@ test('Installer write failure diagnostics compile', { skip: !compilerAvailable } \u0024{Else} Call DbxShowWriteErrorActual \u0024{EndIf} -FunctionEnd` +FunctionEnd`; const script = `Unicode true XPStyle on !include MUI2.nsh @@ -251,57 +251,61 @@ Section !insertmacro DbxExtractFile "/oname=dbx.exe" "${payload}" "$INSTDIR\\dbx.exe" WriteINIStr "$EXEDIR\\result.ini" "install" "complete" "1" SectionEnd -` +`; try { - writeFileSync(source, script) - const flag = process.platform === 'win32' ? '/' : '-' - const compilation = execFileSync(compiler, [`${flag}V2`, `${flag}INPUTCHARSET`, 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) - assert.doesNotMatch(compilation, /unknown variable|unknown constant|unknown identifier/i) - await t.test('Windows locked-file extraction stops and reports diagnostics', { skip: !available }, () => { + writeFileSync(source, script); + const flag = process.platform === "win32" ? "/" : "-"; + const compilation = execFileSync(compiler, [`${flag}V2`, `${flag}INPUTCHARSET`, "UTF8", source], { encoding: "utf8", timeout: 30_000 }); + assert.doesNotMatch(compilation, /unknown variable|unknown constant|unknown identifier/i); + await t.test("Windows locked-file extraction stops and reports diagnostics", { skip: !available }, () => { for (const [language, diagnosticLabel, instruction] of [ - ['1033', 'Diagnostic code:', 'Please run the installer as administrator.'], - ['2052', '诊断码:', '请以管理员权限运行安装程序。'], - ['1028', '診斷碼:', '請以系統管理員權限執行安裝程式。'], + ["1033", "Diagnostic code:", "Please run the installer as administrator."], + ["2052", "诊断码:", "请以管理员权限运行安装程序。"], + ["1028", "診斷碼:", "請以系統管理員權限執行安裝程式。"], ]) { - for (const action of ['writable', 'readonly', 'retry', 'cancel', 'manual']) { - t.diagnostic(`Windows extraction: language=${language}, action=${action}`) - const locked = ['retry', 'cancel', 'manual'].includes(action) - writeFileSync(path.join(target, 'dbx.exe'), 'original executable contents') + for (const action of ["writable", "readonly", "retry", "cancel", "manual"]) { + t.diagnostic(`Windows extraction: language=${language}, action=${action}`); + const locked = ["retry", "cancel", "manual"].includes(action); + writeFileSync(path.join(target, "dbx.exe"), "original executable contents"); // Win32 INI writes otherwise use the runner's ANSI code page. - writeFileSync(report, '\ufeff', 'utf16le') - let exitCode = 0 + writeFileSync(report, "\ufeff", "utf16le"); + let exitCode = 0; try { - execFileSync(exe, ['/S', `/LANG=${language}`, ...(locked ? ['/LOCK'] : []), ...(action === 'manual' ? ['/MANUAL'] : []), ...(action === 'retry' ? ['/RETRY'] : []), ...(action === 'readonly' ? ['/READONLY'] : []), `/D=${target}`], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` }) + execFileSync(exe, ["/S", `/LANG=${language}`, ...(locked ? ["/LOCK"] : []), ...(action === "manual" ? ["/MANUAL"] : []), ...(action === "retry" ? ["/RETRY"] : []), ...(action === "readonly" ? ["/READONLY"] : []), `/D=${target}`], { + timeout: 15_000, + windowsVerbatimArguments: true, + argv0: `"${exe}"`, + }); } catch (error) { - if (typeof error.status !== 'number') { - const diagnostic = existsSync(report) ? readFileSync(report, 'utf16le') : 'No fixture report' - throw new Error(`language=${language}, action=${action}: ${error.message}\n${diagnostic}`, { cause: error }) + if (typeof error.status !== "number") { + const diagnostic = existsSync(report) ? readFileSync(report, "utf16le") : "No fixture report"; + throw new Error(`language=${language}, action=${action}: ${error.message}\n${diagnostic}`, { cause: error }); } - exitCode = error.status + exitCode = error.status; } - const output = readFileSync(report, 'utf16le') - assert.doesNotMatch(output, /Installer:|安装包:|安裝套件:/) - assert.ok(output.includes(target), output) - assert.ok(output.includes(diagnosticLabel), output) - assert.match(output, new RegExp(`A[01]-E[01]-F${locked ? 32 : 0}-D0`)) - assert.ok(output.includes(instruction), output) - assert.ok(output.includes('1.0.1'), output) - assert.doesNotMatch(output, /Program Files|\$Dbx|\$EXEPATH|\$INSTDIR/) - if (action === 'cancel' || action === 'manual') { - assert.equal(exitCode, 2, 'A failed required-file write must stop installation') - assert.doesNotMatch(output, /complete=1/) - if (action === 'manual') assert.ok(output.includes(`args=/select,"${exe}"`), output) - else assert.doesNotMatch(output, /\[manual\]/) - assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'original executable contents') + const output = readFileSync(report, "utf16le"); + assert.doesNotMatch(output, /Installer:|安装包:|安裝套件:/); + assert.ok(output.includes(target), output); + assert.ok(output.includes(diagnosticLabel), output); + assert.match(output, new RegExp(`A[01]-E[01]-F${locked ? 32 : 0}-D0`)); + assert.ok(output.includes(instruction), output); + assert.ok(output.includes("1.0.1"), output); + assert.doesNotMatch(output, /Program Files|\$Dbx|\$EXEPATH|\$INSTDIR/); + if (action === "cancel" || action === "manual") { + assert.equal(exitCode, 2, "A failed required-file write must stop installation"); + assert.doesNotMatch(output, /complete=1/); + if (action === "manual") assert.ok(output.includes(`args=/select,"${exe}"`), output); + else assert.doesNotMatch(output, /\[manual\]/); + assert.equal(readFileSync(path.join(target, "dbx.exe"), "utf8"), "original executable contents"); } else { - assert.equal(exitCode, 0) - assert.match(output, /complete=1/) - assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'new executable contents') + assert.equal(exitCode, 0); + assert.match(output, /complete=1/); + assert.equal(readFileSync(path.join(target, "dbx.exe"), "utf8"), "new executable contents"); } } } - }) + }); } finally { - rmSync(dir, { recursive: true, force: true }) + rmSync(dir, { recursive: true, force: true }); } -}) +});