Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 2 additions & 30 deletions e2e/console/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,8 @@ import { freeLoopbackPort } from "../helpers/port.js";
import { REPO_ROOT } from "../helpers/repo-root.js";
import { waitFor } from "../helpers/wait.js";

/**
* The browser lane's daemons. Each is a real daemon process with HTTP enabled on a free
* loopback port and its own `SIMLOCK_HOME`, so nothing here touches the machine's own Simlock.
* A worker runs the end-to-end suite's fake driver; a gateway runs no drivers at all.
*/

export interface ConsoleDaemon {
/** The console's address, exactly as `simlock status --json` reports it. */
readonly url: string;
/** Runs `simlock` against this daemon, for a test that changes its state (a revoked token). */
readonly cli: (args: readonly string[]) => Promise<CliResult>;
readonly tokens: {
readonly operator: string;
Expand All @@ -40,27 +32,20 @@ export interface ConsoleDaemon {
};
}

/** A daemon a test drives itself: its CLI, and stopping and starting it in place. */
export interface RunningDaemon extends ConsoleDaemon {
readonly port: number;
/** The daemon's unix socket, for a test that drives it with the programmatic client. */
readonly socketPath: string;
cliBackground(args: readonly string[]): CliBackgroundHandle;
/** Replaces the fake driver's script. A daemon reads its host facts and catalog at start. */
writeDriverScript(script: FakeDriverScript): Promise<void>;
/** `simlock daemon stop`, keeping the home: `start()` brings the same daemon back. */
stop(): Promise<void>;
/** `simlock daemon start` on the same home, port and tokens. */
start(): Promise<void>;
/** Stops the daemon and deletes its home. */
dispose(options?: { readonly preservePages?: readonly Page[] }): Promise<void>;
/** why: stats read completed minutes only, so this moves the usage rows two minutes back. */
prepareHistoricalUsage(): Promise<void>;
}

export interface StartOptions {
readonly config?: Readonly<Record<string, unknown>>;
/** The fake driver's script, written before the daemon starts. */
readonly driverScript?: FakeDriverScript;
}

Expand Down Expand Up @@ -213,7 +198,7 @@ async function closeActivePages(preserved: ReadonlySet<Page>): Promise<unknown>
}

/**
* Pins a worker's capacity to the flow, not the machine running it, as the end-to-end suite
* why: Pins a worker's capacity to the flow, not the machine running it, as the end-to-end suite
* does: fake devices use no real memory, so a nominal per-device budget keeps the RAM gate from
* refusing a lease on a small CI runner.
*/
Expand Down Expand Up @@ -417,7 +402,6 @@ export async function startDaemon(
}
}

