Every earlier camera proof stood in for the browser with a werift peer. The question that decides whether the feature works is whether a person looking at the fleet page sees a picture, and the web console had never been opened. BROWSER=1 on scripts/e2e-camera.sh now starts the real Nuxt app and drives it in real Chrome (puppeteer-core): sign in, open the fleet page, wait for the camera player, and check the result. Measured against the H2C: the app's own Supabase client signs in, the header reports Live (the telemetry socket authenticated with the real token), the player reports connected, Chrome decodes 1680x1080 frames, playback time advances, the frame has picture content (mean luma 88, stdev 62.8), and the browser's own ICE stats show the selected candidate is a TURN relay. No uncaught page errors. The login page only offers a magic link by email, which a script cannot follow, so the driver signs in with the app's $supabase.client and a password just set on a throwaway user. That means AUTH=supabase: the backend verifies against the real project's JWKS rather than the local dev issuer, with e2e-supabase-user.ts creating or reusing continuum-e2e@example.invalid (a reserved .invalid address) and resetting its password each run. The JSON it prints carries a password and a live token, so the shell captures it and never echoes it. The frame is judged by pixel statistics and no screenshot is kept: it is the inside of someone's workshop, and "is there a picture" does not need one stored. The relay is read from getStats() through a wrapped RTCPeerConnection rather than inferred from configuration. A browser run with fewer than its nine results fails, so a driver that dies quietly cannot read as a pass. puppeteer-core is a devDependency for these scripts alone. Its 22 resolved packages are all Apache-2.0, BSD-3-Clause, ISC or MIT.
187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
/**
|
|
* Drives the real Continuum web console in a real Chrome, for
|
|
* scripts/e2e-camera.sh (BROWSER=1).
|
|
*
|
|
* Everything before this proved the camera path with a stand-in for the
|
|
* browser. This is the browser: the actual Nuxt app, the actual
|
|
* `WebRTCStreamView`, a real RTCPeerConnection pinned to relay-only, and Chrome's
|
|
* own H.264 decoder. The question it answers is whether a person looking at
|
|
* the fleet page would see a picture.
|
|
*
|
|
* Signing in: the login page only offers a magic link by email, which a
|
|
* script cannot follow, so this signs in with the app's own Supabase client
|
|
* (`$supabase.client`) using a password the caller just set on a throwaway
|
|
* user. The app then behaves exactly as it does for a real session: cookie,
|
|
* redirect, token on every API call, the telemetry socket.
|
|
*
|
|
* What it reads from the browser rather than trusting the UI: the page wraps
|
|
* RTCPeerConnection so the selected ICE candidate pair can be inspected, and
|
|
* the decoded frame is sampled on a canvas. It deliberately saves no
|
|
* screenshot - the frame is the inside of someone's workshop, and pixel
|
|
* statistics answer "is there a picture" without keeping one.
|
|
*
|
|
* Results are `ok<TAB>label` / `FAILED<TAB>label`. The password and token
|
|
* never reach a log line.
|
|
*
|
|
* Environment: APP_URL, SUPABASE_E2E_EMAIL, SUPABASE_E2E_PASSWORD, CHROME_BIN.
|
|
*/
|
|
import puppeteer from "puppeteer-core";
|
|
|
|
const appUrl = process.env.APP_URL!;
|
|
const email = process.env.SUPABASE_E2E_EMAIL!;
|
|
const password = process.env.SUPABASE_E2E_PASSWORD!;
|
|
const chromeBin = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
|
|
|
let failed = false;
|
|
const say = (good: boolean, label: string, detail = "") => {
|
|
if (!good) failed = true;
|
|
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
|
|
};
|
|
|
|
const browser = await puppeteer.launch({
|
|
executablePath: chromeBin,
|
|
headless: true,
|
|
// No sandbox because this container cannot create the namespaces Chrome's
|
|
// sandbox needs (see continuum-local-env-limits); the page it loads is our
|
|
// own dev server.
|
|
args: ["--no-sandbox", "--disable-dev-shm-usage", "--autoplay-policy=no-user-gesture-required"],
|
|
});
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
|
|
const pageErrors: string[] = [];
|
|
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
|
|
|
|
// Keeps every RTCPeerConnection the app creates, so its stats can be read.
|
|
await page.evaluateOnNewDocument(() => {
|
|
const Native = window.RTCPeerConnection;
|
|
(window as any).__pcs = [];
|
|
(window as any).RTCPeerConnection = function (this: unknown, ...args: any[]) {
|
|
const pc = new Native(...(args as [RTCConfiguration?]));
|
|
(window as any).__pcs.push(pc);
|
|
return pc;
|
|
} as unknown as typeof RTCPeerConnection;
|
|
(window as any).RTCPeerConnection.prototype = Native.prototype;
|
|
});
|
|
|
|
// The first load compiles the app, so it is slow.
|
|
await page.goto(`${appUrl}/login`, { waitUntil: "domcontentloaded", timeout: 120_000 });
|
|
await page.waitForFunction(
|
|
() => typeof (window as any).useNuxtApp === "function" && Boolean((window as any).useNuxtApp().$supabase),
|
|
{ timeout: 120_000 },
|
|
);
|
|
|
|
const signInError = await page.evaluate(
|
|
async (e: string, p: string) => {
|
|
const { client } = (window as any).useNuxtApp().$supabase;
|
|
const { error } = await client.auth.signInWithPassword({ email: e, password: p });
|
|
return error ? String(error.message) : null;
|
|
},
|
|
email,
|
|
password,
|
|
);
|
|
say(signInError === null, "the app's own Supabase client signs the user in", signInError ?? "");
|
|
if (signInError) process.exit(1);
|
|
|
|
await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
const stayed = await page
|
|
.waitForSelector(".fleet", { timeout: 60_000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
say(stayed && !page.url().includes("/login"), "the fleet page loads and does not bounce back to /login", page.url());
|
|
|
|
const live = await page
|
|
.waitForFunction(() => document.querySelector(".app-shell__status.is-online") !== null, { timeout: 30_000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
say(live, "the header reports Live: the telemetry socket authenticated with the real token");
|
|
|
|
const connected = await page
|
|
.waitForFunction(() => document.querySelector(".webrtc-view__status.is-connected") !== null, {
|
|
timeout: 60_000,
|
|
})
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
const status = await page.evaluate(
|
|
() => document.querySelector(".webrtc-view__status")?.textContent?.trim() ?? "no player rendered",
|
|
);
|
|
say(connected, "the camera player reports connected", `status: ${status}`);
|
|
|
|
if (connected) {
|
|
// Waits for a decoded frame, then samples it twice a second apart.
|
|
const decoded = await page
|
|
.waitForFunction(
|
|
() => {
|
|
const v = document.querySelector<HTMLVideoElement>(".webrtc-view video");
|
|
return Boolean(v && v.readyState >= 2 && v.videoWidth > 0);
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
const frame = decoded
|
|
? await page.evaluate(async () => {
|
|
const v = document.querySelector<HTMLVideoElement>(".webrtc-view video")!;
|
|
const t0 = v.currentTime;
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 64;
|
|
canvas.height = 36;
|
|
const ctx = canvas.getContext("2d")!;
|
|
ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
|
|
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let sum = 0;
|
|
let sumSq = 0;
|
|
const n = px.length / 4;
|
|
for (let i = 0; i < px.length; i += 4) {
|
|
const y = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
|
sum += y;
|
|
sumSq += y * y;
|
|
}
|
|
const mean = sum / n;
|
|
return {
|
|
width: v.videoWidth,
|
|
height: v.videoHeight,
|
|
advanced: v.currentTime - t0,
|
|
mean,
|
|
stdev: Math.sqrt(Math.max(0, sumSq / n - mean * mean)),
|
|
};
|
|
})
|
|
: null;
|
|
|
|
say(decoded && frame !== null, "Chrome decoded a video frame", frame ? `${frame.width}x${frame.height}` : "");
|
|
if (frame) {
|
|
say(frame.advanced > 0.5, "playback time is advancing, so the stream is live", `+${frame.advanced.toFixed(1)}s`);
|
|
say(frame.stdev > 2, "the frame has picture content, not black or one flat colour",
|
|
`mean luma ${frame.mean.toFixed(0)}, stdev ${frame.stdev.toFixed(1)}`);
|
|
}
|
|
|
|
// Read from the browser's own ICE stats, so "relay-only" is observed
|
|
// rather than inferred from the configuration.
|
|
const selected = await page.evaluate(async () => {
|
|
for (const pc of (window as any).__pcs as RTCPeerConnection[]) {
|
|
const stats = await pc.getStats();
|
|
const byId = new Map<string, any>();
|
|
stats.forEach((s) => byId.set(s.id, s));
|
|
for (const s of byId.values()) {
|
|
if (s.type === "transport" && s.selectedCandidatePairId) {
|
|
const pair = byId.get(s.selectedCandidatePairId);
|
|
const local = pair && byId.get(pair.localCandidateId);
|
|
if (local) return local.candidateType as string;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
say(selected === "relay", "the browser's selected local candidate is a TURN relay", `type: ${selected ?? "none"}`);
|
|
}
|
|
|
|
say(pageErrors.length === 0, "the page raised no uncaught errors", pageErrors.slice(0, 2).join(" | "));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
process.exit(failed ? 1 : 0);
|