tidaldb/tests/e2e/support/cluster.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

232 lines
7.2 KiB
TypeScript

/**
* Cluster-plane access for checks that have no HTTP surface.
*
* tidalDB serves JSON only — it has no operator web UI — and pod readiness,
* NetworkPolicy enforcement, container logs, and Velero state are not reachable
* over HTTP at all. Those checks shell out to kubectl. That is a deliberate,
* documented choice: the alternative is inventing an HTTP surface that does not
* exist and asserting against a fiction.
*
* Everything here returns real captured output for attachment as evidence, with
* secrets redacted on the way out.
*/
import { execFile, spawn, type ChildProcess } from 'node:child_process';
import { createConnection } from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
import { promisify } from 'node:util';
// Explicit `.ts` extension: this module is in the transitive closure of
// `tests/e2e/app/harness.ts`, which `npm run app:dev` loads with bare Node's
// type stripping — and Node's ESM resolver does not guess extensions. Playwright
// resolves either form, so specs elsewhere keep the extensionless style.
import { KUBECONFIG, TIDALCTL_BIN, redact } from './env.ts';
const execFileAsync = promisify(execFile);
export type CommandResult = {
/** argv as run, for the evidence record. */
command: string;
code: number;
stdout: string;
stderr: string;
};
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Run a binary with no shell. Never throws on a non-zero exit — the caller
* asserts on `code`, so a failing command is evidence rather than a crash.
*/
export async function run(
file: string,
args: string[],
options: { timeoutMs?: number; env?: Record<string, string> } = {},
): Promise<CommandResult> {
const command = redact([file, ...args].join(' '));
try {
const { stdout, stderr } = await execFileAsync(file, args, {
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: 32 * 1024 * 1024,
env: { ...process.env, KUBECONFIG, ...options.env },
});
return { command, code: 0, stdout: redact(stdout), stderr: redact(stderr) };
} catch (error) {
const err = error as {
code?: number | string;
stdout?: string;
stderr?: string;
message: string;
};
return {
command,
code: typeof err.code === 'number' ? err.code : 1,
stdout: redact(err.stdout ?? ''),
stderr: redact(err.stderr ?? err.message),
};
}
}
/** Run kubectl with the pinned kubeconfig. */
export async function kubectl(
args: string[],
options: { timeoutMs?: number } = {},
): Promise<CommandResult> {
return run('kubectl', args, options);
}
/** Run kubectl and fail loudly — for prerequisites, where a failure is fatal. */
export async function kubectlOrThrow(args: string[]): Promise<string> {
const result = await kubectl(args);
if (result.code !== 0) {
throw new Error(
`kubectl failed (exit ${result.code}): ${result.command}\n${result.stderr}`,
);
}
return result.stdout;
}
/** Run the built tidalctl binary. */
export async function tidalctl(
args: string[],
options: { timeoutMs?: number } = {},
): Promise<CommandResult> {
return run(TIDALCTL_BIN, args, options);
}
/**
* Resolve one Secret key to plaintext. Used only to source credentials into the
* suite; the value is registered for redaction and never attached.
*/
export async function secretValue(
namespace: string,
secret: string,
key: string,
): Promise<string | undefined> {
const result = await kubectl([
'-n',
namespace,
'get',
'secret',
secret,
'-o',
`jsonpath={.data.${key}}`,
]);
if (result.code !== 0 || result.stdout.trim() === '') return undefined;
return Buffer.from(result.stdout.trim(), 'base64').toString('utf8');
}
/** True once something accepts a TCP connection on the port. */
async function portAccepts(port: number, host = '127.0.0.1'): Promise<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>();
const socket = createConnection({ port, host });
const settle = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.setTimeout(1_000);
socket.once('connect', () => settle(true));
socket.once('timeout', () => settle(false));
socket.once('error', () => settle(false));
return promise;
}
export type PortForward = {
localPort: number;
target: string;
close: () => Promise<void>;
};
/**
* Ports are allocated from a high base and bumped per acquisition so parallel
* workers never collide. Playwright workers are separate processes, so the
* worker index is folded into the base.
*/
let portCursor = 0;
const PORT_BASE = 19_600;
const WORKER_STRIDE = 40;
/**
* Open a port-forward and wait until the local port actually accepts a
* connection.
*
* The manual runbook tells a human to `sleep 8` because a shorter wait races
* kubectl's bind and returns an empty body that reads exactly like a dead node.
* A harness can do better than a fixed sleep: poll until the port answers, then
* proceed. Faster when the bind is quick, and it cannot produce that false
* negative when the cluster is slow.
*/
export async function portForward(
namespace: string,
target: string,
remotePort: number,
options: { timeoutMs?: number } = {},
): Promise<PortForward> {
const workerIndex = Number.parseInt(process.env.TEST_PARALLEL_INDEX ?? '0', 10);
const localPort = PORT_BASE + workerIndex * WORKER_STRIDE + portCursor;
portCursor = (portCursor + 1) % WORKER_STRIDE;
const timeoutMs = options.timeoutMs ?? 30_000;
const child: ChildProcess = spawn(
'kubectl',
['-n', namespace, 'port-forward', target, `${localPort}:${remotePort}`],
{ env: { ...process.env, KUBECONFIG }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let exitInfo: string | undefined;
let stderr = '';
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once('exit', (code, signal) => {
exitInfo = `kubectl port-forward exited early (code=${code} signal=${signal}): ${stderr.trim()}`;
});
const close = async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
const { promise, resolve } = Promise.withResolvers<void>();
const hardKill = setTimeout(() => {
child.kill('SIGKILL');
resolve();
}, 3_000);
child.once('exit', () => {
clearTimeout(hardKill);
resolve();
});
child.kill('SIGTERM');
await promise;
};
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (exitInfo) {
await close();
throw new Error(`${exitInfo}\ntarget: ${namespace}/${target}:${remotePort}`);
}
if (await portAccepts(localPort)) {
return { localPort, target: `${namespace}/${target}:${remotePort}`, close };
}
await sleep(250);
}
await close();
throw new Error(
`port-forward to ${namespace}/${target}:${remotePort} never accepted a connection ` +
`on 127.0.0.1:${localPort} within ${timeoutMs}ms.\nkubectl stderr: ${stderr.trim()}`,
);
}
/** Run `body` against an open port-forward and always tear it down. */
export async function withPortForward<T>(
namespace: string,
target: string,
remotePort: number,
body: (forward: PortForward) => Promise<T>,
): Promise<T> {
const forward = await portForward(namespace, target, remotePort);
try {
return await body(forward);
} finally {
await forward.close();
}
}