45bfae6e94
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
587 lines
20 KiB
JavaScript
587 lines
20 KiB
JavaScript
/**
|
||
* Capture real product screenshots for the landing page.
|
||
*
|
||
* Flow: host a scripted mock LLM (speaks BOTH Anthropic SSE and OpenAI chat-completions
|
||
* SSE, so whichever client AgentHub routes to gets a valid stream) -> start the Web
|
||
* server against a temp data root serving the built web dist -> drive a genuine
|
||
* "build an Agent app" conversation (tools actually execute in the workspace) ->
|
||
* screenshot chat / trace view / evaluation center, per UI language (zh / en, each
|
||
* with its own user so sidebars stay monolingual) and per theme (light / dark), into
|
||
* src/assets/shots/ as <page>-<lang>-<theme>.webp (12 files, re-encoded to WebP
|
||
* inside Chromium to keep the repo small).
|
||
*
|
||
* Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and
|
||
* Playwright's chromium. Run: `node scripts/capture-shots.mjs`.
|
||
*/
|
||
import http from "node:http";
|
||
import { spawn } from "node:child_process";
|
||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { chromium } from "@playwright/test";
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(HERE, "../../..");
|
||
const OUT_DIR = path.resolve(HERE, "../src/assets/shots");
|
||
const MOCK_PORT = 8941;
|
||
const SRV_PORT = 8940;
|
||
const BASE = `http://127.0.0.1:${SRV_PORT}`;
|
||
const MOCK = `http://127.0.0.1:${MOCK_PORT}`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scripted conversation: the Agent builds an Agent application from scratch.
|
||
// Commands are shared across languages (code is code) and really execute.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const CMD_SCAFFOLD = `mkdir -p csv-analyst/src && cat > csv-analyst/package.json <<'EOF'
|
||
{
|
||
"name": "csv-analyst",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": { "start": "tsx src/agent.ts" },
|
||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0" }
|
||
}
|
||
EOF
|
||
ls -R csv-analyst`;
|
||
|
||
const CMD_ENTRY = `cat > csv-analyst/src/agent.ts <<'EOF'
|
||
import { createAgent, isCompleteModelMessage, userText } from "@prismshadow/penguin-core";
|
||
|
||
const agent = await createAgent({ agentId: "csv_analyst" });
|
||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||
|
||
for await (const out of session.run([userText("Analyze data.csv and write summary.md")], {
|
||
approve: async () => "allow",
|
||
})) {
|
||
if (isCompleteModelMessage(out) && out.payload.type === "text") {
|
||
console.log(out.payload.text);
|
||
}
|
||
}
|
||
EOF
|
||
wc -l csv-analyst/src/agent.ts`;
|
||
|
||
const TREE = `\`\`\`text
|
||
csv-analyst/
|
||
├── package.json
|
||
└── src/
|
||
└── agent.ts
|
||
\`\`\``;
|
||
|
||
/** Per-language script: user prompt marker -> turns + session title. */
|
||
const SCRIPTS = {
|
||
zh: {
|
||
marker: "数据分析 Agent 应用",
|
||
prompt: "用 PenguinHarness SDK 创建一个数据分析 Agent 应用:读取 CSV 并输出汇总报告",
|
||
title: "构建数据分析 Agent 应用",
|
||
turns: [
|
||
{
|
||
thinking:
|
||
"需求是基于 penguin-core 的数据分析 Agent 应用。先创建项目骨架:package.json 与源码目录。",
|
||
text: "我来创建应用骨架:",
|
||
cmd: CMD_SCAFFOLD,
|
||
},
|
||
{
|
||
thinking:
|
||
"骨架已建好。入口代码用 createAgent + createSession,把 CSV 分析任务交给 session.run。",
|
||
text: "骨架就绪,写入 Agent 入口代码:创建 Session,把 CSV 分析任务交给 session.run 并流式输出。",
|
||
cmd: CMD_ENTRY,
|
||
},
|
||
{
|
||
text: `数据分析 Agent 应用已创建完成:
|
||
|
||
${TREE}
|
||
|
||
- 入口 \`csv-analyst/src/agent.ts\`:创建 Agent 与 Session,任务经 \`session.run\` 流式执行,工具调用逐个审批;
|
||
- 运行方式:\`cd csv-analyst && npm install && npm start\`;
|
||
- 建议下一步:在评估中心为它配一组 CSV 任务 Benchmark,交给 Optimizer 持续优化。`,
|
||
},
|
||
],
|
||
},
|
||
en: {
|
||
marker: "data-analysis Agent app",
|
||
prompt:
|
||
"Use the PenguinHarness SDK to create a data-analysis Agent app that reads CSV files and writes a summary report",
|
||
title: "Build a data-analysis Agent app",
|
||
turns: [
|
||
{
|
||
thinking:
|
||
"They want a data-analysis Agent app on penguin-core. Start with the project skeleton: package.json plus the source directory.",
|
||
text: "Let me scaffold the app first:",
|
||
cmd: CMD_SCAFFOLD,
|
||
},
|
||
{
|
||
thinking:
|
||
"Skeleton is in place. The entry uses createAgent + createSession and hands the CSV task to session.run.",
|
||
text: "Skeleton ready — now the Agent entry point: create a Session and hand the CSV analysis task to session.run, streaming the output.",
|
||
cmd: CMD_ENTRY,
|
||
},
|
||
{
|
||
text: `The data-analysis Agent app is ready:
|
||
|
||
${TREE}
|
||
|
||
- Entry \`csv-analyst/src/agent.ts\`: creates the Agent and a Session; the task runs through \`session.run\` with per-tool approval;
|
||
- Run it with \`cd csv-analyst && npm install && npm start\`;
|
||
- Suggested next step: give it a CSV Benchmark suite in the evaluation center and let an Optimizer keep improving it.`,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
function scriptFor(flat) {
|
||
return flat.includes(SCRIPTS.en.marker) ? SCRIPTS.en : SCRIPTS.zh;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mock LLM: Anthropic SSE on */messages, OpenAI chunks on */chat/completions.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function sse(res, event, data) {
|
||
res.write(`event: ${event}\n`);
|
||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||
}
|
||
|
||
function anthropicReply(res, body) {
|
||
const flat = JSON.stringify(body.messages ?? []);
|
||
const script = scriptFor(flat);
|
||
const isTitle = flat.includes("concise title");
|
||
const toolResults = flat.split('"tool_result"').length - 1;
|
||
const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];
|
||
const msgCount = (body.messages ?? []).length;
|
||
|
||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||
sse(res, "message_start", {
|
||
type: "message_start",
|
||
message: {
|
||
id: `msg_shot_${Date.now()}`,
|
||
type: "message",
|
||
role: "assistant",
|
||
model: "deepseek-v4-pro",
|
||
content: [],
|
||
stop_reason: null,
|
||
stop_sequence: null,
|
||
usage: {
|
||
input_tokens: 380,
|
||
output_tokens: 0,
|
||
cache_read_input_tokens: 2400 * msgCount,
|
||
cache_creation_input_tokens: 620,
|
||
},
|
||
},
|
||
});
|
||
|
||
const block = (index, start, deltas, extra) => {
|
||
sse(res, "content_block_start", { type: "content_block_start", index, content_block: start });
|
||
for (const d of deltas)
|
||
sse(res, "content_block_delta", { type: "content_block_delta", index, delta: d });
|
||
if (extra)
|
||
sse(res, "content_block_delta", { type: "content_block_delta", index, delta: extra });
|
||
sse(res, "content_block_stop", { type: "content_block_stop", index });
|
||
};
|
||
const finish = (stopReason, outputTokens) => {
|
||
sse(res, "message_delta", {
|
||
type: "message_delta",
|
||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||
usage: { output_tokens: outputTokens },
|
||
});
|
||
sse(res, "message_stop", { type: "message_stop" });
|
||
res.end();
|
||
};
|
||
const textDeltas = (text) =>
|
||
(text.match(/[\s\S]{1,24}/g) ?? []).map((t) => ({ type: "text_delta", text: t }));
|
||
|
||
if (isTitle) {
|
||
block(0, { type: "text", text: "" }, [{ type: "text_delta", text: script.title }]);
|
||
finish("end_turn", 8);
|
||
return;
|
||
}
|
||
|
||
let index = 0;
|
||
if (turn.thinking) {
|
||
block(
|
||
index++,
|
||
{ type: "thinking", thinking: "" },
|
||
turn.thinking.match(/[\s\S]{1,18}/g).map((t) => ({ type: "thinking_delta", thinking: t })),
|
||
{ type: "signature_delta", signature: "sig_shot" },
|
||
);
|
||
}
|
||
if (turn.text) block(index++, { type: "text", text: "" }, textDeltas(turn.text));
|
||
if (turn.cmd) {
|
||
const json = JSON.stringify({ cmd: turn.cmd });
|
||
block(
|
||
index++,
|
||
{ type: "tool_use", id: `toolu_shot_${toolResults + 1}`, name: "exec_command", input: {} },
|
||
(json.match(/[\s\S]{1,32}/g) ?? []).map((partial_json) => ({
|
||
type: "input_json_delta",
|
||
partial_json,
|
||
})),
|
||
);
|
||
finish("tool_use", 160);
|
||
} else {
|
||
finish("end_turn", 420);
|
||
}
|
||
}
|
||
|
||
function openaiReply(res, body) {
|
||
const flat = JSON.stringify(body.messages ?? []);
|
||
const script = scriptFor(flat);
|
||
const isTitle = flat.includes("concise title");
|
||
const toolResults = flat.split('"role":"tool"').length - 1;
|
||
const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];
|
||
|
||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||
const chunk = (delta, finishReason = null, usage) => {
|
||
const payload = {
|
||
id: "chatcmpl-shot",
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: "deepseek-v4-pro",
|
||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||
};
|
||
if (usage) payload.usage = usage;
|
||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||
};
|
||
const usage = {
|
||
prompt_tokens: 5200,
|
||
completion_tokens: turn.cmd ? 180 : 420,
|
||
total_tokens: 5620,
|
||
prompt_cache_hit_tokens: 4300,
|
||
prompt_cache_miss_tokens: 900,
|
||
};
|
||
|
||
chunk({ role: "assistant" });
|
||
if (isTitle) {
|
||
chunk({ content: script.title });
|
||
chunk({}, "stop", usage);
|
||
res.write("data: [DONE]\n\n");
|
||
res.end();
|
||
return;
|
||
}
|
||
if (turn.thinking) {
|
||
for (const t of turn.thinking.match(/[\s\S]{1,18}/g)) chunk({ reasoning_content: t });
|
||
}
|
||
if (turn.text) {
|
||
for (const t of turn.text.match(/[\s\S]{1,24}/g)) chunk({ content: t });
|
||
}
|
||
if (turn.cmd) {
|
||
chunk({
|
||
tool_calls: [
|
||
{
|
||
index: 0,
|
||
id: `call_shot_${toolResults + 1}`,
|
||
type: "function",
|
||
function: { name: "exec_command", arguments: "" },
|
||
},
|
||
],
|
||
});
|
||
const json = JSON.stringify({ cmd: turn.cmd });
|
||
for (const part of json.match(/[\s\S]{1,32}/g)) {
|
||
chunk({ tool_calls: [{ index: 0, function: { arguments: part } }] });
|
||
}
|
||
chunk({}, "tool_calls", usage);
|
||
} else {
|
||
chunk({}, "stop", usage);
|
||
}
|
||
res.write("data: [DONE]\n\n");
|
||
res.end();
|
||
}
|
||
|
||
function startMock() {
|
||
const server = http.createServer((req, res) => {
|
||
if (req.method !== "POST") {
|
||
res.writeHead(404).end();
|
||
return;
|
||
}
|
||
let body = "";
|
||
req.on("data", (c) => (body += c));
|
||
req.on("end", () => {
|
||
let json = {};
|
||
try {
|
||
json = JSON.parse(body);
|
||
} catch {}
|
||
if (req.url?.includes("chat/completions")) return openaiReply(res, json);
|
||
if (req.url?.includes("messages")) return anthropicReply(res, json);
|
||
console.log(`[mock] unexpected path ${req.url}`);
|
||
res.writeHead(404).end();
|
||
});
|
||
});
|
||
return new Promise((resolve) => server.listen(MOCK_PORT, "127.0.0.1", () => resolve(server)));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Server + API helpers.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function waitFor(url, tries = 60) {
|
||
for (let i = 0; i < tries; i++) {
|
||
try {
|
||
const res = await fetch(url);
|
||
if (res.ok) return;
|
||
} catch {}
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
}
|
||
throw new Error(`server not ready: ${url}`);
|
||
}
|
||
|
||
async function api(cookie, method, url, body) {
|
||
const res = await fetch(`${BASE}${url}`, {
|
||
method,
|
||
headers: {
|
||
"content-type": "application/json",
|
||
...(cookie ? { cookie } : {}),
|
||
},
|
||
...(body ? { body: JSON.stringify(body) } : {}),
|
||
});
|
||
if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`);
|
||
return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") };
|
||
}
|
||
|
||
async function login(userId, password) {
|
||
const { json, setCookie } = await api(null, "POST", "/api/auth/login", { userId, password });
|
||
if (!setCookie) throw new Error("no session cookie from login");
|
||
return { cookie: setCookie.split(";")[0], user: json.user };
|
||
}
|
||
|
||
/** Per-language demo users so sidebars stay monolingual in the shots. */
|
||
const USERS = {
|
||
zh: {
|
||
userId: "demo",
|
||
agents: [
|
||
{
|
||
agentId: "data_analyst",
|
||
name: "数据分析师",
|
||
description: "面向 CSV / Excel 的数据分析、图表与报表生成",
|
||
},
|
||
{ agentId: "web_scout", name: "网页调研员", description: "网页检索、信息核对与调研纪要整理" },
|
||
{
|
||
agentId: "agent_optimizer",
|
||
name: "Agent 优化师",
|
||
description: "评估其他 Agent 的表现并迭代其提示词与技能",
|
||
},
|
||
],
|
||
},
|
||
en: {
|
||
userId: "alex",
|
||
agents: [
|
||
{
|
||
agentId: "data_analyst",
|
||
name: "Data Analyst",
|
||
description: "CSV / Excel analysis, charts and report generation",
|
||
},
|
||
{
|
||
agentId: "web_scout",
|
||
name: "Web Scout",
|
||
description: "Web research, fact checking and note-taking",
|
||
},
|
||
{
|
||
agentId: "agent_optimizer",
|
||
name: "Agent Optimizer",
|
||
description: "Evaluates other Agents and iterates their prompts and Skills",
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
/** Provision a user with models + a few Agents; returns { cookie, password, projectId }. */
|
||
async function provisionUser(adminCookie, lang) {
|
||
const { userId, agents } = USERS[lang];
|
||
const initial = `${userId}12345`;
|
||
await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => {
|
||
if (!String(e).includes("409")) throw e;
|
||
});
|
||
let session = await login(userId, initial);
|
||
// Rotate once so the initial-password banner disappears from the shots.
|
||
let password = initial;
|
||
try {
|
||
await api(session.cookie, "PUT", "/api/me/password", {
|
||
oldPassword: initial,
|
||
newPassword: `penguin-${userId}-2026`,
|
||
});
|
||
password = `penguin-${userId}-2026`;
|
||
} catch {}
|
||
session = await login(userId, password);
|
||
|
||
const projects = (await api(session.cookie, "GET", "/api/projects")).json;
|
||
const projectId = projects.projects[0].projectId;
|
||
|
||
await api(session.cookie, "PUT", `/api/projects/${projectId}/models`, {
|
||
defaultModel: { provider: "deepseek", modelId: "deepseek-v4-pro" },
|
||
models: [
|
||
{
|
||
provider: "deepseek",
|
||
modelId: "deepseek-v4-pro",
|
||
apiKey: "sk-demo",
|
||
baseUrl: MOCK,
|
||
contextWindow: 1000000,
|
||
pricing: { cacheRead: 0.003571, cacheWrite: 0.428571, output: 0.857143 },
|
||
},
|
||
],
|
||
});
|
||
|
||
for (const agent of agents) {
|
||
await api(session.cookie, "POST", `/api/projects/${projectId}/agents`, agent).catch((e) => {
|
||
if (!String(e).includes("409")) throw e;
|
||
});
|
||
}
|
||
|
||
return { cookie: session.cookie, password, projectId, userId };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-shots-"));
|
||
const wsDir = path.join(dataRoot, "workspace-apps");
|
||
mkdirSync(wsDir, { recursive: true });
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
|
||
const mock = await startMock();
|
||
console.log(`[shots] mock LLM on ${MOCK}`);
|
||
|
||
const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], {
|
||
env: {
|
||
...process.env,
|
||
PENGUIN_HOME: path.join(dataRoot, "home"),
|
||
PENGUIN_WEB_DB: path.join(dataRoot, "web.db"),
|
||
PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"),
|
||
PORT: String(SRV_PORT),
|
||
HOST: "127.0.0.1",
|
||
},
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`));
|
||
|
||
const cleanup = () => {
|
||
try {
|
||
srv.kill();
|
||
} catch {}
|
||
try {
|
||
mock.close();
|
||
} catch {}
|
||
};
|
||
process.on("exit", cleanup);
|
||
|
||
try {
|
||
await waitFor(`${BASE}/`);
|
||
console.log(`[shots] server ready on ${BASE}`);
|
||
|
||
const admin = await login("admin", "admin123");
|
||
const browser = await chromium.launch();
|
||
|
||
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which
|
||
// keeps repo assets small (~5x lighter than PNG) with no native image deps.
|
||
const encoderPage = await browser.newPage();
|
||
async function saveWebp(pngBuffer, fileName) {
|
||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||
const img = new Image();
|
||
img.src = `data:image/png;base64,${b64}`;
|
||
await img.decode();
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = img.width;
|
||
canvas.height = img.height;
|
||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||
return canvas.toDataURL("image/webp", 0.82);
|
||
}, pngBuffer.toString("base64"));
|
||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||
console.log(`[shots] ${fileName}`);
|
||
}
|
||
|
||
/** The final answer is the only turn mentioning the npm run command. */
|
||
const DONE_MARKER = "npm install && npm start";
|
||
|
||
for (const lang of ["zh", "en"]) {
|
||
const user = await provisionUser(admin.cookie, lang);
|
||
const script = SCRIPTS[lang];
|
||
|
||
// Language-specific workspace subdir so zh/en runs don't collide on files.
|
||
const ws = path.join(wsDir, lang);
|
||
mkdirSync(ws, { recursive: true });
|
||
|
||
const sess = (
|
||
await api(
|
||
user.cookie,
|
||
"POST",
|
||
`/api/projects/${user.projectId}/agents/default_agent/sessions`,
|
||
{
|
||
provider: "deepseek",
|
||
modelId: "deepseek-v4-pro",
|
||
approvalMode: "allow-all",
|
||
workspace: ws,
|
||
},
|
||
)
|
||
).json;
|
||
const sessionId = sess.session.sessionId;
|
||
|
||
let firstTheme = true;
|
||
for (const theme of ["light", "dark"]) {
|
||
// 1280x800 @1.5x -> 1920x1200: sharp enough for the landing's ~1024px-wide
|
||
// frames on retina, while keeping the WebP assets small.
|
||
const context = await browser.newContext({
|
||
viewport: { width: 1280, height: 800 },
|
||
deviceScaleFactor: 1.5,
|
||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||
});
|
||
await context.addInitScript(
|
||
([t, l]) => {
|
||
localStorage.setItem("penguin.theme", t);
|
||
localStorage.setItem("penguin.lang", l);
|
||
},
|
||
[theme, lang],
|
||
);
|
||
const page = await context.newPage();
|
||
await page.goto(`${BASE}/login`);
|
||
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||
data: { userId: user.userId, password: user.password },
|
||
});
|
||
if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`);
|
||
|
||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||
if (firstTheme) {
|
||
// Drive the conversation once per language; the other theme restores it.
|
||
const input = page.getByPlaceholder(/输入消息|Type a message/);
|
||
await input.waitFor({ timeout: 20000 });
|
||
await input.fill(script.prompt);
|
||
await page.getByRole("button", { name: /发送|Send/ }).click();
|
||
firstTheme = false;
|
||
}
|
||
await page.getByText(DONE_MARKER).first().waitFor({ timeout: 90000 });
|
||
await page.waitForTimeout(2000);
|
||
await saveWebp(await page.screenshot(), `chat-${lang}-${theme}.webp`);
|
||
|
||
// Trace view: select the session in the list (deep-link selection is unreliable
|
||
// right after a fresh navigation, so click explicitly — sidebar shows the same
|
||
// title first in DOM order, hence .last()).
|
||
await page.goto(`${BASE}/traces?sessionId=${sessionId}`);
|
||
await page.waitForTimeout(1500);
|
||
await page
|
||
.getByText(script.title)
|
||
.last()
|
||
.click()
|
||
.catch(() => {});
|
||
await page.waitForTimeout(2500);
|
||
await saveWebp(await page.screenshot(), `traces-${lang}-${theme}.webp`);
|
||
|
||
// Evaluation center: open the pre-provisioned example Benchmark scoreboard.
|
||
await page.goto(`${BASE}/benchmark`);
|
||
await page.waitForTimeout(1500);
|
||
await page
|
||
.getByText("Example Benchmark")
|
||
.first()
|
||
.click()
|
||
.catch(() => {});
|
||
await page.waitForTimeout(2500);
|
||
await saveWebp(await page.screenshot(), `benchmark-${lang}-${theme}.webp`);
|
||
|
||
await context.close();
|
||
}
|
||
}
|
||
|
||
await browser.close();
|
||
console.log(`[shots] done -> ${OUT_DIR}`);
|
||
process.exit(0);
|
||
} catch (err) {
|
||
console.error("[shots] FAILED:", err);
|
||
process.exit(1);
|
||
}
|