196 lines
12 KiB
TypeScript
196 lines
12 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createServer, type Server } from "node:http";
|
|
import { resolve } from "node:path";
|
|
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
|
|
|
// Opt in: real isolated PostgreSQL/Qdrant containers and the Python harness are required.
|
|
// Auth, browser, Fastify, ThtRunner and Memory persistence are production code.
|
|
// Embeddings are deterministic; unrelated sessions and Pi use the existing fixture.
|
|
test.skip(process.env.THT_MEMORY_BROWSER_E2E !== "1", "Set THT_MEMORY_BROWSER_E2E=1");
|
|
test.setTimeout(180_000);
|
|
test.use({ actionTimeout: 15_000, viewport: { width: 1440, height: 1000 } });
|
|
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
|
|
let embedding: Server;
|
|
const containers: string[] = [];
|
|
const root = resolve("..");
|
|
let databaseUrl: string;
|
|
let qdrantUrl: string;
|
|
|
|
function command(bin: string, args: string[], env = process.env) {
|
|
return execFileSync(bin, args, { cwd: root, env, encoding: "utf8", timeout: 60_000 }).trim();
|
|
}
|
|
function container(image: string, port: number, args: string[] = []) {
|
|
const id = command("docker", ["run", "-d", "--rm", "-p", `127.0.0.1::${port}`, ...args, image]);
|
|
containers.push(id);
|
|
return { id, port: Number(command("docker", ["port", id, `${port}/tcp`]).split(":").at(-1)) };
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
const pg = container("postgres:16-alpine", 5432, ["-e", "POSTGRES_PASSWORD=memory-test"]);
|
|
const qdrant = container("qdrant/qdrant:v1.18.2", 6333);
|
|
qdrantUrl = `http://127.0.0.1:${qdrant.port}`;
|
|
await expect.poll(async () => { try { return (await fetch(`${qdrantUrl}/healthz`)).ok; } catch { return false; } }).toBe(true);
|
|
await expect.poll(() => {
|
|
// The image's temporary initialization server accepts sockets before restarting.
|
|
// TCP readiness identifies the final server and avoids racing that shutdown.
|
|
try { command("docker", ["exec", pg.id, "pg_isready", "-h", "127.0.0.1", "-U", "postgres"]); return true; } catch { return false; }
|
|
}).toBe(true);
|
|
command("docker", ["exec", pg.id, "psql", "-U", "postgres", "-c",
|
|
"CREATE ROLE thothii_catalog_runtime LOGIN PASSWORD 'memory-runtime-test'; " +
|
|
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO thothii_catalog_runtime; " +
|
|
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO thothii_catalog_runtime"]);
|
|
const migratorUrl = `postgresql://postgres:memory-test@127.0.0.1:${pg.port}/postgres`;
|
|
databaseUrl = `postgresql://thothii_catalog_runtime:memory-runtime-test@127.0.0.1:${pg.port}/postgres`;
|
|
command(resolve(root, "harness/.venv/bin/python"), ["-m", "tht.memory.migrate"],
|
|
{ ...process.env, THT_CATALOG_MIGRATOR_DATABASE_URL: migratorUrl });
|
|
command(resolve(root, "backend/node_modules/.bin/tsx"), ["backend/src/catalog/migrate.ts"],
|
|
{ ...process.env, THT_CATALOG_MIGRATOR_DATABASE_URL: migratorUrl });
|
|
embedding = createServer(async (request, response) => {
|
|
let body = "";
|
|
for await (const chunk of request) body += chunk;
|
|
const payload = JSON.parse(body || "{}");
|
|
const texts = Array.isArray(payload.input) ? payload.input : [payload.input];
|
|
response.setHeader("content-type", "application/json");
|
|
response.end(JSON.stringify({ embeddings: texts.map(() => [1, 0, 0]) }));
|
|
});
|
|
await new Promise<void>(done => embedding.listen(0, "127.0.0.1", done));
|
|
const address = embedding.address();
|
|
if (!address || typeof address === "string") throw new Error("embedding address missing");
|
|
stack = await createAuthenticationStack({ withF1Workspace: true, memoryRuntime: {
|
|
THT_E2E_MEMORY_BIN: resolve(root, "harness/.venv/bin/tht"),
|
|
THT_CATALOG_RUNTIME_DATABASE_URL: databaseUrl,
|
|
THT_CATALOG_DATABASE_URL: databaseUrl,
|
|
THT_INTERNAL_QDRANT_URL: qdrantUrl,
|
|
THT_INTERNAL_EMBEDDING_URL: `http://127.0.0.1:${address.port}`,
|
|
THT_INTERNAL_EMBEDDING_DIMENSIONS: "3",
|
|
} });
|
|
await stack.useLocalMode();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await stack?.close();
|
|
if (embedding) await new Promise<void>(done => embedding.close(() => done()));
|
|
for (const id of containers.reverse()) command("docker", ["rm", "-f", id]);
|
|
});
|
|
|
|
test("admin manages both archives through the real stack without cross-deletion", async ({ page }) => {
|
|
page.on("pageerror", error => console.error("Memory browser error:", error.message));
|
|
await page.goto(stack.publicUrl);
|
|
await page.getByLabel("Username").fill(stack.localAccount("admin").username);
|
|
await page.getByLabel("Password", { exact: true }).fill(stack.localAccount("admin").password);
|
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
await expect(page.getByTestId("app-shell")).toBeVisible();
|
|
// Pull only the temporary fixture registry through the authenticated application API.
|
|
const pulled = await page.evaluate(async () => {
|
|
const me = await (await fetch("/api/me")).json();
|
|
const result = await fetch("/api/workspace-registry/pull", { method: "POST",
|
|
headers: { "x-thothii-csrf": me.csrfToken } });
|
|
return { status: result.status, body: await result.json() };
|
|
});
|
|
expect(pulled.status, JSON.stringify(pulled.body)).toBe(200);
|
|
// This checkout belongs to the temporary authentication fixture, never the PSD archive.
|
|
command(resolve(root, "harness/.venv/bin/python"), ["-c", `
|
|
import os
|
|
from pathlib import Path
|
|
from tht.evidence.canonical import CuratedEvidence, dump_curated_markdown
|
|
from tht.evidence.local_archive import LocalEvidenceArchive
|
|
root = Path(os.environ["FIXTURE_ARCHIVE_ROOT"])
|
|
unit = CuratedEvidence.model_validate({"schema_version":4,"id":"evidence:order-key",
|
|
"kind":"domain","title":"Order key Evidence","language":"en","purposes":["sql_generation"],
|
|
"payload":{"rule":"Use order_id and financial_year when joining order lines."},
|
|
"provenance":{"kind":"manual","declared_by":"Acceptance fixture"}})
|
|
path = root / "evidence/curated/domain/order-key.md"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(dump_curated_markdown(unit))
|
|
LocalEvidenceArchive(root).initialize()
|
|
`], { ...process.env, FIXTURE_ARCHIVE_ROOT: stack.fixtureWorkspaceRoot() });
|
|
async function openMemory() {
|
|
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
|
expect((await page.getByRole("button", {
|
|
name: /^(Database management|Memory management|Evidence management)$/,
|
|
}).allTextContents()).map(text => text.trim())).toEqual([
|
|
"Database management", "Memory management", "Evidence management",
|
|
]);
|
|
await page.getByRole("button", { name: "Memory", exact: true }).click();
|
|
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
|
|
const memory = page.getByRole("main", { name: "Memory management" });
|
|
await expect(memory).toBeVisible();
|
|
await memory.getByLabel("Workspace", { exact: true }).selectOption("fixture-workspace");
|
|
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeVisible();
|
|
}
|
|
await openMemory();
|
|
await page.getByRole("button", { name: "New card", exact: true }).click();
|
|
await page.getByLabel("Title", { exact: true }).fill("Orders approved");
|
|
await page.getByLabel("Scope", { exact: true }).fill("Sales");
|
|
await page.getByLabel("Content", { exact: true }).fill("Only shipped orders");
|
|
await page.getByRole("button", { name: "Save card", exact: true }).click();
|
|
await expect(page.getByText("Saved and indexed.", { exact: true })).toBeVisible();
|
|
await page.getByRole("button", { name: "Edit card", exact: true }).click();
|
|
await page.getByLabel("Content", { exact: true }).fill("Only fully shipped orders");
|
|
await page.getByRole("button", { name: "Save card", exact: true }).click();
|
|
await expect(page.getByText("Only fully shipped orders", { exact: true })).toBeVisible();
|
|
await stack.restartBackend();
|
|
await page.reload();
|
|
await openMemory();
|
|
await page.getByRole("button", { name: /Orders approved Domain clarification/ }).click();
|
|
await expect(page.getByText("Only fully shipped orders", { exact: true })).toBeVisible();
|
|
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-desktop.png", fullPage: true });
|
|
await page.getByRole("button", { name: "Delete card", exact: true }).click();
|
|
await page.getByRole("button", { name: "Confirm deletion", exact: true }).click();
|
|
await expect(page.getByText("Card deleted and removed from recall.", { exact: true })).toBeVisible();
|
|
const recalled = command(resolve(root, "harness/.venv/bin/python"), ["-c", `
|
|
import json, os
|
|
from tht.memory.runtime import admin_service
|
|
from tht.adapters.vector.qdrant import QdrantVectorStore
|
|
runtime = {"internalQdrantUrl": os.environ["QDRANT_URL"], "internalEmbeddingDimensions": 3}
|
|
service = admin_service("fixture-workspace", runtime)
|
|
store = QdrantVectorStore(base_url=runtime["internalQdrantUrl"], workspace_id="fixture-workspace",
|
|
collections={"reference":"fixture-workspace-reference", "memory":"fixture-workspace-memory"}, expected_dimension=3)
|
|
class Searcher:
|
|
def search(self, vector, top_n, kinds, **kwargs): return store.search(["memory"], vector, limit=top_n, kinds=kinds, **kwargs)
|
|
class Embedder:
|
|
def embed_query(self, text): return [1,0,0]
|
|
try: print(json.dumps(service.recall("orders", searcher=Searcher(), embedder=Embedder())))
|
|
finally: service.close()
|
|
`], { ...process.env, THT_CATALOG_RUNTIME_DATABASE_URL: databaseUrl, QDRANT_URL: qdrantUrl,
|
|
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
|
|
expect(JSON.parse(recalled)).toEqual([]);
|
|
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled();
|
|
if (!await page.getByRole("button", { name: "Evidence", exact: true }).isVisible())
|
|
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
|
await page.getByRole("button", { name: "Evidence", exact: true }).click();
|
|
const evidence = page.getByRole("main", { name: "Evidence management" });
|
|
await expect(evidence).toBeVisible();
|
|
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace");
|
|
await evidence.getByRole("button", { name: "Order key Evidence", exact: true }).click();
|
|
await expect(evidence.getByRole("region", { name: "Evidence detail" })).toContainText(
|
|
"Use order_id and financial_year when joining order lines.");
|
|
await evidence.getByLabel("Search Evidence", { exact: true }).fill("no matching knowledge");
|
|
await expect(evidence.getByText(/No Evidence matches these filters/)).toBeVisible();
|
|
await evidence.getByLabel("Search Evidence", { exact: true }).fill("financial_year");
|
|
await expect(evidence.getByRole("button", { name: "Order key Evidence", exact: true })).toBeVisible();
|
|
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-desktop.png", fullPage: true });
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeVisible();
|
|
await expect(page.getByRole("complementary", { name: "Session navigation" })).toBeHidden();
|
|
expect((await evidence.boundingBox())!.width).toBeGreaterThanOrEqual(380);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
|
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
|
|
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
|
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true });
|
|
await expect(navigation.getByRole("button", { name: "Memory", exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(navigation).toBeHidden();
|
|
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
|
|
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
|
await navigation.getByRole("button", { name: "Memory", exact: true }).click();
|
|
await expect(navigation).toBeHidden();
|
|
const mobileMemory = page.getByRole("main", { name: "Memory management" });
|
|
await expect(mobileMemory).toBeVisible();
|
|
expect((await mobileMemory.boundingBox())!.width).toBeGreaterThanOrEqual(380);
|
|
const unknown = await page.evaluate(async () => Promise.all(["memory", "evidence"].map(async archive =>
|
|
(await fetch(`/api/workspaces/unregistered-workspace/${archive}`)).status)));
|
|
expect(unknown).toEqual([404, 404]);
|
|
});
|