Files
ThothII/frontend/e2e/memory-real.spec.ts
T

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]);
});