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:
@@ -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