testBuildStartDeployRollBack

tcmts.cli.test.e2e.prod.testBuildStartDeployRollBack · #jxew2xzd · term

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",
    );
  })

uses