testAppsAndDataInEditor

tcmts.cli.test.e2e.lsp.testAppsAndDataInEditor · #67sm466y · term

apps and data in the editor: roles, an app's parts, tables, rows, migrating

import { plainEnv, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { client } from "#codebase/tcmts/cli/test/e2e/lsp";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";

export const testAppsAndDataInEditor = () =>
  withChildren(async (keep) => {
    const dir3 = tempDir("lsp3");
    const tcm = (...args: string[]) =>
      spawnSync(process.execPath, [tcmBin(), ...args], {
        cwd: dir3,
        env: plainEnv(),
        encoding: "utf8",
      });
    tcm("init");
    assert.equal(tcm("lib", "add", "tcm", "server", "--at", "lib.server").status, 0);
    fs.writeFileSync(
      path.join(dir3, "scratch.ts"),
      `// tcm: namespace shop
import { table, every } from "#codebase/lib/server";
export type Item = { name: string; price: number };
export const items = table<Item>("items", { migrate: (old: { name: string }) => ({ name: old.name, price: 0 }) });
export async function listItems(): Promise<string[]> {
  return (await items.find()).map((r) => r.value.name);
}
export async function cleanup(): Promise<void> {}
export const nightly = every("0 3 * * *", cleanup);
export const format = (names: string[]) => names.join(", ");
export async function main() {
  document.body.textContent = format(await listItems());
}
export const testFormat = () => {
  if (format(["a", "b"]) !== "a, b") throw new Error("bad");
};
`,
    );
    assert.equal(tcm("add").status, 0, tcm("add").stdout);
    fs.writeFileSync(path.join(dir3, "scratch.ts"), "");
    // rows an app wrote: one of the current type, one of an older one
    const schema = JSON.parse(
      spawnSync(
        "sqlite3",
        [
          path.join(dir3, ".tcm", "codebase.sqlite"),
          "select value from metadata where key = 'table' and value like '%\"name\":\"items\"%'",
        ],
        { encoding: "utf8" },
      ).stdout,
    ).hash;
    fs.mkdirSync(path.join(dir3, ".tcm", "data"), { recursive: true });
    const store = new DatabaseSync(path.join(dir3, ".tcm", "data", "store.sqlite"));
    store.exec(`CREATE TABLE rows (tbl TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, schema_hash TEXT NOT NULL, updated INTEGER NOT NULL, PRIMARY KEY (tbl, id));
    CREATE TABLE schemas (tbl TEXT NOT NULL, hash TEXT NOT NULL, schema TEXT NOT NULL, first_seen INTEGER NOT NULL, PRIMARY KEY (tbl, hash));`);
    store
      .prepare("INSERT INTO rows VALUES ('items', 'a1', ?, ?, 2)")
      .run(JSON.stringify({ name: "apple", price: 3 }), schema);
    store
      .prepare("INSERT INTO rows VALUES ('items', 'p1', ?, 'v0', 1)")
      .run(JSON.stringify({ name: "pear" }));
    store.close();

    const c = client(keep);
    await c.request("initialize", {
      processId: null,
      rootUri: pathToFileURL(dir3).href,
      capabilities: {},
      clientInfo: { name: "Visual Studio Code" },
    });
    c.notify("initialized", {});

    // each definition says what it's for
    const shop = await c.request("tcm/tree", { namespace: "shop" });
    const roles = Object.fromEntries(shop.definitions.map((d: any) => [d.name, d.role]));
    assert.deepEqual(roles, {
      Item: "type",
      cleanup: "shared",
      format: "shared",
      items: "table",
      listItems: "server",
      main: "app",
      nightly: "schedule",
      testFormat: "test",
    });
    assert.equal(shop.definitions.find((d: any) => d.name === "listItems").endpoint, true);
    assert.equal(
      shop.definitions.find((d: any) => d.name === "testFormat").place,
      undefined,
      "a test's placement is noise",
    );

    // the app, and what it's made of
    const [app] = await c.request("tcm/apps", {});
    assert.equal(app.path, "shop.main");
    // its page says it's an app (the editor starts it, rather than running it); other definitions' don't
    assert.equal((await c.request("tcm/page", { path: "shop.main" })).app, true);
    assert.equal((await c.request("tcm/page", { path: "shop.format" })).app, false);
    assert.deepEqual(
      app.endpoints.map((e: any) => e.path),
      ["shop.listItems"],
    );
    assert.deepEqual(
      app.browser.map((e: any) => e.path),
      ["shop.format", "shop.main"],
    );
    assert.deepEqual(
      app.tables.map((t: any) => [t.table, t.rows]),
      [["items", 2]],
    );
    assert.deepEqual(
      app.schedules.map((s: any) => [s.path, s.spec]),
      [["shop.nightly", "0 3 * * *"]],
    );
    assert.deepEqual(
      app.tests.map((t: any) => t.path),
      ["shop.testFormat"],
    );

    // the data: tables, a page of rows, a row in full
    const [items] = await c.request("tcm/data", {});
    assert.deepEqual(
      [items.table, items.rows, items.older, items.path],
      ["items", 2, 1, "shop.items"],
    );
    // a page for the table viewer: typed columns, cells, older rows marked; sorted and searched in SQL
    const page = await c.request("tcm/rows", { table: "items", offset: 0, limit: 10 });
    assert.equal(page.total, 2);
    assert.deepEqual(page.fields, [
      { name: "name", type: "string" },
      { name: "price", type: "number" },
    ]);
    assert.deepEqual(
      page.rows.map((r: any) => [r.id, r.cells.name, r.cells.price, r.older]),
      [
        ["a1", "apple", "3", false],
        ["p1", "pear", "", true],
      ],
    );
    const byName = await c.request("tcm/rows", {
      table: "items",
      offset: 0,
      limit: 10,
      sort: { field: "name", desc: true },
    });
    assert.deepEqual(
      byName.rows.map((r: any) => r.id),
      ["p1", "a1"],
    );
    const found = await c.request("tcm/rows", {
      table: "items",
      offset: 0,
      limit: 10,
      search: "APP",
    });
    assert.deepEqual([found.total, found.rows.map((r: any) => r.id)], [1, ["a1"]]);
    const rows = page.rows;
    const row = await c.request("tcm/source", { uri: rows[0].uri });
    assert.match(row.text, /^\/\/ table "items", row a1: written .* as row type #/);
    assert.match(
      (await c.request("tcm/source", { uri: "tcm:/.data/items.txt" })).text,
      /\/\/ table "items": 2 rows\n\na1  \{ name: 'apple', price: 3 \}\np1/,
    );

    // migrate the older row from the editor; the client hears the data changed
    c.notes.length = 0;
    assert.equal(
      await c.request("workspace/executeCommand", {
        command: "tcm.dataMigrate",
        arguments: ["items"],
      }),
      true,
    );
    await c.waitFor(() => c.notes.find((n) => n.method === "tcm/dataChanged"));
    const after = await c.request("tcm/data", {});
    assert.equal(after[0].older, 0);
    assert.equal(
      (await c.request("tcm/rows", { table: "items", offset: 0, limit: 10 })).rows.find(
        (r: any) => r.id === "p1",
      ).summary,
      "{ name: 'pear', price: 0 }",
    );
  })

uses