mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
style: apply oxfmt across drifted files
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -52,26 +52,30 @@ If input is truncated, assess only visible evidence and express uncertainty abou
|
||||
class PriorityError extends Error {}
|
||||
|
||||
function labelNames(issue) {
|
||||
return (issue.labels || []).map((label) => typeof label === "string" ? label : label.name).filter(Boolean);
|
||||
return (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
function withoutUserPriority(body) {
|
||||
let inPrioritySection = false;
|
||||
return String(body || "").split(/\r?\n/).filter((line) => {
|
||||
const heading = line.match(/^#{1,6}\s+(.+)/);
|
||||
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
|
||||
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
|
||||
}).join("\n").trim();
|
||||
return String(body || "")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => {
|
||||
const heading = line.match(/^#{1,6}\s+(.+)/);
|
||||
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
|
||||
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function prepareIssue(issue) {
|
||||
const body = withoutUserPriority(issue.body);
|
||||
return {
|
||||
title: String(issue.title || "").slice(0, 500),
|
||||
body: body.length > MAX_BODY_LENGTH
|
||||
? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}`
|
||||
: body,
|
||||
labels: labelNames(issue).filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)).sort(),
|
||||
body: body.length > MAX_BODY_LENGTH ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` : body,
|
||||
labels: labelNames(issue)
|
||||
.filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label))
|
||||
.sort(),
|
||||
truncated: body.length > MAX_BODY_LENGTH,
|
||||
};
|
||||
}
|
||||
@@ -83,21 +87,26 @@ export function parseAssessment(content, issue) {
|
||||
} catch {
|
||||
throw new PriorityError("AtlasCloud returned invalid priority JSON");
|
||||
}
|
||||
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3
|
||||
&& value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
|
||||
if (!result || typeof result.priority !== "string" || !Object.hasOwn(PRIORITY_LABELS, result.priority)
|
||||
|| !["high", "medium", "low"].includes(result.confidence)
|
||||
|| !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk)
|
||||
|| typeof result.reason !== "string" || !result.reason.trim() || result.reason.length > 600
|
||||
|| !validTextList(result.evidence, 300) || !validTextList(result.missing_information, 250)) {
|
||||
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
|
||||
if (
|
||||
!result ||
|
||||
typeof result.priority !== "string" ||
|
||||
!Object.hasOwn(PRIORITY_LABELS, result.priority) ||
|
||||
!["high", "medium", "low"].includes(result.confidence) ||
|
||||
!["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) ||
|
||||
typeof result.reason !== "string" ||
|
||||
!result.reason.trim() ||
|
||||
result.reason.length > 600 ||
|
||||
!validTextList(result.evidence, 300) ||
|
||||
!validTextList(result.missing_information, 250)
|
||||
) {
|
||||
throw new PriorityError("AtlasCloud returned an invalid priority assessment");
|
||||
}
|
||||
if (!result.evidence.every((quote) => issue.title.includes(quote) || issue.body.includes(quote))) {
|
||||
throw new PriorityError("AtlasCloud priority evidence does not match the issue text");
|
||||
}
|
||||
const insufficientEvidence = result.confidence === "low" || result.evidence.length === 0 || result.risk === "unknown";
|
||||
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high"
|
||||
|| !["security", "data-loss", "core-blocked"].includes(result.risk));
|
||||
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" || !["security", "data-loss", "core-blocked"].includes(result.risk));
|
||||
return {
|
||||
priority: insufficientEvidence || unsupportedCriticalPriority ? "needs-info" : result.priority,
|
||||
confidence: result.confidence,
|
||||
@@ -219,8 +228,7 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
|
||||
await github("POST", `${issuePath}/labels`, { labels: [targetLabel] });
|
||||
}
|
||||
for (const label of existingLabels) {
|
||||
if (label !== targetLabel && label.startsWith(LABEL_PREFIX)
|
||||
&& Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
|
||||
if (label !== targetLabel && label.startsWith(LABEL_PREFIX) && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
|
||||
try {
|
||||
await github("DELETE", `${issuePath}/labels/${encodeURIComponent(label)}`);
|
||||
} catch (error) {
|
||||
@@ -234,10 +242,12 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
|
||||
export function formatSummary(result) {
|
||||
const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
if (result.skipped) return `### AI issue priority\n\nSkipped: ${escape(result.skipped)}\n`;
|
||||
return `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n`
|
||||
+ "AI suggestion based on issue text only, not a verified diagnosis or release commitment. "
|
||||
+ "User-reported and maintainer priority labels are unchanged.\n\n"
|
||||
+ `<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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"] = [];
|
||||
|
||||
@@ -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
@@ -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\)/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] || [])])],
|
||||
}));
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 || "" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+7
-9
@@ -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,
|
||||
|
||||
+7
-9
@@ -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,
|
||||
|
||||
+7
-9
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}\``;
|
||||
|
||||
@@ -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
@@ -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 = {}) {
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user