tidaldb/tests/e2e/app/harness.ts
jordan 9523f6da43 test(e2e): verify ranking semantics with a content-feed app, and route three product findings
The existing 32 checks prove the deployment answers -- TLS, auth, quorum commit,
convergence, isolation, dashboards, backups. Not one wrote a signal and observed
an order change, so VISION.md:17 "Ranking is not a feature. It is a primitive."
was unverified. This adds a 60-item content-feed app and five assertions that
verify the product's semantics, on a hermetic standalone node.

Added
- tests/e2e/app/: fixture contract (60 items, 4 categories, each owning one
  unoccupied 100-id embedding cluster), a deep-module harness owning the whole
  lifecycle behind startApp(), the product page, and an app:dev entry point.
- tidal-stress/src/bin/feed-fixture.rs: seeds the catalog and emits brute-force
  ground truth, reusing recall::embedding_for rather than adding a third copy of
  the corpus generator (tidal/src/db/items.rs already holds a second).
- GroundTruth::from_ids: the oracle now serves sparse id sets. build() delegates,
  so there is no transient copy even at 1M, and top_k indexes positionally.
- 10-ranking-semantics.spec.ts (5 hermetic checks) and
  11-ranking-integrity.spec.ts (2 cluster tripwires).
- playwright.semantics.config.ts + CAP-016 demo beat (walkthrough 82s -> 90s).

Measured, not merely green
- like: index 59 -> 0, like_boost 2.0, with no sleep between write and read.
- decay: implied half-lives 7.0007 d and 14.0014 d against a schema declaring
  7 d and 14 d, recovered from a 4-second window via H = t*ln2 / -ln(v2/v1) and
  compared against the schema the node actually loaded, not a hardcoded copy.
- ANN: top-10 identical to brute-force cosine on all four probes; self-distance
  0.0148-0.0197 against a 0.05 tolerance.
- rank: dense 1..60 on standalone vs [1,1,1,2,2,3,4,3,4,5,6,5] on the cluster.

Three product findings, pinned and routed to @tidal-engineer
- BUG-018 (High) skip is durably accepted and query-time inert. Penalty is fully
  implemented (ranking/profile.rs:227 -> executor/signal_values.rs:183, labelled
  {signal}_penalty at executor/mod.rs:65) but skeleton() sets penalties: vec![]
  (ranking/builtins.rs:62) and none of the 27 built-ins overrides it. So
  VISION.md:187 "negative signals are equal citizens" holds for no shipped
  profile. Same anti-pattern as the reseed defects and scatter_merge: a guard
  present on one path, absent on its sibling.
- BUG-019 (Medium) three built-ins read signals this schema does not declare --
  trending/share_velocity, hidden_gems/completion, controversial/dislike -- so
  those terms are permanently 0 and trending ranks on view_velocity alone.
- BUG-020 (Low) for_you declares Scan{sort_field:"created_at"} but ignores a
  created_at metadata value; an order matching neither id-asc nor
  created_at-desc came back strictly id-ascending.

Two assertions therefore report a gap rather than a success, written as tripwires
whose failure message says what to do when the gap closes. The rank defect is
localised, not fixed: scatter_merge (cluster/node.rs:7542) returns a merged slice
without re-stamping rank while scores stay correctly ordered, so the fault is the
missing stamp and not the merge's sort.

Notes
- Hermetic by construction: its own config, because FullConfig.projects is not
  filtered by --project and globalSetup publishes credentials into the main
  process that forked workers inherit -- so a setup project cannot replace it,
  and weakening globalSetup would destroy the fail-loud behaviour that is its
  purpose. Verified with KUBECONFIG=/nonexistent and all E2E_* unset.
- Never touches the deployed corpus: skip is permanent: true, so seeding it into
  production would be irreversible.
- The page contains no sort, no hostname and no credential; the harness proxy
  injects auth server-side so no bearer reaches a browser or a capture.
- Schema comes from k8s/cluster/schema-configmap.yaml, asserted at 1536 dims;
  tidal-server/config/default-schema.yaml declares 128 and would 422 every write.

Verification: 5 semantics + 34 regression + 10 demo captures green; tsc clean;
tidal-stress clippy clean under clippy::all=deny with unwrap_used=deny; 2101
tidaldb lib tests; preflight 10/10 perfect; render 90.05s/2700 frames with zero
empty boundary frames; zero orphan processes or temp dirs after teardown.
2026-08-23 22:42:02 -06:00