/** A gateway and the workers that joined it, each labelled. */
export interface Fleet {
readonly gateway: RunningDaemon;
readonly workers: readonly RunningDaemon[];
Expand All @@ -432,7 +416,7 @@ export async function mintJoinToken(gateway: RunningDaemon): Promise<string> {
}

/**
* Starts a gateway and one worker per entry of `workers`, each joined to it with its label, and
* why: Starts a gateway and one worker per entry of `workers`, each joined to it with its label, and
* waits until the gateway lists every one of them as connected and has read it: a worker that is
* still `starting`, or not yet read, has no capacity or catalog in its view, and a test that
* leases through the gateway or reads a card's capacity right away would see that gap.
Expand Down Expand Up @@ -504,15 +488,8 @@ export async function disposeFleet(
await fleet.gateway.dispose(options);
}

/** The protocol range the stand-in below claims: older than any this gateway speaks. */
export const OLD_PROTOCOL = { max: 3, min: 1 } as const;

/**
* A worker too old for the gateway, without building an old Simlock: it dials the gateway's
* uplink with a real join token, as a worker does, and answers the gateway's `hello` the way a
* daemon speaking only protocol {@link OLD_PROTOCOL} does. The gateway then lists it as
* `incompatible` with both ranges. Resolves once the uplink is open; call the result to hang up.
*/
export async function joinOldWorker(
gateway: RunningDaemon,
identity: { readonly id: string; readonly label: string },
Expand Down Expand Up @@ -699,10 +676,6 @@ export async function startProxiedDaemon(
};
}

/**
* `daemon` is a single host, shared by every test in a Playwright worker, and the base URL every
* `page.goto` resolves against. `gateway` starts only for a test that asks for it.
*/
export const test = base.extend<
{ proxied: ProxiedDaemon },
{ daemon: RunningDaemon; gateway: RunningDaemon; proxiedDaemon: ProxiedDaemon }
Expand Down Expand Up @@ -755,7 +728,6 @@ export const test = base.extend<

export { expect };

/** Signs in through the sign-in screen, as an operator would. */
export async function signIn(page: Page, token: string): Promise<void> {
await page.getByLabel("Operator token").fill(token);
await page.getByRole("button", { name: "Sign in" }).click();
Expand Down
7 changes: 2 additions & 5 deletions e2e/gateway-component-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ function idOf(workers: readonly WorkerView[], label: string): string {
return worker.id;
}

/** Which workers the gateway's catalog lists Android `runtime` for. */
async function androidRuntimeWorkers(gateway: TestEnv, runtime: string): Promise<unknown> {
const catalog = await gateway.cli(["catalog", "--platform", "android", "--json"]);
expect(catalog.code, catalog.stderr).toBe(0);
Expand All @@ -61,7 +60,7 @@ describe("simlock component install through a gateway", () => {
availableOsVersions: ["34"],
installProgress: [50],
knownModels: ["Pixel 9"],
// Held open long enough for `worker list` to see it.
// why: Held open long enough for `worker list` to see it.
latencyMs: { installComponent: 3_000 },
},
};
Expand Down Expand Up @@ -94,7 +93,6 @@ describe("simlock component install through a gateway", () => {
const locked = idOf(workers, "locked");
expect(await androidRuntimeWorkers(gateway, "35")).toBeUndefined();

// An agent token on the gateway's HTTP API is refused, and no worker starts an install.
const agent = await gateway.cli(["token", "create", "--role", "agent"]);
expect(agent.code, agent.stderr).toBe(0);
const refused = await fetch(`http://127.0.0.1:${port}/v1/components/install`, {
Expand Down Expand Up @@ -143,15 +141,14 @@ describe("simlock component install through a gateway", () => {
outcome: "refused",
workerId: locked,
});
// Progress on stderr names the worker it came from.
const progress = install.stderr
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>)
.filter((line) => line["stage"] !== undefined);
expect(progress).toContainEqual({ fraction: 0.5, stage: "downloading", workerId: open });

// Read straight after the command, with no wait: the relay refreshed the catalog first.
// why: Read straight after the command, with no wait: the relay refreshed the catalog first.
expect(await androidRuntimeWorkers(gateway, "35")).toEqual([open]);
});

Expand Down
18 changes: 1 addition & 17 deletions e2e/gateway-event-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ import { SimlockWire } from "../src/simlock-client/wire.js";
import { mintJoinToken, waitFor, withDaemon, withHttpDaemon } from "./helpers/index.js";
import type { RecordedEvent } from "./helpers/events.js";

/**
* ADR 0014 §2 and §5 with real processes: a gateway and one worker, the CLI an operator uses.
* A worker's event keeps its `id` and `timestamp` on the gateway, which is what lets the two
* event files be joined; `workerId` and the gateway's own `seq` are the only marks of the relay.
*/

async function gatewayWithWorker() {
const { env: gateway, port } = await withHttpDaemon((port) => ({
configOverrides: { http: { host: "127.0.0.1", port } },
Expand Down Expand Up @@ -49,7 +43,6 @@ function leaseArgs(agentId: string): string[] {
];
}

/** The lines of a stream: a replayed event is printed as itself, a pushed one inside its push. */
function parse(stdout: string): RecordedEvent[] {
return stdout
.split("\n")
Expand Down Expand Up @@ -281,11 +274,8 @@ describe("gateway event relay", () => {
const follower = gateway.cliBackground(["events", "--follow", "--since", "1h"], {
env: { SIMLOCK_ADMIN_TOKEN: adminToken, SIMLOCK_HOME: gate.home },
});
// The follower has subscribed and asked for the replay; the gate holds that request.
await waitFor(() => gate.replayHeld(), { label: "the replay request reached the gate" });

// A lease is granted while the replay is pending: its push reaches the follower, and the
// replay, released only afterwards, is taken after the grant and holds it too.
const holder = worker.cliBackground(leaseArgs("relay-follow"));
const grant = JSON.parse(await holder.firstStdoutLine()) as { lease: { id: string } };
const isGrant = (event: RecordedEvent) =>
Expand Down Expand Up @@ -364,12 +354,6 @@ async function gatewayWithTwoWorkers() {

const idsOf = (events: readonly RecordedEvent[]) => events.map((event) => event.id);

/**
* A unix-socket proxy in front of the gateway's daemon socket that holds the follower's
* `events.replay` request until `release()`. Everything else, pushes included, passes through, so
* an event emitted while the request is held reaches the follower as a push and is then also in
* the replay taken after the release.
*/
async function gateReplay(upstreamSocket: string) {
const home = await mkdtemp(join(tmpdir(), "sl-gate-"));
// why: the follower runs with `--gateway`, which looks for a gateway folder under this root.
Expand Down Expand Up @@ -424,7 +408,7 @@ async function gateReplay(upstreamSocket: string) {
held?.();
},
async close() {
// `server.close` waits for every open connection; destroy them so a failed assertion in
// why: `server.close` waits for every open connection; destroy them so a failed assertion in
// the test body is reported, not replaced by a timeout here.
for (const socket of open) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
Expand Down
28 changes: 1 addition & 27 deletions e2e/gateway-fast-fail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,6 @@ import { mintJoinToken, waitFor, withDaemon, withHttpDaemon } from "./helpers/in
import type { CliResult, TestEnv } from "./helpers/index.js";
import type { FakeDriverScript } from "./fake-driver/types.js";

/**
* ADR 0009 §4 end to end, with real processes: a gateway daemon, worker daemons that dial it
* over a real WebSocket, and the CLI an agent would use. A request no worker can serve fails at
* once with the right code instead of waiting.
*
* Every request below has no `--no-wait` and no `--timeout`, the case that used to wait for ever.
* The CLI call carries a deadline of its own, so a request that waits fails here by name instead
* of hanging the suite.
*/

const FAST = { timeout: 20_000 };
const EXIT_NO_CAPACITY = 11;
const EXIT_UNSERVABLE = 12;
Expand All @@ -26,7 +16,6 @@ interface WorkerView {
}

async function startGateway() {
// `driver: "none"`: a gateway starts no drivers (ADR 0005 §2).
const { env: gateway, port } = await withHttpDaemon((port) => ({
configOverrides: { http: { enabled: true, host: "127.0.0.1", port } },
role: "gateway",
Expand All @@ -40,7 +29,7 @@ async function startGateway() {
script: FakeDriverScript,
extraConfig: Record<string, unknown> = {},
) =>
// Seeded before the daemon starts: a worker reports its catalog as its uplink opens.
// why: Seeded before the daemon starts: a worker reports its catalog as its uplink opens.
withDaemon({
configOverrides: {
gateway: { label, token: await mintJoinToken(gateway), url: `ws://127.0.0.1:${port}` },
Expand All @@ -58,7 +47,6 @@ async function workersOf(gateway: TestEnv): Promise<WorkerView[]> {
return (listed.json as { workers: WorkerView[] }).workers;
}

/** Waits until `labels` are all connected with a catalog read, and returns their ids by label. */
async function waitForWorkers(
gateway: TestEnv,
labels: readonly string[],
Expand Down Expand Up @@ -118,7 +106,6 @@ function expectFailure(result: CliResult, code: string, exit: number): void {
describe("a gateway fails a request no worker can serve at once", () => {
it("fails a request for a model, a runtime, or a platform no worker has, and lets the same requester ask again", async () => {
const { gateway, join } = await startGateway();
// 27.0 is installed, but iPhone 17 pairs with 26.0 only.
await join("worker-a", {
ios: {
...IPHONE_17,
Expand All @@ -128,20 +115,17 @@ describe("a gateway fails a request no worker can serve at once", () => {
});
await waitForWorkers(gateway, ["worker-a"]);

// A model no worker lists.
expectFailure(
await lease(gateway, "agent-1", "iPhone 99", ["--detach"]),
"UNKNOWN_MODEL",
EXIT_UNSERVABLE,
);
// The same requester asks again at once: another answer, not REQUESTER_ALREADY_LEASED.
expectFailure(
await lease(gateway, "agent-1", "iPhone 99", ["--detach"]),
"UNKNOWN_MODEL",
EXIT_UNSERVABLE,
);

// A runtime no worker has, and, with --allow-download, still none: only installed runtimes count.
const runtime = [
"--platform",
"ios",
Expand All @@ -157,7 +141,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
const download = await gateway.cli(["lease", ...runtime, "--detach", "--allow-download"], FAST);
expectFailure(download, "RUNTIME_MISSING", EXIT_UNSERVABLE);

// A runtime a worker has, that no worker pairs with the model asked for.
const unpaired = await gateway.cli(
[
"lease",
Expand All @@ -175,7 +158,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
);
expectFailure(unpaired, "RUNTIME_MISSING", EXIT_UNSERVABLE);

// A platform no worker has a driver for.
const android = await gateway.cli(
[
"lease",
Expand All @@ -191,7 +173,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
);
expectFailure(android, "NO_DRIVER", EXIT_UNSERVABLE);

// The fleet still serves what it has, to the requester that was refused five times.
const granted = await lease(gateway, "agent-1", "iPhone 17", ["--detach"]);
expect(granted.code, granted.stderr).toBe(0);
const { lease: held } = granted.json as { lease: { id: string } };
Expand All @@ -215,11 +196,9 @@ describe("a gateway fails a request no worker can serve at once", () => {
FAST,
);

// No worker lists a model of the class.
const watch = await ask("--class", "watch");
expectFailure(watch, "UNKNOWN_MODEL", EXIT_UNSERVABLE);
expect(watch.stderr).toContain("watch");
// Every model of the class pairs with a runtime outside the range, with or without a download.
expectFailure(
await ask("--class", "phone", "--os", "<=17"),
"RUNTIME_MISSING",
Expand All @@ -230,7 +209,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
"RUNTIME_MISSING",
EXIT_UNSERVABLE,
);
// A request that names neither model nor class means phone, and the fleet has one.
const granted = await ask();
expect(granted.code, granted.stderr).toBe(0);
const { lease: held } = granted.json as { lease: { id: string } };
Expand Down Expand Up @@ -268,7 +246,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
const away = await join("worker-16", { ios: IPHONE_16 });
const ids = await waitForWorkers(gateway, ["worker-17", "worker-16"]);

// worker-17 drained: the only worker that lists iPhone 17 is not taking requests.
expect((await gateway.cli(["worker", "drain", ids["worker-17"] ?? ""])).code).toBe(0);
expectFailure(
await lease(gateway, "agent-1", "iPhone 17", ["--detach"]),
Expand All @@ -277,8 +254,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
);
expect((await gateway.cli(["worker", "undrain", ids["worker-17"] ?? ""])).code).toBe(0);

// worker-16 gone: a request only it lists fails with NO_CAPACITY, not UNKNOWN_MODEL, while
// worker-17 still takes requests.
await away.killDaemon();
await waitFor(
async () =>
Expand Down Expand Up @@ -322,7 +297,6 @@ describe("a gateway fails a request no worker can serve at once", () => {
const held = await lease(gateway, "agent-holder", "iPhone 17", ["--detach"]);
expect(held.code, held.stderr).toBe(0);

// A second requester waits: the worker can serve it and is busy.
const waiting = gateway.cliBackground([
"lease",
"--platform",
Expand Down
Loading
Loading