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.
232 lines
7.2 KiB
TypeScript
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();
|
|
}
|
|
}
|