Files
callstack__agent-device/test/integration/provider-scenarios/cloud-webdriver-test-server.ts
Michał Pierzchała b92b6ca70c fix(cloud): cancel a screen read that its caller gave up on (#2516)
* fix(cloud): cancel a screen read that its caller gave up on (#2509)

A hosted page-source read that outran its request was dropped by the client while
the driver kept walking the UI tree. Nothing at the wire said the answer was no
longer wanted, so the read stayed in flight and held the session it was blocking.
On a screen that never goes still -- a looping video, a live ticker, continuous
animation -- every later command then queued behind a capture nobody was waiting
for, which is what makes one stuck `snapshot -i` look like a frozen session.

Bind the read to the request that asked for it, on both Android and iOS, and say
what a source read that runs out of budget was waiting for. The timeout keeps its
`webdriver_request_timeout` reason and gains a hint naming a screen that never goes
still, so the rented minutes end with a cause rather than a silent hang.

Closes #2509

* fix(cloud): stop advising a timeout the source read never sees

The hint and the AWS docs both told the caller to retry with a larger `--timeout`.
That flag widens the command envelope around the read; the read's own budget is the
transport's, so the advice could not work and cost rented minutes to discover. The
report on #2509 shows exactly that experiment failing at 65 seconds.

Say whose budget it is, and offer the two things that do work: a `screenshot`, which
never reads the tree, and the `@refs` an earlier snapshot captured.

* test(cloud): claim only what the cancellation layer proves

The scenario comment credited this layer with the lease-gone symptom, which a
ten-minute cloud WebDriver lease rules out for the reported run. And one assertion
message said the driver's own tree walk had been cancelled, when what the test
observes is our request being hung up at the wire.

Behaviour and coverage are unchanged; the test now says what it measures.

* docs(snapshot): put the never-idle screen rule where snapshot advice lives

The read that fails is shared hosted WebDriver behaviour, so it belongs on the
Snapshots page rather than only under one provider. The provider page keeps the part
that is about being metered.

Both pages name the two dead ends, since both were tried on the reported run: a
larger `--timeout`, which widens the command around the read, and `settings
animations`, which hosted WebDriver sessions do not implement.

* docs(snapshot): bound the cancellation to the waiting this side controls

Hanging up the client read proves agent-device stops waiting and stops holding the
session. It says nothing about the provider, whose tree walk can keep running and can
still occupy that session's queue server-side. The paragraph claimed the recovery the
fixture does not prove.
2026-09-13 09:23:52 +02:00

107 lines
3.8 KiB
TypeScript

import type { IncomingHttpHeaders } from 'node:http';
export type CloudWebDriverHttpCall = {
method: string;
path: string;
headers: IncomingHttpHeaders;
body?: unknown;
/** The caller's cancellation, so a fixture can prove it reached the wire. */
signal?: AbortSignal;
};
export type CloudWebDriverTestResponse = {
body: unknown;
status?: number;
/**
* A driver that never answers. The request stays in flight until the caller's own
* cancellation reaches the transport, then rejects with that caller's reason.
* Models the #2509 shape — a server-side command the client gave up on — which a
* fixture that can only answer fast or answer wrong cannot express.
*/
neverAnswers?: boolean;
};
/**
* In-memory Fetch transport for Cloud WebDriver integration scenarios.
*
* The production client still uses Fetch; this fixture observes the exact
* request URL, headers and body, then supplies a real Response. Avoiding a
* loopback listener keeps the suite hermetic in sandboxes that prohibit
* binding ports without reducing request/response transport coverage.
*/
export abstract class CloudWebDriverTestServer {
readonly calls: CloudWebDriverHttpCall[] = [];
// fallow-ignore-next-line unused-class-member
readonly url = 'http://cloud-webdriver.test';
protected abstract respond(call: CloudWebDriverHttpCall): CloudWebDriverTestResponse;
// fallow-ignore-next-line unused-class-member
readonly fetch: typeof globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
const call: CloudWebDriverHttpCall = {
method: request.method,
path: new URL(request.url).pathname,
headers: Object.fromEntries(request.headers.entries()),
...(init?.signal === null || init?.signal === undefined
? {}
: { signal: init.signal as AbortSignal }),
...(await requestBody(request)),
};
this.calls.push(call);
const response = this.respond(call);
if (response.neverAnswers === true) {
return await neverAnsweredResponse(call.signal);
}
return new Response(JSON.stringify(response.body), {
status: response.status ?? 200,
headers: { 'Content-Type': 'application/json' },
});
};
}
export type StartedCloudWebDriverTestServer<T extends CloudWebDriverTestServer> = T & {
close(): Promise<void>;
};
export async function startCloudWebDriverTestServer<T extends CloudWebDriverTestServer>(
testServer: T,
): Promise<StartedCloudWebDriverTestServer<T>> {
const previousFetch = globalThis.fetch;
globalThis.fetch = testServer.fetch;
return Object.assign(testServer, {
close: async () => {
if (globalThis.fetch === testServer.fetch) globalThis.fetch = previousFetch;
},
});
}
export function cloudWebDriverTestJson(body: unknown, status = 200): CloudWebDriverTestResponse {
return { body, status };
}
/** A driver that never answers: only the caller's cancellation ends it. */
async function neverAnsweredResponse(signal: AbortSignal | undefined): Promise<Response> {
return await new Promise<Response>((_resolve, reject) => {
if (!signal) {
reject(new Error('A never-answered request needs the caller cancellation to end it.'));
return;
}
if (signal.aborted) {
reject(signal.reason as Error);
return;
}
signal.addEventListener('abort', () => reject(signal.reason as Error), { once: true });
});
}
async function requestBody(request: Request): Promise<{ body?: unknown }> {
if (!request.body) return {};
const buffer = Buffer.from(await request.arrayBuffer());
if (request.headers.get('content-type')?.startsWith('multipart/form-data')) {
return { body: { multipartBytes: buffer.length } };
}
const text = buffer.toString('utf8');
return text ? { body: JSON.parse(text) as unknown } : {};
}