style: apply oxfmt across drifted files

This commit is contained in:
t8y2
2026-10-13 12:52:46 +08:00
parent 168fc4c63a
commit 1fb8f150fc
96 changed files with 2437 additions and 2735 deletions
+36 -20
View File
@@ -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);
+35 -25
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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"
+ `<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\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" +
`<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`
);
}
async function main() {
+53 -44
View File
@@ -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) => {
+3 -16
View File
@@ -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"),
});
}
@@ -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);
});
+15 -56
View File
@@ -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,
+5 -24
View File
@@ -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;
}
+6 -2
View File
@@ -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];
}
+8 -6
View File
@@ -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);
});
+5 -10
View File
@@ -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 })),
+131 -84
View File
@@ -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 },
);
});
+46 -18
View File
@@ -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;
+4 -2
View File
@@ -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) {
+54 -40
View File
@@ -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/<name>/ 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));
}
+89 -47
View File
@@ -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"] = [];
+11 -5
View File
@@ -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) {
+30 -13
View File
@@ -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) {
@@ -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\)/);
+40 -28
View File
@@ -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/);
+1 -3
View File
@@ -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> --tag-pattern <regex> --retain <count> [--apply]",
);
throw new Error("Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]");
}
try {
new RegExp(args.tagPattern);
+5 -12
View File
@@ -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(
+4 -41
View File
@@ -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] || [])])],
}));
+43 -18
View File
@@ -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"]);
});
+9 -66
View File
@@ -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]);
},
};
}
+8 -21
View File
@@ -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);
});
+2 -8
View File
@@ -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", () => {
+27 -105
View File
@@ -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,
@@ -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", () => {
+32 -86
View File
@@ -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");
+34 -78
View File
@@ -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(`
<dependencies>
<dependency><groupId>org.example</groupId><artifactId>client</artifactId><version>1</version></dependency>
</dependencies>
`)],
`),
],
["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"],
);
});
+31 -56
View File
@@ -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) {
@@ -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/,
);
});
+33 -16
View File
@@ -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;
+26 -126
View File
@@ -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 [];
+20 -23
View File
@@ -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, /^<!-- dbx-similar-issues -->/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")) {
+10 -10
View File
@@ -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 || "" };
}
+4 -14
View File
@@ -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 <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]",
);
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <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;
+9 -20
View File
@@ -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));
+19 -10
View File
@@ -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) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.f
}
async function pagesURL() {
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
try {
return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/");
} catch {
const [o, n] = repo.split("/");
return `https://${o.toLowerCase()}.github.io/${n}/`;
}
}
async function publish() {
@@ -163,12 +170,12 @@ async function publish() {
const dir = process.env.OUT_DIR;
const result = process.env.RECORD_RESULT || "success";
let m = null;
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
try {
m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"));
} catch {}
if (result === "cancelled" && !m) return; // a newer commit took over
if (!m || m.leak || (!m.scenes.some((s) => 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<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
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);
});
+134 -60
View File
@@ -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 }));
+39 -12
View File
@@ -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 <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
process.exit(1);
+3 -16
View File
@@ -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}`);
}
@@ -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 {
@@ -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;</pre
>
LIMIT 100;</pre>
</div>
<div class="overflow-x-auto rounded-md border">
@@ -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<CodeMirrorBindings[Key]>;
[
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<CodeMirrorBindings[Key]>;
};
export function createQueryEditorCodeMirrorRuntime() {
@@ -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 }}</pre
>
>{{ presentedValuePreview }}</pre>
<div v-if="detail.isValuePreviewTruncated && !sideJsonView" class="text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}</div>
</div>
</div>
@@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({
}));
vi.mock("@/lib/backend/api", () => ({
mqttGetBrokerInfo: vi.fn(
async (): Promise<MqttBrokerInfo> => ({
brokerUrl: "mqtt://localhost:1883",
clientId: "dbx-test",
connected: true,
protocolVersion: "5.0",
subscriptionCount: 1,
}),
),
mqttGetBrokerInfo: vi.fn(async (): Promise<MqttBrokerInfo> => ({
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,
@@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({
}));
vi.mock("@/lib/backend/api", () => ({
mqttGetBrokerInfo: vi.fn(
async (): Promise<MqttBrokerInfo> => ({
brokerUrl: "mqtt://localhost:1883",
clientId: "dbx-test",
connected: true,
protocolVersion: "5.0",
subscriptionCount: 1,
}),
),
mqttGetBrokerInfo: vi.fn(async (): Promise<MqttBrokerInfo> => ({
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,
@@ -12,15 +12,13 @@ const { mqttClearMessagesMock, mqttGetMessagesMock } = vi.hoisted(() => ({
}));
vi.mock("@/lib/backend/api", () => ({
mqttGetBrokerInfo: vi.fn(
async (): Promise<MqttBrokerInfo> => ({
brokerUrl: "mqtt://localhost:1883",
clientId: "dbx-test",
connected: true,
protocolVersion: "5.0",
subscriptionCount: 1,
}),
),
mqttGetBrokerInfo: vi.fn(async (): Promise<MqttBrokerInfo> => ({
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,
@@ -11,15 +11,13 @@ const { mqttGetMessagesMock } = vi.hoisted(() => ({
}));
vi.mock("@/lib/backend/api", () => ({
mqttGetBrokerInfo: vi.fn(
async (): Promise<MqttBrokerInfo> => ({
brokerUrl: "mqtt://localhost:1883",
clientId: "dbx-test",
connected: true,
protocolVersion: "5.0",
subscriptionCount: 1,
}),
),
mqttGetBrokerInfo: vi.fn(async (): Promise<MqttBrokerInfo> => ({
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,
@@ -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;
@@ -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)
@@ -204,7 +204,7 @@ function sqlServerDescriptionParameters(tokens: SqlSemanticToken[]): Map<string,
const procedure = readName(tokens, 1);
if (procedure.length !== 2 || identifier(procedure[0])?.toLowerCase() !== "sys" || identifier(procedure[1])?.toLowerCase() !== "sp_addextendedproperty") return undefined;
const parameters = new Map<string, { value: string; tokens: SqlSemanticToken[] }>();
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[] = [];
@@ -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");
});
@@ -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 },
@@ -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);
@@ -38,7 +38,7 @@ export function createShowWhitespaceExtension(view: Pick<ViewModule, "highlightW
const ranges = [];
let lastLine = -1;
for (const { from, to } of editorView.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
if (line.number !== lastLine) {
lastLine = line.number;
@@ -110,7 +110,7 @@ function validateHtml(text: string): NacosConfigDiagnostic | null {
if (syntaxError) return syntaxError;
const stack: HtmlOpenTag[] = [];
for (let offset = 0; offset < text.length; ) {
for (let offset = 0; offset < text.length;) {
if (text[offset] !== "<") {
offset += 1;
continue;
@@ -527,7 +527,7 @@ function readInt32FromBytes(bytes: Uint8Array, offset: number): number {
function decodeModifiedUtf8(bytes: Uint8Array): string {
let output = "";
for (let index = 0; index < bytes.length; ) {
for (let index = 0; index < bytes.length;) {
const byte = bytes[index];
if ((byte & 0x80) === 0) {
output += String.fromCharCode(byte);
@@ -104,7 +104,7 @@ export class RedisKeyGroupIndex {
}
async append(keys: readonly RedisKeyInfo[], work: GroupWork): Promise<boolean> {
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++) {
+1 -1
View File
@@ -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] === "$") {
@@ -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;
+1 -1
View File
@@ -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;
@@ -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) {
@@ -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());
@@ -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 });
+1 -3
View File
@@ -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));
+6 -8
View File
@@ -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,
+4 -13
View File
@@ -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],
+1 -7
View File
@@ -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");
+13 -13
View File
@@ -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 });
}
+2 -7
View File
@@ -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");
+28 -19
View File
@@ -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 });
}
});
+14 -22
View File
@@ -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");
});
+1 -16
View File
@@ -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 {
@@ -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") },
);
@@ -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,
+8 -27
View File
@@ -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 });
+3 -20
View File
@@ -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)} |`);
}
}
+1 -14
View File
@@ -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;
+15 -5
View File
@@ -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)}`);
}
}
+11 -18
View File
@@ -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) {
+10 -12
View File
@@ -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.`);
}
+36 -23
View File
@@ -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;
+180 -190
View File
@@ -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=<ip> Override the default bind address (127.0.0.1).');
console.log(' DB_PORT=<port> Override the default host port.');
console.log(' DB_PASSWORD=<password> Override the default password (123456).');
console.log('');
console.log('Next steps: make db-verify DB=<product>@<version>, make db-down DB=<product>@<version>');
console.log('Tab completion: make db-completion');
console.log("");
console.log("Optional parameters:");
console.log(" DB_BIND_ADDRESS=<ip> Override the default bind address (127.0.0.1).");
console.log(" DB_PORT=<port> Override the default host port.");
console.log(" DB_PASSWORD=<password> Override the default password (123456).");
console.log("");
console.log("Next steps: make db-verify DB=<product>@<version>, make db-down DB=<product>@<version>");
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}@<version> (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}@<version> (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 <product>@<version>, for example DB=mysql@8.4.');
if (!database || !selectorVersion) throw new Error("DB must use the format <product>@<version>, 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}`);
+310 -281
View File
@@ -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}\``;
+1 -3
View File
@@ -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"];
+16 -47
View File
@@ -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 = {}) {
+4 -19
View File
@@ -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, "");
}
+1 -18
View File
@@ -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() {
+1 -3
View File
@@ -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");
+51 -26
View File
@@ -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: `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="test-root"></div><script type="module">
await page.route(`${frontend}/`, (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="test-root"></div><script type="module">
import {createApp,h,ref} from ${JSON.stringify(dependencyUrl("vue"))};
import {createPinia} from ${JSON.stringify(dependencyUrl("pinia"))};
import Dialog from '/src/components/nacos/NacosContentReplaceDialog.vue';
@@ -65,10 +73,15 @@ try {
const open=ref(true); window.setTestOpen=value=>open.value=value;
const app=createApp({setup:()=>()=>h(Dialog,{open:open.value,connectionId:${JSON.stringify(connectionId)},currentNamespace:'','onUpdate:open':value=>open.value=value})});
app.use(createPinia()); app.use(i18n); app.mount('#test-root'); window.testReady=true;
</script></body></html>` }));
await page.route("**/api/nacos/configs/publish", async route => {
</script></body></html>`,
}),
);
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();
+4 -17
View File
@@ -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);
}
+72 -85
View File
@@ -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");
});
}
}
+141 -90
View File
@@ -3,59 +3,62 @@
// Run: node scripts/windows-installer.integration.mjs <directory>
// 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()<end) {
if(check()) return;
await new Promise(r=>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");
}
});
+152 -148
View File
@@ -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 });
}
})
});