mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
better dev script (#1961)
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Invoked by script/artcraft/unix_dev.sh. Adapted from ArtcraftX's launcher.
|
||||
// Living under frontend lets Node resolve the local Vite installation.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const frontendDir = path.join(rootDir, "frontend");
|
||||
const appDir = path.join(frontendDir, "apps/artcraft");
|
||||
const rustDir = path.join(rootDir, "crates/desktop/artcraft");
|
||||
const host = "127.0.0.1";
|
||||
const startingPort = Number(process.env.ARTCRAFT_DEV_PORT ?? "5193");
|
||||
|
||||
let vite;
|
||||
let rust;
|
||||
let stopping = false;
|
||||
|
||||
async function main() {
|
||||
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
|
||||
throw new Error("ARTCRAFT_DEV_PORT must be an integer between 1024 and 65535.");
|
||||
}
|
||||
|
||||
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
|
||||
// Match the app build's cwd, including Tailwind config/content resolution.
|
||||
process.chdir(appDir);
|
||||
vite = await createServer({
|
||||
configFile: path.join(appDir, "vite.config.ts"),
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host,
|
||||
port: startingPort,
|
||||
// Vite retries occupied ports while binding the real server. There is no
|
||||
// port probe/release/rebind race, and no existing process is terminated.
|
||||
strictPort: false,
|
||||
open: false,
|
||||
hmr: true,
|
||||
},
|
||||
});
|
||||
if (stopping) return;
|
||||
await vite.listen();
|
||||
const { port } = vite.httpServer.address();
|
||||
const devUrl = `http://${host}:${port}`;
|
||||
|
||||
// Only initial startup may choose another port. On a Vite config restart,
|
||||
// rebind this port or fail visibly so Tauri never follows a silent port hop.
|
||||
for (const options of [vite.config.server, vite.config.inlineConfig.server]) {
|
||||
options.port = port;
|
||||
options.strictPort = true;
|
||||
}
|
||||
|
||||
const configPath = path.join(rustDir, "tauri-dev-hot-reload.conf.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf8"));
|
||||
if (stopping) return;
|
||||
const csp = config.app.security.csp;
|
||||
const overrides = {
|
||||
build: { devUrl, beforeDevCommand: "" },
|
||||
app: {
|
||||
security: {
|
||||
devCsp: {
|
||||
...csp,
|
||||
"connect-src": [...csp["connect-src"], devUrl, `ws://${host}:${port}`],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`\n[artcraft] Frontend + HMR: ${devUrl}`);
|
||||
console.log("[artcraft] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
|
||||
console.log("[artcraft] Press Ctrl-C to stop both.\n");
|
||||
|
||||
rust = spawn("cargo", [
|
||||
"tauri", "dev",
|
||||
"--no-dev-server",
|
||||
// Tauri watches the desktop crate and its workspace dependencies itself.
|
||||
// SQLx's external metadata and migrations also affect the Rust build.
|
||||
"--additional-watch-folders", path.join(rootDir, ".sqlx"),
|
||||
"--additional-watch-folders", path.join(rootDir, "_database/sql/artcraft_migrations"),
|
||||
"--config", configPath,
|
||||
"--config", JSON.stringify(overrides),
|
||||
], {
|
||||
cwd: rootDir,
|
||||
// All cargo/rustc/app descendants inherit this private Unix process group.
|
||||
detached: true,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
TAURI_FRONTEND_PATH: frontendDir,
|
||||
TAURI_APP_PATH: rustDir,
|
||||
SQLX_OFFLINE: process.env.SQLX_OFFLINE ?? "true",
|
||||
RUSTFLAGS: process.env.RUSTFLAGS ?? "-Awarnings",
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER: process.env.WEBKIT_DISABLE_DMABUF_RENDERER ?? "1",
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE: process.env.WEBKIT_DISABLE_COMPOSITING_MODE ?? "1",
|
||||
},
|
||||
});
|
||||
rust.on("error", fail);
|
||||
rust.on("exit", (code, signal) => {
|
||||
if (!stopping) {
|
||||
console.log(`[artcraft] Rust/Tauri exited (${signal ?? code}).`);
|
||||
void stop(code ?? 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(exitCode) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
console.log("\n[artcraft] Stopping frontend and Rust/Tauri...");
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
signalRustGroup("SIGKILL");
|
||||
process.exit(exitCode);
|
||||
}, 5000);
|
||||
signalRustGroup("SIGTERM");
|
||||
await Promise.allSettled([
|
||||
vite?.close(),
|
||||
(async () => {
|
||||
while (signalRustGroup(0)) await delay(50);
|
||||
})(),
|
||||
]);
|
||||
clearTimeout(deadline);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function signalRustGroup(signal) {
|
||||
if (!rust?.pid) return false;
|
||||
try {
|
||||
process.kill(-rust.pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(error) {
|
||||
console.error(`[artcraft] ${error.stack ?? error}`);
|
||||
void stop(1);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void stop(130));
|
||||
process.on("SIGTERM", () => void stop(143));
|
||||
process.on("SIGHUP", () => void stop(129));
|
||||
process.on("uncaughtException", fail);
|
||||
process.on("unhandledRejection", fail);
|
||||
|
||||
main().catch(fail);
|
||||
@@ -0,0 +1,188 @@
|
||||
// Integration tests: real Vite sockets + a fake cargo process tree, no GUI/build.
|
||||
// Run: node --test frontend/scripts/unix-dev.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { test } from "node:test";
|
||||
import WebSocket from "ws";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const launcher = path.join(rootDir, "script/artcraft/unix_dev.sh");
|
||||
|
||||
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 40000 }, async (t) => {
|
||||
const occupied = await occupyPort(t);
|
||||
const startingPort = occupied.address().port;
|
||||
const run = await fixture(t, { ARTCRAFT_DEV_PORT: String(startingPort) });
|
||||
const record = await run.record();
|
||||
const config = overrides(record);
|
||||
const url = config.build.devUrl;
|
||||
assert.ok(Number(new URL(url).port) > startingPort);
|
||||
assert.equal(new URL(url).hostname, "127.0.0.1");
|
||||
assert.equal(config.build.beforeDevCommand, "");
|
||||
assert.ok(!record.args.includes("--no-watch"));
|
||||
assert.ok(!record.args.includes("--no-dev-server-wait"));
|
||||
assert.equal(record.env.TAURI_APP_PATH, path.join(rootDir, "crates/desktop/artcraft"));
|
||||
assert.equal(record.env.TAURI_FRONTEND_PATH, path.join(rootDir, "frontend"));
|
||||
assert.ok(config.app.security.devCsp["connect-src"].includes(url.replace("http:", "ws:")));
|
||||
assert.match(await (await fetch(url)).text(), /\/src\/index\.tsx/);
|
||||
assert.match(await (await fetch(`${url}/@vite/client`)).text(), /WebSocket/);
|
||||
|
||||
run.proc.kill("SIGINT");
|
||||
assert.deepEqual(await run.exited, [130, null], run.output());
|
||||
await until(() => !alive(record.pid) && !alive(record.childPid));
|
||||
await assert.rejects(fetch(url));
|
||||
assert.equal(occupied.listening, true, "must not terminate the existing listener");
|
||||
});
|
||||
|
||||
test("Tauri failure propagates and stops Vite and leftover descendants", { timeout: 40000 }, async (t) => {
|
||||
const run = await fixture(t, { TEST_EXIT_CODE: "17" });
|
||||
const record = await run.record();
|
||||
assert.deepEqual(await run.exited, [17, null], run.output());
|
||||
await until(() => !alive(record.childPid));
|
||||
await assert.rejects(fetch(overrides(record).build.devUrl));
|
||||
});
|
||||
|
||||
test("SIGTERM shuts down the complete process tree", { timeout: 40000 }, async (t) => {
|
||||
const run = await fixture(t);
|
||||
const record = await run.record();
|
||||
run.proc.kill("SIGTERM");
|
||||
assert.deepEqual(await run.exited, [143, null], run.output());
|
||||
await until(() => !alive(record.pid) && !alive(record.childPid));
|
||||
await assert.rejects(fetch(overrides(record).build.devUrl));
|
||||
});
|
||||
|
||||
test("invalid ports fail before starting Tauri", { timeout: 40000 }, async (t) => {
|
||||
const run = await fixture(t, { ARTCRAFT_DEV_PORT: "65536" });
|
||||
assert.deepEqual(await run.exited, [1, null]);
|
||||
assert.match(run.output(), /ARTCRAFT_DEV_PORT must be an integer/);
|
||||
});
|
||||
|
||||
test("concurrent launchers use different ports without replacing either frontend", { timeout: 40000 }, async (t) => {
|
||||
const occupied = await occupyPort(t);
|
||||
const env = { ARTCRAFT_DEV_PORT: String(occupied.address().port) };
|
||||
const first = await fixture(t, env);
|
||||
const second = await fixture(t, env);
|
||||
const [a, b] = await Promise.all([first.record(), second.record()]);
|
||||
const firstUrl = overrides(a).build.devUrl;
|
||||
const secondUrl = overrides(b).build.devUrl;
|
||||
assert.notEqual(firstUrl, secondUrl);
|
||||
assert.equal((await fetch(firstUrl)).status, 200);
|
||||
assert.equal((await fetch(secondUrl)).status, 200);
|
||||
first.proc.kill("SIGINT");
|
||||
await first.exited;
|
||||
assert.equal((await fetch(secondUrl)).status, 200);
|
||||
assert.ok(alive(b.pid));
|
||||
});
|
||||
|
||||
test("a React source edit sends HMR on the selected port without restarting Rust", { timeout: 40000 }, async (t) => {
|
||||
const run = await fixture(t);
|
||||
const record = await run.record();
|
||||
const url = overrides(record).build.devUrl;
|
||||
const probeDir = await mkdtemp(path.join(rootDir, "frontend/apps/artcraft/app/src/dev-probe-"));
|
||||
t.after(() => rm(probeDir, { recursive: true, force: true }));
|
||||
const probe = path.join(probeDir, "Probe.tsx");
|
||||
const moduleUrl = `/src/${path.basename(probeDir)}/Probe.tsx`;
|
||||
await writeFile(probe, "export default function Probe() { return <div>before</div>; }\n");
|
||||
const code = await (await fetch(`${url}${moduleUrl}`)).text();
|
||||
assert.match(code, /\$RefreshReg\$/);
|
||||
|
||||
const client = await (await fetch(`${url}/@vite/client`)).text();
|
||||
const token = client.match(/const wsToken = "([^"]+)"/)[1];
|
||||
const ws = new WebSocket(`${url.replace("http:", "ws:")}/?token=${token}`, "vite-hmr");
|
||||
t.after(() => ws.terminate());
|
||||
const messages = [];
|
||||
ws.on("message", (data) => messages.push(JSON.parse(data.toString())));
|
||||
await once(ws, "open");
|
||||
await writeFile(probe, "export default function Probe() { return <div>after</div>; }\n");
|
||||
await until(() => messages.some((m) => m.type === "update" && m.updates.some((u) => u.path === moduleUrl)));
|
||||
assert.match(await (await fetch(`${url}${moduleUrl}`)).text(), /after/);
|
||||
assert.ok(alive(record.pid), "JS edits must leave the Rust watcher running");
|
||||
assert.equal((await run.record()).pid, record.pid);
|
||||
});
|
||||
|
||||
test("Rust watcher arguments include SQLx inputs and preserve caller overrides", { timeout: 40000 }, async (t) => {
|
||||
const run = await fixture(t, { SQLX_OFFLINE: "false", RUSTFLAGS: "-C debuginfo=1" });
|
||||
const record = await run.record();
|
||||
assert.equal(record.env.SQLX_OFFLINE, "false");
|
||||
assert.equal(record.env.RUSTFLAGS, "-C debuginfo=1");
|
||||
assert.ok(record.args.includes(path.join(rootDir, ".sqlx")));
|
||||
assert.ok(record.args.includes(path.join(rootDir, "_database/sql/artcraft_migrations")));
|
||||
const ignores = await readFile(path.join(rootDir, "crates/desktop/artcraft/.taurignore"), "utf8");
|
||||
assert.doesNotMatch(ignores, /^(crates\/|Cargo\.toml|Cargo\.lock)$/m);
|
||||
});
|
||||
|
||||
async function until(check) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const result = await check();
|
||||
if (result) return result;
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error("Timed out waiting for dev launcher");
|
||||
}
|
||||
|
||||
async function fixture(t, env = {}) {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "artcraft-dev-test-"));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
await writeFile(path.join(dir, "cargo"), `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const { spawn } = require("node:child_process");
|
||||
if (process.argv.includes("--version")) process.exit(0);
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||
fs.writeFileSync(process.env.TEST_RECORD, JSON.stringify({
|
||||
args: process.argv.slice(2), cwd: process.cwd(),
|
||||
env: Object.fromEntries(["TAURI_APP_PATH", "TAURI_FRONTEND_PATH", "SQLX_OFFLINE", "RUSTFLAGS"].map(key => [key, process.env[key]])),
|
||||
pid: process.pid, childPid: child.pid,
|
||||
}));
|
||||
if (process.env.TEST_EXIT_CODE) setTimeout(() => process.exit(Number(process.env.TEST_EXIT_CODE)), 200);
|
||||
setInterval(() => {}, 1000);
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const recordFile = path.join(dir, "record.json");
|
||||
const proc = spawn(launcher, [], {
|
||||
// Prove the launcher is independent of the invoking working directory.
|
||||
cwd: dir,
|
||||
env: { ...process.env, ...env, PATH: `${dir}:${process.env.PATH}`, TEST_RECORD: recordFile },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
proc.stdout.on("data", (chunk) => { output += chunk; });
|
||||
proc.stderr.on("data", (chunk) => { output += chunk; });
|
||||
const exited = once(proc, "exit");
|
||||
t.after(async () => {
|
||||
if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM");
|
||||
await exited;
|
||||
});
|
||||
return {
|
||||
proc, exited, output: () => output,
|
||||
async record() {
|
||||
return until(async () => {
|
||||
if (proc.exitCode !== null) throw new Error(output);
|
||||
try { return JSON.parse(await readFile(recordFile, "utf8")); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function occupyPort(t) {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
return server;
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
try { process.kill(pid, 0); return true; }
|
||||
catch (error) { if (error.code === "ESRCH") return false; throw error; }
|
||||
}
|
||||
|
||||
function overrides(record) {
|
||||
return JSON.parse(record.args.at(-1));
|
||||
}
|
||||
Reference in New Issue
Block a user