517 lines
18 KiB
TypeScript

/**
* The content-feed app's lifecycle, in one place.
*
* `startApp()` boots a throwaway standalone tidalDB, seeds the fixture catalog,
* serves the page plus a token-injecting `/api` proxy, and hands back a handle
* whose `close()` unwinds all of it. The semantic spec, the demo capture and
* `npm run app:dev` all drive the identical lifecycle, so what a human sees by
* hand is what the assertions ran against.
*
* Two rules shape the design:
*
* - **Relative `/api` only.** `.sdlc/guidance.md:165` forbids a hardcoded
* `http://localhost:PORT` in frontend code and prescribes a dev-server proxy.
* Independently, a bearer must never reach a browser — a page holding the key
* leaks it to anyone who opens devtools, and the demo capture would photograph
* it. The proxy satisfies both: the page calls `/api/feed`, the server adds
* `Authorization`, the key stays server-side.
* - **Poll, never sleep.** A fixed sleep either wastes time or produces the
* empty-body false negative that reads exactly like a dead node. This is the
* same discipline as `tests/e2e/support/cluster.ts:154` `portForward`.
*
* It targets a LOCAL standalone node, never the deployed cluster: `skip` is
* declared `permanent: true`, so seeding signals into production would be an
* irreversible mutation of the live corpus.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createConnection } from 'node:net';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { extname, join, resolve, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { run } from '../support/cluster.ts';
import { redact } from '../support/env.ts';
import {
CATALOG,
EMBEDDING_DIM,
PROBE_IDS,
type FixtureGroundTruth,
} from './fixture-contract.ts';
const REPO_ROOT = resolve(import.meta.dirname, '../../..');
const PUBLIC_DIR = join(import.meta.dirname, 'public');
const SCHEMA_CONFIGMAP = join(REPO_ROOT, 'k8s/cluster/schema-configmap.yaml');
const SERVER_BIN = join(REPO_ROOT, 'target/debug/tidal-server');
const FIXTURE_BIN = join(REPO_ROOT, 'target/debug/feed-fixture');
const BOOT_TIMEOUT_MS = 60_000;
const BUILD_TIMEOUT_MS = 900_000;
const SEED_TIMEOUT_MS = 120_000;
const KILL_GRACE_MS = 3_000;
/**
* Ask the OS for a free port rather than computing one from a base.
*
* A fixed base collides with whatever is already holding it — a leftover node, a
* second checkout, or `npm run app:dev` running in another terminal while the
* suite runs. Letting the kernel choose removes that whole class of failure. The
* handover window between close and the child's bind is microseconds, and a lost
* race surfaces as the child's own "address in use" stderr rather than a hang.
*/
async function freePort(): Promise<number> {
const probe = createServer();
const { promise, resolve: settle, reject } = Promise.withResolvers<number>();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
probe.close(() => settle(port));
});
return promise;
}
export type RunningApp = {
/** The app origin: serves the page and proxies `/api/*`. */
url: string;
/** The tidalDB node itself, for probes that bypass the app. */
nodeUrl: string;
/** The oracle the fixture binary emitted for this exact corpus. */
groundTruth: FixtureGroundTruth;
/** Decay as declared by the schema THIS node loaded, not a copy of it. */
declaredDecay: DeclaredDecay;
/**
* The throwaway data dir. Exposed so a caller can say where a run's state went,
* and so a long-lived host can install a synchronous last-resort cleanup — an
* async `close()` that throws would otherwise leave the dir behind.
*/
dataDir: string;
close: () => Promise<void>;
};
export type StartAppOptions = {
/** Bearer to inject into proxied requests. A local standalone needs none. */
apiKey?: string;
/** Log lifecycle progress to stdout — for `npm run app:dev`, quiet in tests. */
verbose?: boolean;
};
/**
* Extract the schema the DEPLOYED cluster loads, from its ConfigMap wrapper.
*
* Not interchangeable with `tidal-server/config/default-schema.yaml`: that file is
* otherwise identical but declares `dimensions: 128`, while the cluster declares
* 1536. Seeding 1536-wide vectors against a 128-wide slot is rejected with a 422
* that reads like a malformed body, so the dimension is asserted here rather than
* discovered three layers downstream.
*/
async function extractSchema(): Promise<string> {
const raw = await readFile(SCHEMA_CONFIGMAP, 'utf8');
const marker = ' schema.yaml: |\n';
const start = raw.indexOf(marker);
if (start < 0) {
throw new Error(`${SCHEMA_CONFIGMAP} has no ' schema.yaml: |' block scalar to extract`);
}
const body = raw.slice(start + marker.length);
const schema = body
.split('\n')
.map((line) => (line.startsWith(' ') ? line.slice(4) : line))
.join('\n');
if (!schema.startsWith('signals:')) {
throw new Error(`extracted schema does not start with 'signals:'; got: ${schema.slice(0, 80)}`);
}
if (!new RegExp(`dimensions:\\s*${EMBEDDING_DIM}\\b`).test(schema)) {
throw new Error(
`extracted schema does not declare dimensions: ${EMBEDDING_DIM}. The fixture ` +
`contract and the schema must agree or every embedding write is rejected as 422.`,
);
}
return schema;
}
/**
* The decay each signal declares, read out of the schema the node actually
* loaded.
*
* The decay assertion compares an OBSERVED decay rate against a DECLARED
* half-life. Hardcoding the declared value in the test would make it a
* tautology the moment the schema changed, so it is parsed from the same string
* that was handed to `--schema`.
*
* A deliberately small parser rather than a YAML dependency: the shape it reads
* is four lines of a file this repo owns, and it fails loudly (returns nothing
* for a signal) rather than guessing.
*/
export type DeclaredDecay = Record<string, number | 'permanent'>;
function parseDeclaredDecay(schema: string): DeclaredDecay {
const declared: DeclaredDecay = {};
let current: string | undefined;
for (const line of schema.split('\n')) {
const name = /^\s*-\s*name:\s*(\S+)/.exec(line);
if (name?.[1]) {
current = name[1];
continue;
}
if (current === undefined) continue;
const halfLife = /^\s*half_life_seconds:\s*(\d+)/.exec(line);
if (halfLife?.[1]) {
declared[current] = Number(halfLife[1]);
continue;
}
if (/^\s*permanent:\s*true/.test(line)) declared[current] = 'permanent';
}
return declared;
}
/** True once something accepts a TCP connection on the port. */
async function portAccepts(port: number): Promise<boolean> {
const { promise, resolve: settle } = Promise.withResolvers<boolean>();
const socket = createConnection({ port, host: '127.0.0.1' });
const done = (ok: boolean) => {
socket.destroy();
settle(ok);
};
socket.setTimeout(1_000);
socket.once('connect', () => done(true));
socket.once('timeout', () => done(false));
socket.once('error', () => done(false));
return promise;
}
/** True once the node answers `/health` with 2xx. */
async function healthOk(nodeUrl: string): Promise<boolean> {
try {
const response = await fetch(`${nodeUrl}/health`, {
signal: AbortSignal.timeout(2_000),
});
return response.ok;
} catch {
return false;
}
}
const buildsInFlight = new Map<string, Promise<void>>();
/**
* Build a cargo binary if it is not already present, once per process.
*
* `feed-fixture` is new, so no existing tree has it; failing with "binary
* missing, go run cargo" would make the hermetic suite un-runnable from a clean
* checkout for no reason. Dependencies are already compiled, so this is seconds,
* not minutes — but the timeout allows for a cold cache.
*/
function ensureBinary(binPath: string, cargoArgs: string[], verbose: boolean): Promise<void> {
if (existsSync(binPath)) return Promise.resolve();
const existing = buildsInFlight.get(binPath);
if (existing) return existing;
const build = (async () => {
if (verbose) console.log(`[app] building ${binPath} (missing)`);
const result = await run('cargo', cargoArgs, { timeoutMs: BUILD_TIMEOUT_MS });
if (result.code !== 0 || !existsSync(binPath)) {
throw new Error(
`could not build ${binPath}\n$ ${result.command}\nexit ${result.code}\n${result.stderr}`,
);
}
})();
buildsInFlight.set(binPath, build);
return build;
}
/** MIME types for the handful of things the page is made of. */
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
/**
* Read the whole request body as text.
*
* Text rather than bytes because the only thing the page ever sends is one small
* JSON object, and `string` is unambiguously a `BodyInit` — TS's `BufferSource`
* is `ArrayBufferView<ArrayBuffer>`, which a pooled Node `Buffer` does not
* satisfy. If this ever needs to forward binary, that is a real change, not a cast.
*/
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
/**
* Forward one `/api/*` request to the node, adding the bearer.
*
* A byte pipe: it must never reorder, re-score or filter a response, because the
* ordering IS the thing under test (`CODING_GUIDELINES.md:88`).
*/
async function proxy(
req: IncomingMessage,
res: ServerResponse,
nodeUrl: string,
apiKey: string | undefined,
): Promise<void> {
const path = (req.url ?? '/').slice('/api'.length);
const headers: Record<string, string> = { accept: 'application/json' };
const contentType = req.headers['content-type'];
if (contentType) headers['content-type'] = contentType;
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const method = req.method ?? 'GET';
const hasBody = method !== 'GET' && method !== 'HEAD';
const body = hasBody ? await readBody(req) : undefined;
try {
const upstream = await fetch(`${nodeUrl}${path}`, {
method,
headers,
body,
signal: AbortSignal.timeout(30_000),
});
const payload = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, {
'content-type': upstream.headers.get('content-type') ?? 'application/json',
'content-length': String(payload.byteLength),
});
res.end(payload);
} catch (error) {
// Surface the upstream fault as a real status rather than hanging the page.
const message = redact(error instanceof Error ? error.message : String(error));
const payload = Buffer.from(JSON.stringify({ error: `proxy: ${message}` }));
res.writeHead(502, {
'content-type': 'application/json',
'content-length': String(payload.byteLength),
});
res.end(payload);
}
}
/** Serve one file from the public dir, refusing anything outside it. */
async function serveStatic(req: IncomingMessage, res: ServerResponse): Promise<void> {
const requested = (req.url ?? '/').split('?')[0] ?? '/';
const relative = requested === '/' ? 'index.html' : requested.replace(/^\/+/, '');
const target = resolve(PUBLIC_DIR, relative);
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + sep)) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('forbidden');
return;
}
try {
const body = await readFile(target);
res.writeHead(200, {
'content-type': CONTENT_TYPES[extname(target)] ?? 'application/octet-stream',
'content-length': String(body.byteLength),
});
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
}
}
/**
* Boot a standalone node, seed the fixture catalog, and serve the app against it.
*
* Every stage that can fail reports why: an early child exit surfaces the node's
* stderr, and a non-zero seed exit surfaces the seeder's transcript, because a
* partially seeded corpus would make every downstream assertion meaningless.
*/
export async function startApp(options: StartAppOptions = {}): Promise<RunningApp> {
const verbose = options.verbose ?? false;
const log = (message: string) => {
if (verbose) console.log(`[app] ${message}`);
};
await ensureBinary(SERVER_BIN, ['build', '-p', 'tidal-server', '--bin', 'tidal-server'], verbose);
await ensureBinary(
FIXTURE_BIN,
['build', '-p', 'tidal-stress', '--bin', 'feed-fixture'],
verbose,
);
const dataDir = await mkdtemp(join(tmpdir(), 'tidaldb-feed-app-'));
const schemaPath = join(dataDir, 'schema.yaml');
const catalogPath = join(dataDir, 'catalog.json');
const truthPath = join(dataDir, 'truth.json');
const schema = await extractSchema();
const declaredDecay = parseDeclaredDecay(schema);
await writeFile(schemaPath, schema, 'utf8');
await writeFile(catalogPath, JSON.stringify(CATALOG), 'utf8');
// `--data-dir` must already exist: the server treats a missing directory as a
// config error rather than creating it (verified against its own stderr).
await mkdir(join(dataDir, 'data'), { recursive: true });
const nodePort = await freePort();
const nodeUrl = `http://127.0.0.1:${nodePort}`;
// `--listen` also reads the PORT env var (tidal-server/src/main.rs:99). An
// explicit flag wins in clap, but dropping PORT removes the ambiguity entirely.
const childEnv = { ...process.env };
delete childEnv.PORT;
delete childEnv.TIDAL_CONFIG;
log(`booting node on ${nodeUrl} (data ${dataDir})`);
const child: ChildProcess = spawn(
SERVER_BIN,
[
'standalone',
'--listen',
`127.0.0.1:${nodePort}`,
'--schema',
schemaPath,
'--data-dir',
join(dataDir, 'data'),
],
{ cwd: REPO_ROOT, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] },
);
let nodeStderr = '';
let exitInfo: string | undefined;
child.stderr?.on('data', (chunk: Buffer) => {
nodeStderr += chunk.toString();
});
child.stdout?.on('data', () => {
/* the node's own log; kept off the test transcript unless it fails */
});
child.once('exit', (code, signal) => {
exitInfo = `tidal-server exited early (code=${code} signal=${signal})`;
});
const stopNode = async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
const { promise, resolve: settle } = Promise.withResolvers<void>();
const hardKill = setTimeout(() => {
child.kill('SIGKILL');
settle();
}, KILL_GRACE_MS);
child.once('exit', () => {
clearTimeout(hardKill);
settle();
});
child.kill('SIGTERM');
await promise;
};
const cleanup = async (server?: Server) => {
if (server) {
const { promise, resolve: settle } = Promise.withResolvers<void>();
server.close(() => settle());
server.closeAllConnections?.();
await promise;
}
await stopNode();
await rm(dataDir, { recursive: true, force: true });
};
try {
const deadline = Date.now() + BOOT_TIMEOUT_MS;
let ready = false;
while (Date.now() < deadline) {
if (exitInfo) {
throw new Error(`${exitInfo}\n--- node stderr ---\n${redact(nodeStderr.trim())}`);
}
if (await healthOk(nodeUrl)) {
ready = true;
break;
}
await sleep(150);
}
if (!ready) {
throw new Error(
`tidal-server never answered ${nodeUrl}/health within ${BOOT_TIMEOUT_MS}ms\n` +
`--- node stderr ---\n${redact(nodeStderr.trim())}`,
);
}
log(`node healthy; seeding ${CATALOG.length} items`);
const seed = await run(
FIXTURE_BIN,
[
'--base-url',
nodeUrl,
'--catalog',
catalogPath,
'--out',
truthPath,
'--dim',
String(EMBEDDING_DIM),
...PROBE_IDS.flatMap((id) => ['--probe', String(id)]),
],
{ timeoutMs: SEED_TIMEOUT_MS },
);
if (seed.code !== 0) {
throw new Error(
`fixture seeding failed — a partial corpus makes every assertion ` +
`meaningless, so this is fatal.\n$ ${seed.command}\nexit ${seed.code}\n` +
`${seed.stdout}\n${seed.stderr}`,
);
}
const groundTruth = JSON.parse(await readFile(truthPath, 'utf8')) as FixtureGroundTruth;
const route = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.url?.startsWith('/api/') === true) {
return proxy(req, res, nodeUrl, options.apiKey);
}
if ((req.url ?? '/').split('?')[0] === '/catalog.json') {
// The page joins ranked ids to titles, so it needs the catalog — served
// from the same contract the seeder used, never a second copy.
const payload = Buffer.from(JSON.stringify(CATALOG));
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-length': String(payload.byteLength),
});
res.end(payload);
return;
}
return serveStatic(req, res);
};
const server = createServer((req, res) => {
route(req, res).catch((error: unknown) => {
if (res.headersSent) {
res.end();
return;
}
res.writeHead(500, { 'content-type': 'text/plain' });
res.end(redact(error instanceof Error ? error.message : String(error)));
});
});
// Bind port 0 and read back what the kernel gave us: no probe, no race.
const listening = Promise.withResolvers<number>();
server.once('error', (error) => listening.reject(error));
server.listen(0, '127.0.0.1', () => {
const address = server.address();
listening.resolve(typeof address === 'object' && address !== null ? address.port : 0);
});
const appPort = await listening.promise;
const url = `http://127.0.0.1:${appPort}`;
// Prove the app origin is actually reachable before handing it to a caller,
// so a bind that silently failed cannot look like a page-render bug later.
if (!(await portAccepts(appPort))) {
throw new Error(`app server bound ${url} but the port does not accept connections`);
}
log(`app ready at ${url}`);
return {
url,
nodeUrl,
groundTruth,
declaredDecay,
dataDir,
close: () => cleanup(server),
};
} catch (error) {
await cleanup();
throw error;
}
}