feat(web): CI screenshot capture for the mgmt console
Marketing/store screenshots of the console, captured from the built Storybook with headless Chromium (web/tools/screenshots.mjs) — every Pages/* + Shell/* story rendered at 1440x900@2x. The page stories render from fixtures, so no live mgmt API, login, or GPU is needed (the web analogue of apple.yml's screenshots job). Gated to stable release tags in a standalone best-effort workflow; PNGs upload as a 30-day artifact, not committed. - Add Stats + Pairing stories (the two pages that lacked them) with stats/pairing fixtures typed against the generated models. - Extract a pure PairingView (index.tsx -> view.tsx), matching the Dashboard/Clients/Stats split, so the page renders host-free from mock state instead of racing its polling queries. Container wiring is behaviour-identical. - Playwright driver + a chromium-capable tag-gated job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PairingView } from "@/sections/Pairing/view";
|
||||
import {
|
||||
nativeClients,
|
||||
nativePairArmed,
|
||||
pairingIdle,
|
||||
pendingDevices,
|
||||
} from "./lib/fixtures";
|
||||
|
||||
const noop = () => {};
|
||||
const idle = { isLoading: false, error: null, refetch: noop };
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Pairing",
|
||||
component: PairingView,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onApprove: noop,
|
||||
onDeny: noop,
|
||||
pendingBusy: false,
|
||||
onArm: noop,
|
||||
onDisarm: noop,
|
||||
isArming: false,
|
||||
isDisarming: false,
|
||||
onUnpair: noop,
|
||||
isUnpairing: false,
|
||||
pin: "",
|
||||
onPinChange: noop,
|
||||
onSubmitPin: noop,
|
||||
isSubmittingPin: false,
|
||||
pinSuccess: false,
|
||||
pinError: false,
|
||||
},
|
||||
} satisfies Meta<typeof PairingView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// The marketing state: a PIN armed for a phone, one device knocking for delegated
|
||||
// approval, two already-paired native clients.
|
||||
export const Armed: Story = {
|
||||
args: {
|
||||
pending: { data: pendingDevices, ...idle },
|
||||
native: { data: nativePairArmed, ...idle },
|
||||
clients: { data: nativeClients, ...idle },
|
||||
moonlight: { data: pairingIdle, ...idle },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StatsView } from "@/sections/Stats/view";
|
||||
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
|
||||
|
||||
const noop = () => {};
|
||||
const idle = { isLoading: false, error: null, refetch: noop };
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Stats",
|
||||
component: StatsView,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onSelect: noop,
|
||||
onDownload: noop,
|
||||
onDelete: noop,
|
||||
isStarting: false,
|
||||
isStopping: false,
|
||||
isDeleting: false,
|
||||
},
|
||||
} satisfies Meta<typeof StatsView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// A finished run open in the detail view: recordings table populated and the full
|
||||
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
|
||||
// fixture series — no live host or capture needed.
|
||||
export const Recording: Story = {
|
||||
args: {
|
||||
status: { data: statsStatusIdle, ...idle },
|
||||
live: { data: undefined, ...idle },
|
||||
recordings: { data: captureMetas, ...idle },
|
||||
detail: { data: captureDetail, ...idle },
|
||||
selectedId: captureMetas[0]?.id ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
status: { data: statsStatusIdle, ...idle },
|
||||
live: { data: undefined, ...idle },
|
||||
recordings: { data: [], ...idle },
|
||||
detail: { data: undefined, ...idle },
|
||||
selectedId: null,
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,18 @@
|
||||
// Mock API payloads for the page stories — typed against the generated models so
|
||||
// they stay honest if the OpenAPI schema changes.
|
||||
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import type { NativeClient } from "@/api/gen/model/nativeClient";
|
||||
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
|
||||
import type { PairedClient } from "@/api/gen/model/pairedClient";
|
||||
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
|
||||
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
|
||||
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||||
import type { StatsSample } from "@/api/gen/model/statsSample";
|
||||
import type { StatsStatus } from "@/api/gen/model/statsStatus";
|
||||
|
||||
export const hostInfo: HostInfo = {
|
||||
abi_version: 2,
|
||||
@@ -112,3 +120,115 @@ export const library: GameEntry[] = [
|
||||
launch: null,
|
||||
},
|
||||
];
|
||||
|
||||
// --- Performance (stats) page ------------------------------------------------
|
||||
|
||||
export const statsStatusIdle: StatsStatus = {
|
||||
armed: false,
|
||||
kind: "native",
|
||||
sample_count: 0,
|
||||
started_unix_ms: 0,
|
||||
};
|
||||
|
||||
// A native-path pipeline: capture → submit → encode → send. Deterministic (no
|
||||
// Math.random) so the screenshot is byte-stable across CI runs; a gentle sine
|
||||
// gives the charts a realistic shape without a live capture.
|
||||
const STAGE_BASE_US: Record<string, number> = {
|
||||
capture: 320,
|
||||
submit: 90,
|
||||
encode: 760,
|
||||
send: 140,
|
||||
};
|
||||
const STAGE_ORDER = ["capture", "submit", "encode", "send"];
|
||||
|
||||
function buildSamples(n: number): StatsSample[] {
|
||||
const out: StatsSample[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const wobble = Math.sin(i / 4);
|
||||
out.push({
|
||||
t_ms: i * 1000,
|
||||
session_id: 1,
|
||||
fps: 240,
|
||||
repeat_fps: i % 3 === 0 ? 2 : 1,
|
||||
mbps: 920 + wobble * 55,
|
||||
bitrate_kbps: 150_000,
|
||||
frames_dropped: i % 17 === 0 ? 1 : 0,
|
||||
packets_dropped: i % 9 === 0 ? 2 : 0,
|
||||
send_dropped: 0,
|
||||
fec_recovered: i % 5 === 0 ? 3 : 1,
|
||||
stages: STAGE_ORDER.map((name) => {
|
||||
const base = STAGE_BASE_US[name] ?? 100;
|
||||
const p50 = Math.round(base + wobble * base * 0.15);
|
||||
return { name, p50_us: p50, p99_us: Math.round(p50 * 1.8) };
|
||||
}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const captureMetas: CaptureMeta[] = [
|
||||
{
|
||||
id: "cap-20260628-2041",
|
||||
client: "enricos-macbook",
|
||||
kind: "native",
|
||||
codec: "h265",
|
||||
width: 5120,
|
||||
height: 1440,
|
||||
fps: 240,
|
||||
duration_ms: 92_000,
|
||||
sample_count: 92,
|
||||
started_unix_ms: 1_782_415_260_000,
|
||||
},
|
||||
{
|
||||
id: "cap-20260628-1903",
|
||||
client: "living-room-tv",
|
||||
kind: "gamestream",
|
||||
codec: "av1",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
fps: 120,
|
||||
duration_ms: 240_000,
|
||||
sample_count: 240,
|
||||
started_unix_ms: 1_782_409_380_000,
|
||||
},
|
||||
];
|
||||
|
||||
export const captureDetail: Capture = {
|
||||
meta: captureMetas[0] as CaptureMeta,
|
||||
samples: buildSamples(60),
|
||||
};
|
||||
|
||||
// --- Pairing page ------------------------------------------------------------
|
||||
|
||||
export const nativePairArmed: NativePairStatus = {
|
||||
enabled: true,
|
||||
armed: true,
|
||||
pin: "4827",
|
||||
expires_in_secs: 98,
|
||||
paired_clients: 2,
|
||||
};
|
||||
|
||||
export const pendingDevices: PendingDevice[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "studio-deck",
|
||||
fingerprint:
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
age_secs: 8,
|
||||
},
|
||||
];
|
||||
|
||||
export const nativeClients: NativeClient[] = [
|
||||
{
|
||||
name: "enricos-macbook",
|
||||
fingerprint:
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||
},
|
||||
{
|
||||
name: "living-room-tv",
|
||||
fingerprint:
|
||||
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
|
||||
},
|
||||
];
|
||||
|
||||
export const pairingIdle: PairingStatus = { pin_pending: false };
|
||||
|
||||
Reference in New Issue
Block a user