testBuildStartDeployRollBack
tcm build / start: deploy and roll back by moving a name; open tabs keep working
import { plainEnv, tcmBin, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import { call, get, loadTab, setUp, waitFor } from "#codebase/tcmts/cli/test/e2e/prod"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import os from "node:os"; export const testBuildStartDeployRollBack = () => withChildren(async (keep) => { const { dir, tcm, scratch } = setUp(); scratch(`// tcm: namespace app import { hostname } from "node:os"; export async function greeting(name: string): Promise<string> { return "hello " + name + " from " + hostname(); } export async function main() { document.querySelector("#app")!.textContent = await greeting("ada"); } `); tcm("add"); const built = /built app\.main #([a-z2-7]{8})/.exec(tcm("build", "app.main"))![1]!; const child: ChildProcess = keep( spawn(process.execPath, [tcmBin(), "start", "app.main", "--port", "0"], { cwd: dir, env: plainEnv(), }), ); let out = ""; child.stdout!.setEncoding("utf8").on("data", (d: string) => (out += d)); child.stderr!.setEncoding("utf8").on("data", (d: string) => (out += d)); await waitFor(() => /url\s+http:\/\/\S+/.test(out)); const url = /url\s+(http:\/\/\S+)/.exec(out)![1]!; // v1, as an open tab sees it const v1 = await loadTab(url); assert.ok(!v1.js.includes("hostname"), "server code isn't in the production bundle"); assert.equal((await call(url, v1.endpoint, ["ada"])).body, `"hello ada from ${os.hostname()}"`); // public endpoints check their arguments against the parameter types const bad = await call(url, v1.endpoint, [42]); assert.equal(bad.status, 400); assert.match(bad.body, /argument `name` should be a string, got number 42/); assert.match( (await call(url, v1.endpoint, ["ada", "extra"])).body, /expected at most 1 argument/, ); // deploy: update the server function; the server builds and switches by itself // (still using Node, so it stays on the server; without hostname() it would move into the browser bundle) scratch( `// tcm: namespace app\nimport { hostname } from "node:os";\nexport async function greeting(name: string): Promise<string> { return "hi " + name + " (v2 on " + hostname() + ")"; }\n`, ); tcm("update"); await waitFor(() => /deployed app\.main/.test(out)); const v2 = await loadTab(url); assert.notEqual(v2.asset, v1.asset); assert.equal((await call(url, v2.endpoint, ["ada"])).body, `"hi ada (v2 on ${os.hostname()})"`); // the tab opened before the deploy keeps working against the code it was built with assert.equal((await get(new URL(v1.asset, url))).status, 200); assert.equal((await call(url, v1.endpoint, ["ada"])).body, `"hello ada from ${os.hostname()}"`); // roll back by moving the names back: the v1 build is reused // the history entry that changed the greeting, and the one before it const entries = tcm("history", "50").split(/\n(?=#)/); const changed = entries.findIndex((e) => /update app\.greeting/.test(e)); const before = String(changed + 1); tcm("reset", before); await waitFor(async () => (await loadTab(url)).asset === v1.asset); // pruning: keep only the current build; a tab still on the removed v2 build is told to reload (410) const apps = tcm("apps"); assert.equal(apps.trim().split("\n").length, 2, "both builds are listed"); assert.match( apps, new RegExp(`\\* app\\.main\\s+#${built}`), "the rolled-back-to build is current", ); assert.match(tcm("apps", "prune", "--keep", "0"), /removed 1 build/); await waitFor(async () => (await call(url, v2.endpoint, ["ada"])).status === 410); assert.equal((await get(new URL(v2.asset, url))).status, 404); assert.equal( (await call(url, v1.endpoint, ["ada"])).status, 200, "the current build is untouched", ); })