fix(ui): preserve mocked browser proof across reruns (#133918)

* fix(ui): preserve mocked browser proof across reruns

Give mocked browser captures run- and attempt-owned directories so reruns preserve earlier screenshots, recordings, and reports. Keep capture helpers and Mantis evidence consumers within the same directory owner.

* test(ci): align fixtures with capture allocation and source imports
This commit is contained in:
Peter Steinberger 2026-08-31 08:10:09 -07:00 • committed by GitHub
parent d6f13a7e08
commit e8ffa3557d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
269 changed files with 3043 additions and 2130 deletions

View file

@ -41,7 +41,10 @@ When running mocked Control UI/dashboard validation for a user-facing feature, p
- After or alongside the focused E2E test, run the mocked Control UI app when available, for example `pnpm dev:ui:mock -- --port <port>`.
- Drive Chromium with Playwright against the local mock URL and capture a video plus screenshots for each meaningful state: initial view, interaction input, result state, and final/paginated/selected state.
- Use `browser.newContext({ recordVideo: { dir, size }, viewport })`, `page.screenshot({ path })`, and close the context before reporting the video path.
- Put artifacts under `.artifacts/control-ui-e2e/<short-feature-name>/` or another clearly named local temp directory, and report the absolute paths in the final answer.
- Allocate retained proof with `createControlUiE2eArtifactDir(scope, parentDir?)` from `ui/src/test-helpers/control-ui-e2e-artifacts.ts`. Each call atomically creates a fresh directory and logs its actual path. An explicit parent wins, then the trimmed existing `OPENCLAW_UI_E2E_ARTIFACT_DIR`, then the repository's `.artifacts/control-ui-e2e` parent. Existing custom output controls select parents; do not add or rewrite env vars to enable capture.
- Allocate during the test/scenario or `beforeEach`, once per attempt; standalone scripts allocate once per invocation. Pass the owner explicitly to shared capture helpers. Keep the original gates, feature/stage names, viewports, waits, and recording options. Use distinct filenames for distinct stages and keep screenshots, reports, and video together.
- Retain successful and failed evidence. Report actual allocated paths, including relocated filename overrides. Manually delete only exact owned directories after review; never clear shared parents before a replay. Disposable build/media fixtures and owned temporary raw video may keep their cleanup. New synthetic captures do not recover overwritten evidence.
- Timeout diagnostics use fresh children beneath their existing diagnostic parent. Mantis retains every capture attempt under an invocation-owned directory and refuses to overwrite reports. Real-Gateway suites, `chat-outbox-*`, and `chat-attachment-read-lifecycle` remain separate owners; coordinate before claiming replay-safe retention there.
- Treat recording as validation, not only demo capture. If the recorder fails or shows surprising behavior, stop, fix the behavior, add or update a regression test, then rerecord.
- If visual proof is blocked, state the exact blocker and still report the textual E2E evidence.
@ -74,4 +77,4 @@ When recording an already-running mocked Control UI URL, use a temporary Playwri
- Open the mock URL, interact through stable `data-*` selectors or user-facing role selectors, and wait on asserted states instead of relying on fixed sleeps.
- Assert both visible UI state and mocked Gateway traffic for request-driven flows. For example, verify the expected count/row is visible and that `sessions.list` was called with the expected `search`, `offset`, and `limit`.
- Use short sleeps only after assertions to make the captured video readable.
- Store the generated video under `.artifacts/control-ui-e2e/<feature>/`; do not commit it.
- Store the generated video in the invocation's fresh allocated directory; do not commit it or remove older captures.

View file

@ -113,9 +113,9 @@ jobs:
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
outputs:
artifact_name: ${{ steps.run_mantis.outputs.artifact_name }}
output_dir: ${{ steps.run_mantis.outputs.output_dir }}
proof_status: ${{ steps.run_mantis.outputs.proof_status }}
artifact_name: ${{ steps.prepare_evidence.outputs.artifact_name }}
output_dir: ${{ steps.prepare_evidence.outputs.output_dir }}
proof_status: ${{ steps.build_evidence.outputs.proof_status }}
steps:
- name: Checkout harness ref
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -123,6 +123,18 @@ jobs:
persist-credentials: false
fetch-depth: 0
- name: Allocate invocation evidence directory
id: prepare_evidence
shell: bash
run: |
set -euo pipefail
output_parent="${GITHUB_WORKSPACE}/.artifacts/qa-e2e/mantis/web-ui-chat-proof"
mkdir -p "$output_parent"
root="$(mktemp -d "$output_parent/run.XXXXXX")"
echo "Retained Mantis invocation evidence: $root"
echo "artifact_name=mantis-web-ui-chat-proof-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
- name: Prepare Git owner
uses: openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475
@ -140,71 +152,68 @@ jobs:
shell: bash
run: |
set -euo pipefail
worktree_root=".artifacts/qa-e2e/mantis/web-ui-chat-proof-worktrees"
mkdir -p "$worktree_root"
python3 -I -S "$CI_GIT_OWNER" --checkout-git 0 worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA"
pnpm --dir "$worktree_root/candidate" install --frozen-lockfile --prefer-offline
root="${{ steps.prepare_evidence.outputs.output_dir }}"
{
worktree_root=".artifacts/qa-e2e/mantis/web-ui-chat-proof-worktrees"
mkdir -p "$worktree_root"
python3 -I -S "$CI_GIT_OWNER" --checkout-git 0 worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA"
pnpm --dir "$worktree_root/candidate" install --frozen-lockfile --prefer-offline
} 2>&1 | tee "$root/setup.log"
- name: Run web UI chat proof
id: run_mantis
env:
CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }}
CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }}
shell: bash
run: |
set -euo pipefail
artifact_name="mantis-web-ui-chat-proof-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
root="${{ steps.prepare_evidence.outputs.output_dir }}"
candidate_repo="$(pwd)/.artifacts/qa-e2e/mantis/web-ui-chat-proof-worktrees/candidate"
output_rel=".artifacts/qa-e2e/mantis/web-ui-chat-proof"
root="$candidate_repo/$output_rel"
mkdir -p "$root"
echo "artifact_name=${artifact_name}" >> "$GITHUB_OUTPUT"
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
{
install -D -m 0644 \
"${GITHUB_WORKSPACE}/ui/src/e2e/mantis-chat-proof.e2e.test.ts" \
"$candidate_repo/ui/src/e2e/mantis-chat-proof.e2e.test.ts"
install -D -m 0644 \
"${GITHUB_WORKSPACE}/ui/src/test-helpers/control-ui-e2e.ts" \
"$candidate_repo/ui/src/test-helpers/control-ui-e2e.ts"
install -D -m 0644 \
"${GITHUB_WORKSPACE}/ui/src/test-helpers/control-ui-e2e-artifacts.ts" \
"$candidate_repo/ui/src/test-helpers/control-ui-e2e-artifacts.ts"
install -D -m 0644 \
"${GITHUB_WORKSPACE}/ui/src/e2e/mantis-chat-proof.e2e.test.ts" \
"$candidate_repo/ui/src/e2e/mantis-chat-proof.e2e.test.ts"
install -D -m 0644 \
"${GITHUB_WORKSPACE}/ui/src/test-helpers/control-ui-e2e.ts" \
"$candidate_repo/ui/src/test-helpers/control-ui-e2e.ts"
cd "$candidate_repo"
node --import tsx scripts/ensure-playwright-chromium.mts
set +e
OPENCLAW_MANTIS_WEB_UI_CHAT_OUTPUT_DIR="$root" \
node scripts/run-vitest.mjs run \
--config test/vitest/vitest.ui-e2e.config.ts \
--configLoader runner \
ui/src/e2e/mantis-chat-proof.e2e.test.ts \
2>&1 | tee "$root/vitest.log"
vitest_exit="${PIPESTATUS[0]}"
set -e
proof_status="pass"
if [[ "$vitest_exit" -ne 0 ]]; then
proof_status="fail"
fi
echo "proof_status=${proof_status}" >> "$GITHUB_OUTPUT"
cd "$candidate_repo"
node --import tsx scripts/ensure-playwright-chromium.mts
OPENCLAW_MANTIS_WEB_UI_CHAT_OUTPUT_DIR="$root" \
OPENCLAW_UI_E2E_ARTIFACT_DIR="$root" \
node scripts/run-vitest.mjs run \
--config test/vitest/vitest.ui-e2e.config.ts \
--configLoader runner \
ui/src/e2e/mantis-chat-proof.e2e.test.ts
} 2>&1 | tee "$root/vitest.log"
- name: Build invocation evidence report
id: build_evidence
if: ${{ always() && steps.prepare_evidence.outputs.output_dir != '' }}
env:
CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }}
CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }}
PROOF_STATUS: ${{ steps.run_mantis.outcome == 'success' && 'pass' || 'fail' }}
shell: bash
run: |
set -euo pipefail
root="${{ steps.prepare_evidence.outputs.output_dir }}"
echo "proof_status=${PROOF_STATUS}" >> "$GITHUB_OUTPUT"
node "${GITHUB_WORKSPACE}/scripts/mantis/build-web-ui-chat-evidence.mjs" \
--output-dir "$root" \
--candidate-ref "$CANDIDATE_REF" \
--candidate-sha "$CANDIDATE_SHA" \
--status "$proof_status"
--status "$PROOF_STATUS"
cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY"
if [[ "$vitest_exit" -ne 0 ]]; then
exit "$vitest_exit"
fi
- name: Upload Mantis web UI chat artifacts
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
if: ${{ always() && steps.prepare_evidence.outputs.output_dir != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.run_mantis.outputs.artifact_name }}
path: ${{ steps.run_mantis.outputs.output_dir }}
name: ${{ steps.prepare_evidence.outputs.artifact_name }}
path: ${{ steps.prepare_evidence.outputs.output_dir }}
retention-days: 14
if-no-files-found: error

View file

@ -330,6 +330,40 @@ not sweep old directories or infer ownership from names, ages, or PIDs.
- Base Vitest config defaults to `pool: "threads"` and `isolate: false`, with the shared non-isolated runner enabled across repo configs.
- `pnpm test:channels` runs `vitest.channels.config.ts`.
### Retained mocked Control UI proof
Ordinary mocked browser screenshots, recordings, and reports use fresh directories
for each test attempt or standalone capture invocation. The Node-only
`createControlUiE2eArtifactDir(scope, parentDir?)` helper in
`ui/src/test-helpers/control-ui-e2e-artifacts.ts` prints the actual allocated path.
An explicit parent wins; otherwise it uses the trimmed existing
`OPENCLAW_UI_E2E_ARTIFACT_DIR`, then the repository's `.artifacts/control-ui-e2e`
parent. Existing feature-specific directory controls and script output arguments
select parents, with unique children beneath them. Explicit screenshot filename
controls preserve the basename and print the relocated path.
Keep capture gates independent from allocation: `OPENCLAW_CAPTURE_UI_PROOF`,
`OPENCLAW_UI_E2E_RECORD`, and output-presence gates retain their existing meanings.
Allocate during scenario execution or `beforeEach`, and pass the same owner to
shared capture helpers so screenshots, reports, and video stay together. Distinguish
stage names within an attempt. Close the browser context before finalizing video.
Successful and failed evidence is retained. Cleanup is manual: remove only exact
directories that you own and have finished reviewing. Never recursively delete
the shared parent before a replay. Disposable build/media fixtures and temporary
raw video have their own cleanup. New captures cannot recover overwritten evidence;
do not describe a replay as recovery of lost files.
Timeout diagnostics allocate fresh children beneath the existing
`OPENCLAW_UI_E2E_DIAGNOSTIC_DIR` or default timeout directory, keeping each PNG and
JSON report together. Mantis allocates an invocation directory for setup logs,
capture attempts, and its report; the builder preserves each attempt's relative
paths and refuses to overwrite an existing report.
Separate output owners remain: real-Gateway suites, `chat-outbox-*`, and
`chat-attachment-read-lifecycle`. Do not assume those owners have the ordinary
mocked proof retention guarantee.
## Gateway and E2E
- Gateway tests are included in the untargeted `pnpm test` full suite; run them alone with `pnpm test:gateway`.

View file

@ -1,9 +1,9 @@
#!/usr/bin/env node
// Captures composer mic-hover proof shots: idle vs hovered talk control, plus
// the mic button's x position in both states (the hover-shift regression).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
// Captures composer mic-hover proof shots: idle vs hovered talk control, plus
// the mic button's x position in both states (the hover-shift regression).
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -21,7 +21,8 @@ function readOption(name: string): string | undefined {
return index >= 0 ? process.argv[index + 1] : undefined;
}
const outputDir = path.resolve(
const outputDir = createControlUiE2eArtifactDir(
"composer-mic-hover-proof",
readOption("output-dir") ?? ".artifacts/control-ui-e2e/composer-mic-hover-proof",
);
const label = readOption("label") ?? "after";
@ -30,7 +31,6 @@ if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
const context = await browser.newContext({

View file

@ -1,10 +1,10 @@
#!/usr/bin/env node
import path from "node:path";
import { chromium } from "playwright";
// Captures webchat proof that a CLI harness-injected user turn renders as a
// collapsed system notice while the operator's own message keeps its bubble.
// Usage: node --import tsx scripts/capture-injected-turn-notice-proof.mts [--mode after|before]
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -26,7 +26,8 @@ const mode = readOption("mode") ?? "after";
if (mode !== "after" && mode !== "before") {
throw new Error(`Expected --mode after|before, received ${mode}`);
}
const outputDir = path.resolve(
const outputDir = createControlUiE2eArtifactDir(
"injected-turn-notice-proof",
readOption("output-dir") ?? ".artifacts/control-ui-e2e/injected-turn-notice-proof",
);
@ -78,7 +79,6 @@ if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
try {

View file

@ -1,11 +1,11 @@
#!/usr/bin/env node
import path from "node:path";
import { chromium } from "playwright";
// Captures chat model-picker proof shots: the open picker inheriting the agent
// default, the same picker with a session override, and the picker after a
// failed catalog refresh. Also reports the x offset between the provider
// heading label and the model row name (the row-alignment change).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -23,7 +23,8 @@ function readOption(name: string): string | undefined {
return index >= 0 ? process.argv[index + 1] : undefined;
}
const outputDir = path.resolve(
const outputDir = createControlUiE2eArtifactDir(
"model-picker-proof",
readOption("output-dir") ?? ".artifacts/control-ui-e2e/model-picker-proof",
);
const label = readOption("label") ?? "after";
@ -46,7 +47,6 @@ const models = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic", contextWindow: 200_000 },
];
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
const context = await browser.newContext({

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Page } from "playwright";
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -112,7 +112,8 @@ const ungroupedSessions = [
];
const mode = readMode();
const outputDir = path.resolve(
const outputDir = createControlUiE2eArtifactDir(
"session-toolbar-proof",
readOption("output-dir") ?? ".artifacts/control-ui-e2e/session-toolbar-proof",
);
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
@ -120,7 +121,6 @@ if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
const captured: string[] = [];

View file

@ -1,9 +1,9 @@
#!/usr/bin/env node
// Captures placement, inventory, and context-meter proof in both themes.
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -12,9 +12,11 @@ import {
} from "../ui/src/test-helpers/control-ui-e2e.ts";
const captureLabel = process.argv[2] ?? "after";
const outputDir = path.resolve(process.argv[3] ?? "/tmp/node-slot-pips-proof");
const outputDir = createControlUiE2eArtifactDir(
"node-slot-pips-proof",
process.argv[3] ?? "/tmp/node-slot-pips-proof",
);
const remoteExec = process.argv[4] === "remote-exec";
await mkdir(outputDir, { recursive: true });
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium unavailable at ${executablePath}`);

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
@ -22,13 +22,15 @@ function readOption(name: string): string | undefined {
}
const baseUrl = new URL(readOption("base-url") ?? DEFAULT_BASE_URL);
const outputDir = path.resolve(readOption("output-dir") ?? DEFAULT_OUTPUT_DIR);
const outputDir = createControlUiE2eArtifactDir(
"workboard-proof",
readOption("output-dir") ?? DEFAULT_OUTPUT_DIR,
);
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ executablePath });
const context = await browser.newContext({
colorScheme: "light",

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
// Builds a Mantis evidence manifest from Control UI web chat proof artifacts.
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -35,26 +35,27 @@ function normalizeStatus(value) {
throw new Error(`Unsupported web UI chat proof status: ${value}`);
}
function artifactEntry({ inline = false, kind, label, path: artifactPath, required, targetPath }) {
function artifactEntry({ inline = false, kind, label, path: artifactPath, required }) {
return {
kind,
lane: "candidate",
label,
path: artifactPath,
targetPath,
targetPath: artifactPath,
required,
...(inline ? { alt: label, inline: true, width: 900 } : {}),
};
}
export function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status }) {
function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status, captures }) {
const passed = status === "pass";
return {
schemaVersion: 2,
id: "web-ui-chat-proof",
title: "Mantis Web UI Chat Proof",
summary:
"Mantis ran the OpenClaw Control UI chat proof against the candidate ref, sent a message through the mocked Gateway, rendered the final reply in the browser, and captured browser artifacts for review.",
summary: passed
? "Mantis ran the OpenClaw Control UI chat proof against the candidate ref, sent a message through the mocked Gateway, rendered the final reply in the browser, and captured browser artifacts for review."
: "Mantis could not complete the Control UI chat proof. Retained attempt artifacts and logs describe the failure.",
scenario: "web-ui-chat-proof",
comparison: {
candidate: {
@ -69,34 +70,38 @@ export function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, sta
pass: passed,
},
artifacts: [
artifactEntry({
inline: true,
kind: "desktopScreenshot",
label: "Control UI web chat proof",
path: "web-ui-chat.png",
required: passed,
targetPath: "web-ui-chat.png",
}),
artifactEntry({
kind: "fullVideo",
label: "Control UI web chat recording",
path: "web-ui-chat.webm",
required: false,
targetPath: "web-ui-chat.webm",
}),
artifactEntry({
kind: "metadata",
label: "Control UI web chat proof metadata",
path: "web-ui-chat-proof.json",
required: passed,
targetPath: "web-ui-chat-proof.json",
}),
...captures.flatMap(({ directory, complete }) => [
artifactEntry({
inline: true,
kind: "desktopScreenshot",
label: `Control UI web chat proof (${directory})`,
path: `${directory}/web-ui-chat.png`,
required: passed && complete,
}),
artifactEntry({
kind: "fullVideo",
label: `Control UI web chat recording (${directory})`,
path: `${directory}/web-ui-chat.webm`,
required: false,
}),
artifactEntry({
kind: "metadata",
label: `Control UI web chat proof metadata (${directory})`,
path: `${directory}/web-ui-chat-proof.json`,
required: passed && complete,
}),
]),
artifactEntry({
kind: "metadata",
label: "Control UI web chat Vitest log",
path: "vitest.log",
required: false,
targetPath: "vitest.log",
}),
artifactEntry({
kind: "metadata",
label: "Control UI web chat setup log",
path: "setup.log",
required: false,
}),
{
kind: "report",
@ -109,7 +114,7 @@ export function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, sta
};
}
function renderReport({ candidateRef, candidateSha, outputDir, status }) {
function renderReport({ candidateRef, candidateSha, outputDir, status, artifacts }) {
const artifactStatus = (artifactPath) =>
existsSync(path.join(outputDir, artifactPath)) ? "present" : "missing";
return [
@ -121,14 +126,16 @@ function renderReport({ candidateRef, candidateSha, outputDir, status }) {
"",
"## Scenario",
"",
"OpenClaw Control UI chat was loaded in a browser with the mocked Gateway harness. The proof sends a chat message through the GUI, verifies the `chat.send` request, emits a final Gateway reply, and waits for the reply to render in the web chat thread.",
"The scenario loads OpenClaw Control UI chat in a browser with the mocked Gateway harness, sends a chat message through the GUI, verifies the `chat.send` request, emits a final Gateway reply, and waits for the reply to render in the web chat thread.",
"",
"## Artifacts",
"",
`- Screenshot: \`web-ui-chat.png\` (${artifactStatus("web-ui-chat.png")})`,
`- Recording: \`web-ui-chat.webm\` (${artifactStatus("web-ui-chat.webm")})`,
`- Proof metadata: \`web-ui-chat-proof.json\` (${artifactStatus("web-ui-chat-proof.json")})`,
`- Vitest log: \`vitest.log\` (${artifactStatus("vitest.log")})`,
...artifacts
.filter((artifact) => artifact.kind !== "report")
.map(
(artifact) =>
`- ${artifact.label}: \`${artifact.path}\` (${artifactStatus(artifact.path)})`,
),
"",
].join("\n");
}
@ -143,13 +150,35 @@ export function writeWebUiChatEvidence(rawArgs = process.argv.slice(2)) {
}
const outputDir = path.resolve(args.output_dir);
mkdirSync(outputDir, { recursive: true });
const reportPath = path.join(outputDir, "mantis-report.md");
const manifestPath = path.join(outputDir, "mantis-evidence.json");
if (existsSync(reportPath) || existsSync(manifestPath)) {
throw new Error("Evidence already exists in --output-dir; use a fresh invocation directory.");
}
const status = normalizeStatus(args.status);
// The workflow owns this fresh invocation root. Keep every attempt, including
// incomplete retries; never pick a newest capture from a shared history root.
const captures = readdirSync(outputDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("mantis-chat-proof-"))
.map((entry) => entry.name)
.toSorted()
.map((directory) => ({
directory,
complete: ["web-ui-chat.png", "web-ui-chat-proof.json"].every((file) =>
existsSync(path.join(outputDir, directory, file)),
),
}));
if (status === "pass" && !captures.some((capture) => capture.complete)) {
throw new Error(
"Passing proof requires a captured screenshot and proof metadata in --output-dir.",
);
}
const manifest = buildWebUiChatEvidenceManifest({
candidateRef: args.candidate_ref,
candidateSha: args.candidate_sha,
status,
captures,
});
const reportPath = path.join(outputDir, "mantis-report.md");
writeFileSync(
reportPath,
renderReport({
@ -157,11 +186,14 @@ export function writeWebUiChatEvidence(rawArgs = process.argv.slice(2)) {
candidateSha: args.candidate_sha,
outputDir,
status,
artifacts: manifest.artifacts,
}),
"utf8",
{ encoding: "utf8", flag: "wx" },
);
const manifestPath = path.join(outputDir, "mantis-evidence.json");
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
});
return { manifest, manifestPath, reportPath };
}

View file

@ -115,6 +115,7 @@ export async function runCiGitStep(options: {
policy?: string;
inlinePolicy?: boolean;
step?: string;
stepOutputs?: Record<string, Record<string, string>>;
env?: Record<string, string>;
fetchResults: FetchResult[];
cloneResults?: FetchResult[];
@ -409,6 +410,11 @@ def main():`,
}),
);
let run = renderGitTestClock(step.run, clock);
for (const [stepId, outputs] of Object.entries(options.stepOutputs ?? {})) {
for (const [name, value] of Object.entries(outputs)) {
run = run.replaceAll(`\${{ steps.${stepId}.outputs.${name} }}`, value);
}
}
if (externalOwner) {
const prepare = parse(readFileSync(".github/actions/git-owner/action.yml", "utf8")) as {
runs: { steps: { run?: string }[] };

View file

@ -934,6 +934,12 @@ posixIt.each([
},
fetchResults: [],
worktreeResults: failure ? ["cleanup-failure"] : lanes.map(() => 0),
// The runner hands off the earlier allocation's output; keep setup logs
// inside this fixture's fresh invocation directory.
stepOutputs:
workflow === "web-ui-chat-proof"
? { prepare_evidence: { output_dir: "${RUNNER_TEMP}" } }
: undefined,
realClock: true,
realDrain: false,
poisonPython: true,

View file

@ -1,12 +1,13 @@
// Mantis web UI chat evidence tests cover manifest generation.
// Mantis web UI chat evidence tests cover retained capture-to-publisher identity.
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { writeWebUiChatEvidence } from "../../scripts/mantis/build-web-ui-chat-evidence.mjs";
import {
buildWebUiChatEvidenceManifest,
writeWebUiChatEvidence,
} from "../../scripts/mantis/build-web-ui-chat-evidence.mjs";
loadEvidenceManifest,
renderEvidenceComment,
} from "../../scripts/mantis/publish-pr-evidence.mjs";
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-mantis-web-ui-chat-"));
@ -17,89 +18,149 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
}
function captureAttempt(parent: string, marker: string, complete = true): string {
const directory = mkdtempSync(path.join(parent, "mantis-chat-proof-"));
writeFileSync(path.join(directory, "web-ui-chat.png"), `${marker} screenshot`);
writeFileSync(path.join(directory, "web-ui-chat.webm"), `${marker} video`);
if (complete) {
const metadata = JSON.stringify({ status: "pass" });
writeFileSync(path.join(directory, "web-ui-chat-proof.json"), metadata);
}
return directory;
}
describe("build-web-ui-chat-evidence", () => {
it("marks a passing Control UI chat proof as publishable visible Mantis evidence", () => {
const manifest = buildWebUiChatEvidenceManifest({
candidateRef: "HEAD",
candidateSha: "1234567890abcdef1234567890abcdef12345678",
status: "pass",
});
expect(manifest).toMatchObject({
id: "web-ui-chat-proof",
scenario: "web-ui-chat-proof",
schemaVersion: 2,
comparison: {
candidate: {
expectationMet: true,
fixed: true,
ref: "HEAD",
sha: "1234567890abcdef1234567890abcdef12345678",
status: "pass",
},
pass: true,
},
});
expect(manifest.artifacts).toContainEqual(
expect.objectContaining({
inline: true,
kind: "desktopScreenshot",
path: "web-ui-chat.png",
required: true,
}),
);
});
it("writes a failing manifest without requiring a missing screenshot", () => {
it("publishes every attempt at its exact retained path without mixing invocation roots", () => {
withTempDir((dir) => {
const priorRoot = mkdtempSync(path.join(dir, "run-"));
const priorCapture = captureAttempt(priorRoot, "prior");
const prior = writeWebUiChatEvidence(["--output-dir", priorRoot, "--status", "pass"]);
const priorReport = readFileSync(prior.reportPath, "utf8");
const priorManifest = readFileSync(prior.manifestPath, "utf8");
const root = mkdtempSync(path.join(dir, "run-"));
const incomplete = captureAttempt(root, "incomplete", false);
const complete = captureAttempt(root, "complete");
writeFileSync(path.join(root, "vitest.log"), "current invocation log");
const result = writeWebUiChatEvidence([
"--output-dir",
dir,
root,
"--candidate-ref",
"main",
"--candidate-sha",
"abcdef",
"1234567890abcdef1234567890abcdef12345678",
"--status",
"fail",
"pass",
]);
expect(existsSync(result.manifestPath)).toBe(true);
expect(existsSync(result.reportPath)).toBe(true);
const manifest = JSON.parse(readFileSync(result.manifestPath, "utf8"));
expect(manifest.comparison.pass).toBe(false);
expect(manifest.comparison.candidate.expectationMet).toBe(false);
expect(
manifest.artifacts.find(
(artifact: { path: string }) => artifact.path === "web-ui-chat.png",
),
).toMatchObject({
required: false,
const manifest = loadEvidenceManifest(result.manifestPath);
expect(manifest).toMatchObject({
id: "web-ui-chat-proof",
scenario: "web-ui-chat-proof",
schemaVersion: 2,
comparison: {
candidate: { expectationMet: true, fixed: true, ref: "main", status: "pass" },
pass: true,
},
});
const screenshots = manifest.artifacts.filter(
(artifact) => artifact.kind === "desktopScreenshot",
);
expect(screenshots.map((artifact) => artifact.source).sort()).toEqual(
[incomplete, complete].map((directory) => path.join(directory, "web-ui-chat.png")).sort(),
);
const comment = renderEvidenceComment({
manifest,
marker: "<!-- synthetic-mantis-proof -->",
rawBase: "https://artifacts.example.test/current",
});
for (const [directory, marker] of [
[incomplete, "incomplete"],
[complete, "complete"],
] as const) {
const relative = path.basename(directory);
for (const [file, content] of [
["web-ui-chat.png", "screenshot"],
["web-ui-chat.webm", "video"],
]) {
const artifact = manifest.artifacts.find((entry) => entry.path === `${relative}/${file}`);
expect(artifact?.targetPath).toBe(`${relative}/${file}`);
expect(readFileSync(artifact!.source, "utf8")).toBe(`${marker} ${content}`);
expect(comment).toContain(`https://artifacts.example.test/current/${relative}/${file}`);
expect(readFileSync(result.reportPath, "utf8")).toContain(`${relative}/${file}`);
}
}
expect(readFileSync(prior.reportPath, "utf8")).toBe(priorReport);
expect(readFileSync(prior.manifestPath, "utf8")).toBe(priorManifest);
expect(readFileSync(path.join(priorCapture, "web-ui-chat.png"), "utf8")).toBe(
"prior screenshot",
);
expect(readFileSync(result.reportPath, "utf8")).not.toContain(path.basename(priorCapture));
});
});
it("retains setup failures before a capture exists as publishable failure evidence", () => {
withTempDir((dir) => {
writeFileSync(path.join(dir, "setup.log"), "candidate installation failed");
const result = writeWebUiChatEvidence(["--output-dir", dir, "--status", "fail"]);
const manifest = loadEvidenceManifest(result.manifestPath);
expect(manifest.comparison).toMatchObject({
candidate: { expectationMet: false, status: "fail" },
pass: false,
});
expect(manifest.artifacts.map((artifact) => artifact.targetPath)).toEqual([
"setup.log",
"mantis-report.md",
"mantis-evidence.json",
]);
expect(manifest.summary).toContain("could not complete");
expect(readFileSync(result.reportPath, "utf8")).toContain("`setup.log` (present)");
});
});
it("keeps a failed process verdict even when an attempt completed its captures", () => {
withTempDir((dir) => {
captureAttempt(dir, "completed before process failure");
const result = writeWebUiChatEvidence(["--output-dir", dir, "--status", "fail"]);
expect(loadEvidenceManifest(result.manifestPath).comparison.pass).toBe(false);
expect(readFileSync(result.reportPath, "utf8")).toContain("Status: fail");
});
});
it("uses the explicit pass status and includes report output", () => {
withTempDir((dir) => {
writeFileSync(path.join(dir, "web-ui-chat.png"), "png");
writeFileSync(
path.join(dir, "web-ui-chat-proof.json"),
`${JSON.stringify({ status: "pass" })}\n`,
);
const result = writeWebUiChatEvidence([
"--output-dir",
dir,
"--candidate-ref",
"main",
"--status",
"pass",
]);
expect(result.manifest.comparison).toMatchObject({
candidate: { fixed: true, ref: "main", status: "pass" },
pass: true,
it.each(["absent", "old flat files", "split attempts"])(
"rejects a passing verdict with %s instead of using unrelated proof",
(kind) => {
withTempDir((dir) => {
const metadata = JSON.stringify({ status: "pass" });
if (kind === "old flat files") {
writeFileSync(path.join(dir, "web-ui-chat.png"), "old screenshot");
writeFileSync(path.join(dir, "web-ui-chat-proof.json"), metadata);
} else if (kind === "split attempts") {
captureAttempt(dir, "screenshot only", false);
const metadataOnly = mkdtempSync(path.join(dir, "mantis-chat-proof-"));
writeFileSync(path.join(metadataOnly, "web-ui-chat-proof.json"), metadata);
}
expect(() => writeWebUiChatEvidence(["--output-dir", dir, "--status", "pass"])).toThrow(
"Passing proof requires a captured screenshot and proof metadata",
);
expect(existsSync(path.join(dir, "mantis-evidence.json"))).toBe(false);
});
expect(readFileSync(result.reportPath, "utf8")).toContain(
"Screenshot: `web-ui-chat.png` (present)",
},
);
it("refuses to rewrite reports when the builder is replayed against the same invocation", () => {
withTempDir((dir) => {
captureAttempt(dir, "original");
const original = writeWebUiChatEvidence(["--output-dir", dir, "--status", "pass"]);
const report = readFileSync(original.reportPath, "utf8");
const manifest = readFileSync(original.manifestPath, "utf8");
const retry = captureAttempt(dir, "retained retry", false);
expect(() => writeWebUiChatEvidence(["--output-dir", dir, "--status", "fail"])).toThrow(
"use a fresh invocation directory",
);
expect(readFileSync(original.reportPath, "utf8")).toBe(report);
expect(readFileSync(original.manifestPath, "utf8")).toBe(manifest);
expect(readFileSync(path.join(retry, "web-ui-chat.png"), "utf8")).toBe(
"retained retry screenshot",
);
});
});

View file

@ -7,6 +7,9 @@ const WORKFLOW = ".github/workflows/mantis-web-ui-chat-proof.yml";
const SHARED_RESOLVE_WORKFLOW = ".github/workflows/mantis-resolve-request.yml";
type WorkflowStep = {
id?: string;
if?: string;
env?: Record<string, string>;
name?: string;
run?: string;
uses?: string;
@ -14,6 +17,7 @@ type WorkflowStep = {
};
type WorkflowJob = {
outputs?: Record<string, string>;
permissions?: Record<string, string>;
steps?: WorkflowStep[];
};
@ -91,6 +95,37 @@ describe("Mantis Web UI chat proof workflow", () => {
expect(publish?.run).not.toContain("--import tsx");
});
it("retains one invocation root through capture, failure reporting, and artifact upload", () => {
const job = workflowJob("run_web_ui_chat");
const allocate = job.steps?.find((step) => step.id === "prepare_evidence");
const capture = job.steps?.find((step) => step.id === "run_mantis");
const report = job.steps?.find((step) => step.id === "build_evidence");
const upload = job.steps?.find((step) => step.name === "Upload Mantis web UI chat artifacts");
const rootOutput = "${{ steps.prepare_evidence.outputs.output_dir }}";
expect(allocate?.run).toContain('mktemp -d "$output_parent/run.XXXXXX"');
expect(capture?.run).toContain(`root="${rootOutput}"`);
expect(capture?.run).toContain('OPENCLAW_MANTIS_WEB_UI_CHAT_OUTPUT_DIR="$root"');
expect(capture?.run).toContain('OPENCLAW_UI_E2E_ARTIFACT_DIR="$root"');
// Older candidates need the allocator imported by both overlaid harness files.
expect(capture?.run).toContain(
'"${GITHUB_WORKSPACE}/ui/src/test-helpers/control-ui-e2e-artifacts.ts"',
);
expect(capture?.run).toContain(
'"$candidate_repo/ui/src/test-helpers/control-ui-e2e-artifacts.ts"',
);
expect(report?.if).toContain("always()");
expect(report?.run).toContain(`root="${rootOutput}"`);
expect(report?.run).toContain('--output-dir "$root"');
expect(report?.env?.PROOF_STATUS).toBe(
"${{ steps.run_mantis.outcome == 'success' && 'pass' || 'fail' }}",
);
expect(upload?.if).toContain("always()");
expect(upload?.with?.path).toBe(rootOutput);
expect(job.outputs?.output_dir).toBe(rootOutput);
expect(upload?.with?.name).toBe(job.outputs?.artifact_name);
});
it("only treats explicit candidate assignments as PR head overrides", () => {
const pattern = candidateOverridePattern();

View file

@ -6606,10 +6606,16 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
for (const [workflowPath, jobName, count] of cases) {
const job = workflowJob(workflowPath, jobName);
const initialSteps = [
"Checkout harness ref",
...(jobName === "run_web_ui_chat" ? ["Allocate invocation evidence directory"] : []),
"Prepare Git owner",
"Setup Node environment",
];
expect(
job.steps?.slice(0, 3).map(({ name }) => name),
job.steps?.slice(0, initialSteps.length).map(({ name }) => name),
workflowPath,
).toEqual(["Checkout harness ref", "Prepare Git owner", "Setup Node environment"]);
).toEqual(initialSteps);
expect(job.steps?.filter(({ name }) => name === "Prepare Git owner")).toEqual([
{
name: "Prepare Git owner",

View file

@ -1,8 +1,8 @@
// Control UI E2E tests cover Codex final-answer candidate Activity rendering.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -14,18 +14,17 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"codex-answer-candidates",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("codex-answer-candidates");
}
});
async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -35,9 +34,6 @@ async function screenshot(page: Page, name: string) {
suite.define(() => {
it("shows superseded and authoritative selected answers only in Activity", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",

View file

@ -1,8 +1,9 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Locator, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { AuditRunInspectResult } from "../../../packages/gateway-protocol/src/schema/audit-run.js";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -21,7 +22,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.resolve(".artifacts/control-ui-e2e/activity-run-inspector-r6");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("activity-run-inspector-r6");
}
});
let browser: Browser;
let server: ControlUiE2eServer;
@ -91,7 +97,6 @@ async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiBundledGatewayUrl,
controlUiSessionUrl,
@ -15,7 +15,10 @@ const suite = createControlUiE2eSuite({
startServerBeforeBrowser: true,
});
const outputDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/session-activity-feed");
let outputDir: string;
beforeEach(() => {
outputDir = createControlUiE2eArtifactDir("session-activity-feed");
});
const proofPhase = process.env.OPENCLAW_MENU_THEME_PROOF_PHASE;
suite.define(() => {
@ -295,7 +298,6 @@ suite.define(() => {
const onlineToggle = page.getByRole("button", { name: "Online", exact: true });
await expect.poll(() => onlineToggle.getAttribute("aria-expanded")).toBe("true");
await expect.poll(() => page.locator(".sidebar-online__person").count()).toBe(4);
await mkdir(outputDir, { recursive: true });
await page.locator(".sidebar").screenshot({
animations: "disabled",
path: path.join(outputDir, "01-sidebar-online-default-open-light.png"),

View file

@ -1,6 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -11,7 +12,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.resolve(".artifacts/control-ui-e2e/control-ui-activity-summaries");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("control-ui-activity-summaries");
}
});
suite.define(() => {
it("updates one visible tool summary from running to completed output", async () => {

View file

@ -1,8 +1,8 @@
// Control UI E2E tests cover Agents channel runtime-status precedence.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -14,13 +14,17 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "channel-runtime-status");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("channel-runtime-status");
}
});
async function screenshot(page: Page) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,8 +1,8 @@
// Control UI E2E proves per-agent config writes use the canonical keyed shape.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -13,7 +13,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "agent-config-save");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-config-save");
}
});
const requireRecord = createRequireRecord("record", "expected-object-value");
@ -52,7 +57,6 @@ suite.define(() => {
const reload = agentsPage.getByRole("button", { name: "Reload Config" });
await reload.waitFor();
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -194,7 +198,6 @@ suite.define(() => {
await page.getByRole("alert").filter({ hasText: "mock validation failure" }).waitFor();
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,8 +1,8 @@
// Control UI E2E proves the complete Agents -> Tools GitHub authorization presentation.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -13,18 +13,17 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"agent-github-device-authorization",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-github-device-authorization");
}
});
async function capture(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,7 +1,7 @@
// Real browser proof that agent model fallbacks follow the Gateway's ownership contract.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -15,7 +15,12 @@ const primaryModel = "openai/gpt-5.4";
const inheritedFallback = "anthropic/claude-sonnet-4-6";
const writerWorkspace = "/tmp/agents/writer";
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.resolve(".artifacts/control-ui-e2e/agent-context-ownership");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-context-ownership");
}
});
suite.define(() => {
it.each([
@ -120,7 +125,6 @@ suite.define(() => {
.toBe("/settings/agents/writer/overview");
if (captureUiProof && model && !("primary" in Object(model))) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,8 +1,8 @@
// Control UI E2E tests cover chip-selected page scope and the all-agents escape.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway, type MockGatewayControls } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -14,7 +14,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "agent-page-scope");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-page-scope");
}
});
function requestParams(request: { params?: unknown }): Record<string, unknown> {
return request.params && typeof request.params === "object"
@ -38,7 +43,6 @@ async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -205,9 +209,6 @@ suite.define(() => {
});
it("keeps a refreshed canonical roster while chat startup remains delayed", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",

View file

@ -1,8 +1,8 @@
// Control UI tests cover bounded authenticated agent-picker avatar fetches.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -14,18 +14,17 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"agent-select-avatar-timeout",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-select-avatar-timeout");
}
});
async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { BrowserContext, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiBundledGatewayUrl,
controlUiBundledSettingsStorageKey,
@ -20,12 +20,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"agent-selection-persistence",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("agent-selection-persistence");
}
});
const agentsList = {
agents: [
@ -78,7 +78,6 @@ async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.waitForTimeout(600);
await page.screenshot({
animations: "disabled",
@ -139,9 +138,6 @@ async function openPage(
suite.define(() => {
it("restores the selected agent when a fresh shell reopens the canonical global session", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",

View file

@ -1,8 +1,8 @@
// Control UI tests cover the canonical scrollbar profile and native-style text selection.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
installMockGateway,
waitForControlUiSettingsTakeover,
@ -17,12 +17,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"app-chrome-interaction",
);
let uiProofArtifactDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
uiProofArtifactDir = createControlUiE2eArtifactDir("app-chrome-interaction");
}
});
async function dragAcross(page: Page, locator: Locator): Promise<string> {
await locator.scrollIntoViewIfNeeded();
@ -52,7 +52,6 @@ async function captureUiProof(page: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
}
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, fileName),
@ -61,9 +60,6 @@ async function captureUiProof(page: Page, fileName: string) {
suite.define(() => {
it("keeps canonical scrollbars without horizontal model-picker overflow and preserves selection", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",
@ -258,9 +254,6 @@ suite.define(() => {
});
it("resolves the scrollbar thumb from theme tokens and captures dark/light proof", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
const thumbColorByScheme: Record<"dark" | "light", string> = { dark: "", light: "" };
for (const colorScheme of ["dark", "light"] as const) {
await suite.withPage(

View file

@ -1,9 +1,9 @@
// Control UI tests cover Appearance override provenance and restoring product defaults.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { importCustomThemeFromUrl } from "../pages/config/custom-theme-import.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiBundledGatewayUrl,
controlUiBundledSettingsStorageKey,
@ -24,12 +24,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"appearance-settings-defaults",
);
let uiProofArtifactDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
uiProofArtifactDir = createControlUiE2eArtifactDir("appearance-settings-defaults");
}
});
function settingsStorageKey(): string {
return controlUiBundledSettingsStorageKey(suite.server.baseUrl);
}
@ -169,7 +169,6 @@ async function captureViewport(page: Page, filename: string): Promise<void> {
if (!captureUiProofEnabled) {
return;
}
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, filename),

View file

@ -1,9 +1,9 @@
// Control UI E2E tests cover approval queue behavior through the Gateway WebSocket.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import type { Page } from "playwright";
import { afterEach, expect, it } from "vitest";
import { afterEach, beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -15,7 +15,12 @@ const suite = createControlUiE2eSuite({
let page: Page | undefined;
const activeSessionKey = "agent:main:main";
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "approval-flow");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("approval-flow");
}
});
function approval(id: string, command: string, createdAtMs: number, sessionKey = activeSessionKey) {
return {
@ -95,9 +100,6 @@ suite.define(() => {
});
it("keeps approvals passive until the Inbox opens the full queue", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await suite.browser.newContext({
viewport: { height: 800, width: 1200 },
...(captureUiProof
@ -144,9 +146,6 @@ suite.define(() => {
});
it("keeps no-auth inline and Inbox approvals readable while blocking decisions", async () => {
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;

View file

@ -1,11 +1,12 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import { copyFile, rm } from "node:fs/promises";
import path from "node:path";
import type { Browser, BrowserContext, Page, Video } from "playwright";
import { afterEach, expect, it } from "vitest";
import { afterEach, beforeEach, expect, it } from "vitest";
import type {
AllowedApprovalSnapshot,
PendingApprovalSnapshot,
} from "../../../packages/gateway-protocol/src/index.js";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiE2eWaitTimeoutMs,
installMockGateway,
@ -21,9 +22,8 @@ const suite = createControlUiE2eSuite({
const APPROVAL_ID = "Approval:Mobile/東京 100% 🦞";
const APPROVAL_NOW_MS = Date.UTC(2026, 6, 10, 18, 0, 0);
const ARTIFACT_DIR = path.resolve(".artifacts/control-ui-e2e/approval-page");
let ARTIFACT_DIR: string;
const CAPTURE_UI_PROOF = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const MOBILE_RAW_VIDEO_DIR = path.join(ARTIFACT_DIR, "mobile-raw");
const MOBILE_VIEWPORT = { height: 844, width: 390 } as const;
const DESKTOP_VIEWPORT = { height: 800, width: 1200 } as const;
@ -36,7 +36,6 @@ type ApprovalSurface = {
};
const openContexts = new Set<BrowserContext>();
const rawVideoDirs = new Set<string>();
function approvalPath(basePath: string): string {
return `${basePath}/approve/${encodeURIComponent(APPROVAL_ID)}`;
@ -116,13 +115,10 @@ async function createSurface(params: {
recordVideo?: boolean;
viewport: { height: number; width: number };
}): Promise<ApprovalSurface> {
const rawVideoDir = params.recordVideo && CAPTURE_UI_PROOF ? MOBILE_RAW_VIDEO_DIR : undefined;
if (rawVideoDir) {
await mkdir(ARTIFACT_DIR, { recursive: true });
await rm(rawVideoDir, { force: true, recursive: true });
await mkdir(rawVideoDir, { recursive: true });
rawVideoDirs.add(rawVideoDir);
}
const rawVideoDir =
params.recordVideo && CAPTURE_UI_PROOF
? createControlUiE2eArtifactDir("mobile-raw", ARTIFACT_DIR)
: undefined;
const context = await requireBrowser().newContext({
colorScheme: "dark",
hasTouch: params.viewport.width <= MOBILE_VIEWPORT.width,
@ -158,7 +154,7 @@ async function closeRecordedSurface(surface: ApprovalSurface, targetName: string
}
const rawVideoPath = await video.path();
await copyFile(rawVideoPath, path.join(ARTIFACT_DIR, targetName));
rawVideoDirs.delete(surface.rawVideoDir);
// Remove raw footage only after the retained named copy succeeds.
await rm(surface.rawVideoDir, { force: true, recursive: true });
}
@ -209,7 +205,6 @@ async function captureProof(page: Page, name: string): Promise<void> {
if (!CAPTURE_UI_PROOF) {
return;
}
await mkdir(ARTIFACT_DIR, { recursive: true });
await waitForStableApprovalPaint(page);
await page.screenshot({ path: path.join(ARTIFACT_DIR, name) });
}
@ -218,7 +213,6 @@ async function captureDocumentProof(page: Page, name: string): Promise<void> {
if (!CAPTURE_UI_PROOF) {
return;
}
await mkdir(ARTIFACT_DIR, { recursive: true });
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise<void>((resolve) => {
@ -287,13 +281,14 @@ async function expectMobilePendingLayout(page: Page): Promise<void> {
}
suite.define(() => {
beforeEach(() => {
if (CAPTURE_UI_PROOF) {
ARTIFACT_DIR = createControlUiE2eArtifactDir("approval-page");
}
});
afterEach(async () => {
await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
openContexts.clear();
await Promise.all(
[...rawVideoDirs].map((rawVideoDir) => rm(rawVideoDir, { force: true, recursive: true })),
);
rawVideoDirs.clear();
});
it("keeps one canonical winner across conflicting surfaces and terminal reload", async () => {

View file

@ -1,9 +1,9 @@
// Control UI E2E: grapheme-aware avatar initials remain intact across every
// live agent-avatar fallback surface.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -15,7 +15,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "avatar-initial-emoji");
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("avatar-initial-emoji");
}
});
const emojiAgent = { id: "emoji", identity: { name: "🚀Rocket" }, name: "🚀Rocket" };
const asciiAgent = { id: "main", identity: { name: "Main" }, name: "Main" };
@ -43,7 +48,6 @@ async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -9,6 +9,7 @@ import { buildWidgetDocument } from "../../../src/canvas/wrap.js";
import { buildBoardWidgetSandboxPath } from "../../../src/gateway/board-sandbox.js";
import { createSandboxHostHttpServer } from "../../../src/gateway/mcp-app-sandbox-http.js";
import { getGatewayE2ePortBlock } from "../../../src/gateway/test-helpers.e2e.js";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiBundledSettingsStorageKey,
@ -266,8 +267,7 @@ describeControlUiE2e("Control UI dashboard A2UI", () => {
expect(scrollbar.ratio).toBeLessThan(0.2);
if (scrollbarProofLabel) {
const screenshotPath = path.resolve(
process.cwd(),
".artifacts/control-ui-e2e/widget-scrollbar",
createControlUiE2eArtifactDir("widget-scrollbar"),
`${scrollbarProofLabel}-${colorScheme}.png`,
);
await mkdir(path.dirname(screenshotPath), { recursive: true });

View file

@ -1,10 +1,10 @@
// Regression: the chat transcript must repaint after a dashboard -> split face
// switch re-stamps it into the sidebar region (issue: virtualizer stayed
// detached until an unrelated re-render, painting a blank pane).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiBundledSettingsStorageKey,
@ -21,7 +21,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const sessionKey = "agent:main:board-split-transcript";
const proofDir = path.resolve(".artifacts/control-ui-e2e/dashboard-side-chat-tabs");
let proofDir: string;
beforeEach(() => {
if (process.env.OPENCLAW_UI_E2E_RECORD === "1") {
proofDir = createControlUiE2eArtifactDir("dashboard-side-chat-tabs");
}
});
let browser: Browser;
let controlUi: ControlUiE2eServer;
@ -270,9 +275,6 @@ describeControlUiE2e("Board split transcript restore", () => {
it("transitions between Dashboard and Split with the whole side panel", async () => {
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
if (recordProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { width: 1400, height: 900 },
...(recordProof
@ -380,9 +382,6 @@ describeControlUiE2e("Board split transcript restore", () => {
it("activates Side chat from a split dashboard panel", async () => {
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
if (recordProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { width: 1400, height: 900 },
...(recordProof
@ -445,9 +444,6 @@ describeControlUiE2e("Board split transcript restore", () => {
it("does not offer Discussion when the gateway has no discussion provider", async () => {
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
if (recordProof) {
await mkdir(proofDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { width: 1400, height: 900 },
...(recordProof

View file

@ -1,3 +1,4 @@
import path from "node:path";
// Control UI E2E tests cover visible browser dictation state through a real composer.
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
@ -69,7 +70,11 @@ suite.define(() => {
const committedDraft = cancel ? "ship it" : expected;
expect(await textarea.inputValue()).toBe(committedDraft);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await captureComposerProof(page, `dictation-${name.replaceAll(" ", "-")}-committed.png`);
await captureComposerProof(
suite,
page,
`dictation-${name.replaceAll(" ", "-")}-committed.png`,
);
expect(await textarea.inputValue()).toBe(committedDraft);
});
});
@ -132,7 +137,7 @@ suite.define(() => {
await expect
.poll(() => textarea.inputValue())
.toBe("keep this draft discard this speech too");
await captureComposerProof(page, "dictation-new-session-recognized-preview.png");
await captureComposerProof(suite, page, "dictation-new-session-recognized-preview.png");
await page.keyboard.press("Escape");
await expect.poll(() => textarea.inputValue()).toBe("keep this draft");
await gateway.waitForRequest("talk.session.close");
@ -140,7 +145,7 @@ suite.define(() => {
true,
);
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
await captureComposerProof(page, "dictation-new-session-cancelled.png");
await captureComposerProof(suite, page, "dictation-new-session-cancelled.png");
});
});
@ -173,10 +178,10 @@ suite.define(() => {
await expect.poll(() => toggle.getAttribute("aria-checked")).toBe("false");
await expect.poll(() => picker.getAttribute("open")).not.toBeNull();
await captureComposerProof(page, "microphone-picker-hold-toggle.png");
await captureComposerProof(suite, page, "microphone-picker-hold-toggle.png");
await page.screenshot({
animations: "disabled",
path: ".artifacts/control-ui-e2e/voice-controls/microphone-picker-hold-toggle-full.png",
path: path.join(suite.artifactDir, "voice-controls/microphone-picker-hold-toggle-full.png"),
});
});
});
@ -204,10 +209,13 @@ suite.define(() => {
await expect
.poll(() => unavailable.getByRole("button", { name: "Configure" }).count())
.toBe(2);
await captureComposerProof(page, "microphone-picker-capability-gating.png");
await captureComposerProof(suite, page, "microphone-picker-capability-gating.png");
await page.screenshot({
animations: "disabled",
path: ".artifacts/control-ui-e2e/voice-controls/microphone-picker-capability-gating-full.png",
path: path.join(
suite.artifactDir,
"voice-controls/microphone-picker-capability-gating-full.png",
),
});
});
});
@ -252,10 +260,10 @@ suite.define(() => {
expect(await composer.locator(".agent-chat__dictation-elapsed").count()).toBe(0);
await expect.poll(() => stop.isVisible()).toBe(true);
await expect.poll(() => send.isVisible()).toBe(true);
await captureComposerProof(page, "dictation-status-actions.png");
await captureComposerProof(suite, page, "dictation-status-actions.png");
await page.screenshot({
animations: "disabled",
path: ".artifacts/control-ui-e2e/voice-controls/dictation-latched-after-release.png",
path: path.join(suite.artifactDir, "voice-controls/dictation-latched-after-release.png"),
});
const composerBox = await composer.boundingBox();
expect(composerBox).not.toBeNull();

View file

@ -1,6 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { openChatSidePanelType } from "./chat-side-panel.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -12,10 +13,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.resolve(
process.cwd(),
".artifacts/control-ui-e2e/browser-screenshot-body-cancel",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
proofDir = createControlUiE2eArtifactDir("browser-screenshot-body-cancel");
}
});
suite.define(() => {
it("keeps the status error visible and cancels the unread media body", async () => {
@ -175,7 +178,6 @@ suite.define(() => {
});
if (captureUiProofEnabled) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(proofDir, "failed-screenshot.png"),

View file

@ -56,7 +56,7 @@ suite.define(() => {
)
.toBe(true);
expect(await gateway.getRequests("talk.client.create")).toHaveLength(0);
await captureMicrophoneLossProof(page, "prepared-input-pending.png");
await captureMicrophoneLossProof(suite, page, "prepared-input-pending.png");
const guidance = page.locator('.agent-chat__talk-status[role="status"]');
await expect
.poll(() => guidance.allTextContents())
@ -76,7 +76,7 @@ suite.define(() => {
.toBe(1);
await expect.poll(() => guidance.count()).toBe(0);
expect(await gateway.getRequests("talk.client.create")).toHaveLength(1);
await captureMicrophoneLossProof(page, "prepared-input-ready.png");
await captureMicrophoneLossProof(suite, page, "prepared-input-ready.png");
await page.getByRole("button", { name: "Stop voice input" }).click();
});
});
@ -120,10 +120,10 @@ suite.define(() => {
)
.toMatchObject({ status: "listening" });
} catch (error) {
await captureMicrophoneLossProof(page, "microphone-loss-setup-failure.png");
await captureMicrophoneLossProof(suite, page, "microphone-loss-setup-failure.png");
throw error;
}
await captureMicrophoneLossProof(page, "microphone-loss-before-listening.png");
await captureMicrophoneLossProof(suite, page, "microphone-loss-before-listening.png");
await page.evaluate(() => {
(
@ -150,7 +150,7 @@ suite.define(() => {
}),
)
.toEqual({ tracksStopped: 1, peerClosed: true, trackState: "ended", audioElements: 0 });
await captureMicrophoneLossProof(page, "microphone-loss-after-error.png");
await captureMicrophoneLossProof(suite, page, "microphone-loss-after-error.png");
await gateway.waitForRequest("talk.client.close");
await page.getByRole("button", { name: "Dismiss voice input error" }).click();
console.info(

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import {
dispatchOpenAiTalkEvent,
@ -18,8 +18,7 @@ const suite = createControlUiE2eSuite({
suite.define(() => {
it("renders and saves overlapping replies beside their delayed user transcripts", async () => {
const artifactDir = path.join(process.cwd(), ".artifacts/control-ui-e2e/transcript-ordering");
await mkdir(artifactDir, { recursive: true });
const artifactDir = createControlUiE2eArtifactDir("transcript-ordering");
await suite.withPage(
{
permissions: ["microphone"],

View file

@ -258,7 +258,7 @@ suite.define(() => {
Number(await page.locator(".agent-chat__voice-activity").getAttribute("data-level")),
)
.toBeGreaterThan(0);
await captureComposerProof(page, "01-voice-live-listening.png");
await captureComposerProof(suite, page, "01-voice-live-listening.png");
// Enter-sends while voice is active; the deferred chat.send keeps the
// run abortable so both stop controls render side by side.
@ -296,16 +296,16 @@ suite.define(() => {
).toBe(false);
expect(await stopVoice.locator(".agent-chat__voice-activity").count()).toBe(1);
expect(await page.locator(".chat-send-btn--stop").count()).toBe(1);
await captureComposerProof(page, "02-voice-plus-run-stop.png");
await captureComposerProof(suite, page, "02-voice-plus-run-stop.png");
await page.emulateMedia({ colorScheme: "dark" });
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.themeMode))
.toBe("dark");
await captureComposerProof(page, "03-voice-plus-run-stop-dark.png");
await captureComposerProof(suite, page, "03-voice-plus-run-stop-dark.png");
await stopVoice.hover();
await captureComposerProof(page, "04-voice-live-hover-stop-glyph.png");
await captureComposerProof(suite, page, "04-voice-live-hover-stop-glyph.png");
// Stopping voice must leave the run (and its stop control) untouched.
await stopVoice.click();
@ -333,7 +333,7 @@ suite.define(() => {
await page.setViewportSize({ width: 1366, height: 900 });
await page.goto(`${suite.server.baseUrl}chat`);
await captureVideoTalkProof(page, "01-before-video-talk.png");
await captureVideoTalkProof(suite, page, "01-before-video-talk.png");
await page.getByRole("button", { name: "Start voice input" }).click();
const request = await gateway.waitForRequest("talk.client.create");
@ -382,7 +382,7 @@ suite.define(() => {
item_id: "unintelligible-input",
error: { message: "The audio could not be transcribed." },
});
await captureMicrophoneLossProof(page, "input-transcription-error.png");
await captureMicrophoneLossProof(suite, page, "input-transcription-error.png");
const transcriptionError = page.getByRole("alert").filter({
hasText: "The audio could not be transcribed.",
});
@ -409,7 +409,7 @@ suite.define(() => {
console.info(
`[video-talk-e2e] preview=live,width:${dimensions.width},height:${dimensions.height}`,
);
await captureVideoTalkProof(page, "02-live-camera-preview.png");
await captureVideoTalkProof(suite, page, "02-live-camera-preview.png");
await dispatchOpenAiTalkEvent(page, {
type: "response.done",
@ -476,7 +476,7 @@ suite.define(() => {
);
expect(trackStates).toHaveLength(2);
expect(trackStates?.every((state) => state === "ended")).toBe(true);
await captureVideoTalkProof(page, "04-after-video-talk-stop.png");
await captureVideoTalkProof(suite, page, "04-after-video-talk-stop.png");
console.info("[video-talk-e2e] stop=preview-removed,tracks:ended+ended");
});
});
@ -604,7 +604,7 @@ suite.define(() => {
"talk.catalog",
"talk.client.create",
]);
await captureVideoTalkProof(page, "05-gemini-live-camera-preview.png");
await captureVideoTalkProof(suite, page, "05-gemini-live-camera-preview.png");
console.info(
"[video-talk-e2e] gemini=realtimeInput.video+functionResponse,gateway_frame_requests:0",
);
@ -673,7 +673,7 @@ suite.define(() => {
await expect
.poll(() => page.getByRole("button", { name: "Turn camera on" }).isVisible())
.toBe(true);
await captureVideoTalkProof(page, "03-camera-permission-blocked.png");
await captureVideoTalkProof(suite, page, "03-camera-permission-blocked.png");
console.info("[video-talk-e2e] camera_denial=actionable,no-audio-fallback");
});
});
@ -834,7 +834,7 @@ suite.define(() => {
await expect
.poll(() => gateway.getRequests("talk.session.close").then((requests) => requests.length))
.toBe(1);
await captureComposerProof(page, "relay-input-backpressure-error.png");
await captureComposerProof(suite, page, "relay-input-backpressure-error.png");
});
});

View file

@ -1,4 +1,3 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
@ -419,32 +418,44 @@ export async function installOversizedWebRtcSdpFixture(page: Page) {
});
}
export async function captureComposerProof(page: Page, fileName: string) {
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls");
await mkdir(artifactDir, { recursive: true });
export async function captureComposerProof(
owner: { readonly artifactDir: string },
page: Page,
fileName: string,
) {
const artifactDir = path.join(owner.artifactDir, "voice-controls");
await page
.locator(".agent-chat__composer-shell")
.screenshot({ path: path.join(artifactDir, fileName) });
}
export async function captureMicrophoneLossProof(page: Page, fileName: string) {
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls");
await mkdir(artifactDir, { recursive: true });
export async function captureMicrophoneLossProof(
owner: { readonly artifactDir: string },
page: Page,
fileName: string,
) {
const artifactDir = path.join(owner.artifactDir, "voice-controls");
// The error floats above the composer bounds; retain the real chat context.
await page.screenshot({ path: path.join(artifactDir, fileName), fullPage: true });
}
export async function captureVideoTalkProof(page: Page, fileName: string) {
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "video-talk");
await mkdir(artifactDir, { recursive: true });
export async function captureVideoTalkProof(
owner: { readonly artifactDir: string },
page: Page,
fileName: string,
) {
const artifactDir = path.join(owner.artifactDir, "video-talk");
await page
.locator(".agent-chat__composer-shell")
.screenshot({ path: path.join(artifactDir, fileName) });
}
export async function captureWebRtcSdpAlertProof(page: Page, fileName: string) {
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "webrtc-sdp");
await mkdir(artifactDir, { recursive: true });
export async function captureWebRtcSdpAlertProof(
owner: { readonly artifactDir: string },
page: Page,
fileName: string,
) {
const artifactDir = path.join(owner.artifactDir, "webrtc-sdp");
await page
.locator('.agent-chat__talk-status[role="alert"]')
.screenshot({ path: path.join(artifactDir, fileName) });

View file

@ -57,7 +57,7 @@ suite.define(() => {
const alert = page.locator('.agent-chat__talk-status[role="alert"]');
await expect.poll(() => alert.textContent()).toContain("Realtime WebRTC setup failed (502)");
await captureWebRtcSdpAlertProof(page, "01-http-failure-alert.png");
await captureWebRtcSdpAlertProof(suite, page, "01-http-failure-alert.png");
await expect
.poll(() =>
page.evaluate(
@ -109,7 +109,7 @@ suite.define(() => {
await expect
.poll(() => alert.textContent())
.toContain("Realtime WebRTC SDP answer: text response exceeds 262144 bytes");
await captureWebRtcSdpAlertProof(page, "02-oversized-answer-alert.png");
await captureWebRtcSdpAlertProof(suite, page, "02-oversized-answer-alert.png");
await expect
.poll(() =>
page.evaluate(

View file

@ -1,3 +1,4 @@
import path from "node:path";
// Control UI E2E coverage for legacy WebKit media errors.
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
@ -37,9 +38,13 @@ suite.define(() => {
.toBe("The selected microphone is unavailable. Choose another input or System default.");
expect(await gateway.getRequests("talk.client.create")).toHaveLength(0);
expect(await gateway.getRequests("talk.session.close")).toHaveLength(0);
await captureComposerProof(page, "webkit-selected-microphone-unavailable-composer.png");
await captureComposerProof(
suite,
page,
"webkit-selected-microphone-unavailable-composer.png",
);
await page.getByRole("alert").screenshot({
path: ".artifacts/control-ui-e2e/voice-controls/webkit-selected-microphone-alert.png",
path: path.join(suite.artifactDir, "voice-controls/webkit-selected-microphone-alert.png"),
});
});
});

View file

@ -1,8 +1,8 @@
// Control UI tests keep build identity readable at UTF-16 truncation boundaries.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway, startControlUiE2eServer } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -25,12 +25,12 @@ const suite = createControlUiE2eSuite({
});
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"build-info-unicode",
);
let uiProofArtifactDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
uiProofArtifactDir = createControlUiE2eArtifactDir("build-info-unicode");
}
});
const RAW_BRANCH = `${"a".repeat(12)}😀${"b".repeat(85)}😀suffix`;
const NORMALIZED_BRANCH = `${"a".repeat(12)}😀${"b".repeat(85)}`;
@ -128,7 +128,6 @@ suite.define(() => {
});
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await identityCard.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "00-footer-custom-build-identity.png"),

View file

@ -1,6 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -12,12 +13,12 @@ const suite = createControlUiE2eSuite({
});
const proofVariant = process.env.OPENCLAW_PICKER_PROOF_VARIANT;
const proofDirectory = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"channel-wizard-option-contrast",
);
let proofDirectory: string;
beforeEach(() => {
if (proofVariant) {
proofDirectory = createControlUiE2eArtifactDir("channel-wizard-option-contrast");
}
});
suite.define(() => {
it("keeps option subtext legible and keyboard focus visible in forced colors", async () => {
@ -101,7 +102,6 @@ suite.define(() => {
});
if (proofVariant) {
await mkdir(proofDirectory, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,7 +1,7 @@
// Control UI tests cover WhatsApp logout feedback against a mocked Gateway.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway, waitForConfirmModal } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -14,24 +14,21 @@ const suite = createControlUiE2eSuite({
const QR_DATA_URL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlY9Z8AAAAASUVORK5CYII=";
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"channels-save-failure",
);
const wizardUiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"channel-wizard-continue-spinner",
);
let uiProofArtifactDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
uiProofArtifactDir = createControlUiE2eArtifactDir("channels-save-failure");
}
});
let wizardUiProofArtifactDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
wizardUiProofArtifactDir = createControlUiE2eArtifactDir("channel-wizard-continue-spinner");
}
});
suite.define(() => {
it("shows rejected channel configuration saves in the open editor without losing the draft", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",
@ -185,7 +182,6 @@ suite.define(() => {
});
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -272,7 +268,6 @@ suite.define(() => {
await firstConfirm.getByRole("button", { name: "Cancel" }).focus();
await page.keyboard.press("Escape");
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -377,9 +372,6 @@ suite.define(() => {
});
it("preserves standard channel details and the complete Telegram setup wizard", async () => {
if (captureUiProofEnabled) {
await mkdir(wizardUiProofArtifactDir, { recursive: true });
}
await suite.withPage({ locale: "en-US", serviceWorkers: "block" }, async ({ page }) => {
const channelEntries = [
["discord", "Discord"],

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
@ -14,10 +14,10 @@ const suite = createChatFlowE2eSuite();
suite.define(() => {
it("restores active commentary when an evicted session revalidates from its cursor", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-active-cursor-replay", artifactRoot)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "playwright/test";
import { it } from "vitest";
import { beforeEach, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
installMockGateway,
type MockGatewayControls,
@ -16,7 +16,12 @@ const suite = createControlUiE2eSuite({
`Playwright Chromium is required for active-turn recovery proof at ${executablePath}`,
});
const proofDir = path.resolve(".artifacts/control-ui-e2e/active-turn-recovery");
let proofDir: string;
beforeEach(() => {
if (captureProof) {
proofDir = createControlUiE2eArtifactDir("active-turn-recovery");
}
});
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
type ActiveRunSnapshotOptions = {
events?: unknown[];
@ -30,7 +35,6 @@ async function capture(page: Page, name: string): Promise<void> {
if (!captureProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ path: path.join(proofDir, `${name}.png`), fullPage: true });
}

View file

@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -84,9 +84,11 @@ suite.define(() => {
expect(rowKeys[0]).toMatch(/^agent-run:/u);
expect(rowKeys).toEqual([rowKeys[0], rowKeys[0], rowKeys[0]]);
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-agent-run-transcript", artifactRoot)
: undefined;
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
await page.locator(".chat-thread-inner").screenshot({
path: path.join(artifactDir, "restart-recovery-run-frame.png"),
});
@ -249,9 +251,11 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}chat`);
const transcript = page.locator(".chat-thread-inner");
await transcript.getByText("Caption ready for the second run.", { exact: true }).waitFor();
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-agent-run-transcript", artifactRoot)
: undefined;
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
const firstNarration = transcript.getByText(
"I’ll create the launch card and check the existing style first.",
{ exact: true },
@ -445,9 +449,11 @@ suite.define(() => {
});
expect(overlappingRows).toEqual([]);
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-agent-run-transcript", artifactRoot)
: undefined;
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
await page.screenshot({
path: path.join(artifactDir, "cross-session-run-isolation.png"),
fullPage: true,

View file

@ -1,12 +1,18 @@
import { mkdir, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "Attachment failure announcements" });
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts/control-ui-e2e/attachment-announcement");
let proofDir: string;
beforeEach(() => {
if (captureProof) {
proofDir = createControlUiE2eArtifactDir("attachment-announcement");
}
});
suite.define(() => {
it.each(["ordinary", "completed"])(
@ -39,7 +45,6 @@ suite.define(() => {
expect(await announcement.getAttribute("aria-atomic")).toBe("true");
expect(await announcement.textContent()).toBe("");
if (captureProof) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({ path: path.join(proofDir, `${flow}-initial.png`) });
}

View file

@ -1,8 +1,8 @@
// Control UI E2E tests cover attributed chat identity placement.
import fs from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "playwright/test";
import { it } from "vitest";
import { beforeEach, it } from "vitest";
// Control UI E2E tests cover attributed chat identity placement.
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -11,16 +11,19 @@ const suite = createControlUiE2eSuite({
startServerBeforeBrowser: true,
});
function resolveArtifactDir(): string | undefined {
return process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim() || undefined;
}
let proofArtifactDir: string | undefined;
beforeEach(() => {
const parent = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
proofArtifactDir = parent
? createControlUiE2eArtifactDir("chat-attributed-identity", parent)
: undefined;
});
async function captureProof(page: Page, name: string) {
const artifactDir = resolveArtifactDir();
const artifactDir = proofArtifactDir;
if (!artifactDir) {
return;
}
await fs.mkdir(artifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(artifactDir, name),
@ -66,10 +69,7 @@ function expectStableNamePosition(
suite.define(() => {
it("uses one avatar placement and keeps shared-thread authors readable", async () => {
const artifactDir = resolveArtifactDir();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
}
const artifactDir = proofArtifactDir;
const context = await suite.browser.newContext({
viewport: { height: 760, width: 1180 },
...(artifactDir
@ -446,10 +446,10 @@ suite.define(() => {
});
it("keeps an attributed failed send in the transcript with one-line retry metadata", async () => {
const artifactDir = process.env.OPENCLAW_BUBBLE_DELIVERY_ARTIFACT_DIR?.trim();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
}
const artifactRoot = process.env.OPENCLAW_BUBBLE_DELIVERY_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("bubble-delivery", artifactRoot)
: undefined;
const context = await suite.browser.newContext({ viewport: { height: 760, width: 1180 } });
const page = await context.newPage();
const sender = {
@ -554,10 +554,7 @@ suite.define(() => {
});
it("keeps missing local-viewer avatar initials through a live rerender", async () => {
const artifactDir = resolveArtifactDir();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
}
const artifactDir = proofArtifactDir;
const context = await suite.browser.newContext({
viewport: { height: 760, width: 1180 },
...(artifactDir

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -17,7 +17,12 @@ const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofStage = process.env.OPENCLAW_CODE_FENCE_PROOF_STAGE ?? "after";
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-code-block-fences");
let artifactDir: string;
beforeEach(() => {
if (captureProof) {
artifactDir = createControlUiE2eArtifactDir("chat-code-block-fences");
}
});
function fencedJson(lineCount: number): string {
const values = Array.from({ length: lineCount - 2 }, (_, index) => ` ${index},`);
@ -62,9 +67,6 @@ describeControlUiE2e("Control UI fenced code blocks", () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
}
server = await startControlUiE2eServer(undefined, { source: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});

View file

@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -70,9 +70,11 @@ suite.define(() => {
await outside.focus();
await trigger.click();
expect(await outside.evaluate((element) => document.activeElement === element)).toBe(true);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-accessory-focus", artifactRoot)
: undefined;
if (artifactDir && triggerSelector.startsWith(".context-usage")) {
await fs.mkdir(artifactDir, { recursive: true });
const composerBox = await composer.boundingBox();
const popoverBox = await composer.locator(".context-usage__popover").boundingBox();
if (!composerBox || !popoverBox) {

View file

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -141,13 +142,15 @@ suite.define(() => {
await dropdown.locator('[value="open-skills"]').click();
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("skills");
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-composer-capability-menu-height", artifactDirParent)
: undefined;
const captureStage = process.env.OPENCLAW_UI_E2E_CAPTURE_STAGE?.trim();
const capture = async (view: string, theme: "dark" | "light") => {
if (!artifactDir || !captureStage) {
return;
}
await fs.mkdir(artifactDir, { recursive: true });
await page.evaluate((mode) => {
document.documentElement.dataset.themeMode = mode;
}, theme);

View file

@ -1,5 +1,6 @@
// Control UI E2E tests cover chat composer catalog discovery.
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiSessionUrl,
installMockGateway,
@ -238,7 +239,10 @@ suite.define(() => {
await expect.poll(() => composer.locator("textarea").isDisabled()).toBe(true);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-catalog", artifactRoot)
: undefined;
if (artifactDir) {
await composer.screenshot({
animations: "disabled",
@ -467,7 +471,10 @@ suite.define(() => {
await expect
.poll(() => composer.locator("[data-chat-model-catalog-state]").textContent())
.toContain("No models available");
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-catalog", artifactRoot)
: undefined;
if (artifactDir) {
await page.screenshot({
animations: "disabled",
@ -546,7 +553,10 @@ suite.define(() => {
await expect
.poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.6-luna"]').isVisible())
.toBe(true);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-catalog", artifactRoot)
: undefined;
if (artifactDir) {
await page.screenshot({
animations: "disabled",
@ -606,7 +616,10 @@ suite.define(() => {
.poll(() => textarea.evaluate((node) => node.matches(":placeholder-shown")))
.toBe(true);
await expect.poll(() => textarea.getAttribute("placeholder")).toContain("Message");
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-catalog", artifactRoot)
: undefined;
if (artifactDir) {
await page.locator(".agent-chat__composer-shell").screenshot({
animations: "disabled",

View file

@ -1,5 +1,6 @@
// Control UI E2E tests cover the redesigned chat composer.
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -25,7 +26,10 @@ suite.define(() => {
] as const)(
"keeps $reason model availability honest in the composer and picker",
async ({ reason, blocked, message }) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-redesign", artifactRoot)
: undefined;
await suite.withPage(
{
viewport: { width: 1280, height: 900 },
@ -189,7 +193,10 @@ suite.define(() => {
});
it("keeps the model in the bottom bar, session settings in the header, and holds send beside the microphone in every input state", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-composer-redesign", artifactRoot)
: undefined;
const pageOptions = {
viewport: { width: 1920, height: 1080 },
...(artifactDir

View file

@ -1,8 +1,8 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { decodeResumeHandoff } from "../../../src/shared/resume-handoff.js";
import type { ChatPaneElement } from "../pages/chat/route-draft-focus-handoff.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiSessionPath,
controlUiSessionUrl,
@ -18,7 +18,6 @@ const suite = createControlUiE2eSuite({
`Playwright Chromium is not installed at ${executablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
});
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/header-session-menu");
const basePath = new URL("/nested/$&;=()+,![]{}'`/%25PATH%25", "http://localhost").pathname;
const agentId = "runner";
const sessionKey = `agent:${agentId}:main-'"$&;|<>^()%![]{}\\\`-%PATH%`;
@ -67,8 +66,7 @@ const compactManagementActions = sharedManagementActions.filter(
suite.define(() => {
it("shows, copies, and retires a credential-free exact continuation command", async () => {
await rm(artifactDir, { recursive: true, force: true });
await mkdir(artifactDir, { recursive: true });
const artifactDir = createControlUiE2eArtifactDir("header-session-menu");
await suite.withPage(
{
locale: "en-US",
@ -164,7 +162,7 @@ suite.define(() => {
});
it("keeps the canonical session actions reachable in the mobile header menu", async () => {
await mkdir(artifactDir, { recursive: true });
const artifactDir = createControlUiE2eArtifactDir("header-session-menu");
await suite.withPage(
{
locale: "en-US",

View file

@ -2,7 +2,8 @@
import fs from "node:fs";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiE2eWaitTimeoutMs,
@ -17,14 +18,16 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-file-links");
let artifactDir: string;
beforeEach(() => {
artifactDir = createControlUiE2eArtifactDir("chat-file-links");
});
let browser: Browser;
let server: ControlUiE2eServer;
describeControlUiE2e("Control UI chat file links", () => {
beforeAll(async () => {
fs.mkdirSync(artifactDir, { recursive: true });
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { waitForControlUiGatewayReconnecting } from "../test-helpers/control-ui-e2e-readiness.ts";
import {
chatSessionListResponse,
@ -187,8 +187,7 @@ suite.define(() => {
});
const startupIndicator = page.locator('.chat-working-indicator[role="status"]');
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
const startupProofDir = path.resolve(".artifacts/control-ui-e2e/duplicate-session-naming");
await mkdir(startupProofDir, { recursive: true });
const startupProofDir = path.join(suite.artifactDir, "duplicate-session-naming");
await page.screenshot({ path: path.join(startupProofDir, "steer.png"), fullPage: true });
}
await expect.poll(() => startupIndicator.textContent()).not.toContain("Naming worktree…");
@ -483,9 +482,11 @@ suite.define(() => {
)
.toEqual([initialText, beforeText, steerText, afterText]);
} finally {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.active-run-follow-ups", artifactDirParent)
: undefined;
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, `steer-history-${historyOrder}-live-event.png`),
@ -544,9 +545,11 @@ suite.define(() => {
state: "delta",
});
const capture = async (name: string) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.active-run-follow-ups", artifactDirParent)
: undefined;
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, `steer-split-commentary-${name}.png`),

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
@ -99,10 +99,10 @@ suite.define(() => {
it.each(["tool-diff", "selection", "agent-id"] as const)(
"reports clipboard failure from the %s action",
async (surface) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.clipboard", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
colorScheme: "light",
locale: "en-US",
@ -399,9 +399,11 @@ suite.define(() => {
});
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.clipboard", artifactDirParent)
: undefined;
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, `clipboard-${action}-failure.png`),
@ -454,9 +456,11 @@ suite.define(() => {
});
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.clipboard", artifactDirParent)
: undefined;
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "clipboard-assistant-code-failure.png"),

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
createChatFlowE2eSuite,
controlUiSessionUrl,
@ -8,16 +8,16 @@ import {
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
const dynamicCatalogProofDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "dynamic-catalog-convergence")
: null;
let dynamicCatalogProofDir: string | null;
beforeEach(() => {
dynamicCatalogProofDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? createControlUiE2eArtifactDir("dynamic-catalog-convergence")
: null;
});
suite.define(() => {
it("converges Chat reasoning and context metadata after dynamic catalog discovery", async () => {
if (dynamicCatalogProofDir) {
await mkdir(dynamicCatalogProofDir, { recursive: true });
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",

View file

@ -1,4 +1,3 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
@ -7,11 +6,12 @@ import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-sup
const suite = createChatFlowE2eSuite();
async function captureHistoryIssuanceProof(page: Page, name: string): Promise<void> {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim()
? suite.artifactDir
: undefined;
if (!artifactDir) {
return;
}
await mkdir(artifactDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(artifactDir, `${name}.png`) });
}

View file

@ -1,8 +1,8 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import type { ApplicationContext } from "../app/context.ts";
import type { ChatQueueItem } from "../lib/chat/chat-types.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatSessionListResponse,
controlUiSessionUrl,
@ -134,10 +134,10 @@ suite.define(() => {
});
it("restores reasoning and tool activity after navigating away from a session", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.history-recovery", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir
@ -425,7 +425,10 @@ suite.define(() => {
});
it("keeps evicted paginated history stable when returning to a session", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.history-recovery", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir
@ -695,7 +698,10 @@ suite.define(() => {
});
it("stores new input while offline and sends it after reconnect", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.history-recovery", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir

View file

@ -1,14 +1,17 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createPlaybackMediaFixture } from "../../../test/fixtures/media-playback.js";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("allows tilde local media previews when the preview root home contains a literal $ pattern", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.local-media-dollar-home", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -92,7 +95,6 @@ suite.define(() => {
.toBeGreaterThanOrEqual(1);
expect(await page.getByText("Outside allowed folders").count()).toBe(0);
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "local-media-dollar-home-allowed.png"),

View file

@ -1,7 +1,8 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { afterEach, expect, it } from "vitest";
import { createPlaybackMediaFixture } from "../../../test/fixtures/media-playback.js";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.ts";
import {
buildLocalWebchatAudioMessage,
captureUiProofEnabled,
@ -10,12 +11,12 @@ import {
expectDefined,
installMockGateway,
installPlainHttpClipboardCapture,
managedImageCacheProofDir,
waitForChatScrollIdle,
} from "./chat-flow.test-support.ts";
import { openChatSidePanelType } from "./chat-side-panel.test-support.ts";
const suite = createChatFlowE2eSuite();
const mediaTempDirs = useAutoCleanupTempDirTracker(afterEach);
suite.define(() => {
it("exposes an assistant document download with its Unicode filename and ticketed URL", async () => {
@ -89,13 +90,17 @@ suite.define(() => {
},
{
kind: "audio",
source: `FILE:${path.join(managedImageCacheProofDir, "bootstrap-structured-audio.mp3")}`,
source: "bootstrap-structured-audio.mp3",
ticket: "ticket-bootstrap-structured-audio",
structured: true,
},
] as const)(
"renders local assistant $kind through server metadata before preview roots load",
async ({ kind, source, ticket, ...options }) => {
async ({ kind, source: fixtureSource, ticket, ...options }) => {
const source =
"structured" in options
? `FILE:${path.join(mediaTempDirs.make("control-ui-audio-"), fixtureSource)}`
: fixtureSource;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -200,12 +205,10 @@ suite.define(() => {
.toBe(180);
}
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
if (process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim()) {
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, `bootstrap-local-${kind}-${ticket}.png`),
path: path.join(suite.artifactDir, `bootstrap-local-${kind}-${ticket}.png`),
});
}
if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") {
@ -286,12 +289,10 @@ suite.define(() => {
expect(await page.locator(".chat-assistant-attachment-card audio").count()).toBe(0);
expect(await page.locator(".chat-assistant-attachment-card__download").count()).toBe(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
if (process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim()) {
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, `bootstrap-blocked-${code}.png`),
path: path.join(suite.artifactDir, `bootstrap-blocked-${code}.png`),
});
}
if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") {
@ -432,7 +433,9 @@ suite.define(() => {
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim()
? suite.artifactDir
: undefined;
const managedAttachmentSource = (artifactId: string) =>
`/api/chat/media/outgoing/agent%3Amain%3Amain/${artifactId.slice("artifact_managed_media_".length)}/full`;
const attachments = [
@ -548,7 +551,6 @@ suite.define(() => {
expect(await page.getByText("Checking...", { exact: true }).count()).toBe(0);
expect(((await page.locator("body").textContent()) ?? "").includes("MEDIA:")).toBe(false);
if (proofDir) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({ path: path.join(proofDir, "media-batch-skeletons.png") });
}
@ -602,7 +604,9 @@ suite.define(() => {
] as const)(
"renders a $name image through the ticketed media route",
async ({ source, workspaceDir, screenshotName }) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim()
? suite.artifactDir
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -670,7 +674,6 @@ suite.define(() => {
)
.toBe(1);
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: `${artifactDir}/${screenshotName}.png`,
@ -889,7 +892,7 @@ suite.define(() => {
revokedBlobUrls: finalProof.revoked.length,
};
if (captureUiProofEnabled) {
await mkdir(managedImageCacheProofDir, { recursive: true });
const managedImageCacheProofDir = path.join(suite.artifactDir, "managed-image-cache");
await page.evaluate((summary) => {
const panel = document.createElement("pre");
panel.setAttribute("data-managed-image-cache-proof", "true");

View file

@ -1,10 +1,8 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import {
captureUiProofEnabled,
channelStopProofDir,
chatSessionListResponse,
controlUiSessionUrl,
createChatFlowE2eSuite,
@ -872,7 +870,7 @@ suite.define(() => {
await expect.poll(() => page.getByRole("listbox").count()).toBe(0);
expect(await composer.inputValue()).toBe("");
if (captureUiProofEnabled) {
await mkdir(channelStopProofDir, { recursive: true });
const channelStopProofDir = path.join(suite.artifactDir, "channel-stop");
await page.screenshot({
path: path.join(channelStopProofDir, "stopped.png"),
fullPage: true,

View file

@ -11,14 +11,16 @@ import {
const suite = createChatFlowE2eSuite();
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "model-picker-refresh");
async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ animations: "disabled", path: path.join(proofDir, name) });
await mkdir(path.join(suite.artifactDir, "model-picker-refresh"), { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(path.join(suite.artifactDir, "model-picker-refresh"), name),
});
}
suite.define(() => {
@ -28,7 +30,12 @@ suite.define(() => {
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(captureUiProof
? { recordVideo: { dir: proofDir, size: { height: 900, width: 1280 } } }
? {
recordVideo: {
dir: path.join(suite.artifactDir, "model-picker-refresh"),
size: { height: 900, width: 1280 },
},
}
: {}),
});
const page = await context.newPage();

View file

@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
@ -119,7 +120,10 @@ suite.define(() => {
});
await expect.poll(() => page.getByText(/not supported for/u).count()).toBe(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.model-switch-reasoning", artifactDirParent)
: undefined;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/model-thinking-sync.png`,

View file

@ -1,5 +1,6 @@
import { expect, it } from "vitest";
import { t } from "../i18n/lib/translate.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
@ -484,7 +485,10 @@ suite.define(() => {
},
]);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.models-reasoning", artifactDirParent)
: undefined;
if (artifactDir) {
await menu.screenshot({
animations: "disabled",
@ -682,7 +686,10 @@ suite.define(() => {
});
it("shows one canonical default model with matching inherited reasoning", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.models-reasoning", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",

View file

@ -946,7 +946,7 @@ suite.define(() => {
expect(await link.getAttribute("aria-current")).toBe("page");
expect(await link.getAttribute("aria-describedby")).toBeNull();
expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`);
await captureSessionAccessibilityProof(page, "after-derived-title");
await captureSessionAccessibilityProof(suite, page, "after-derived-title");
const listCountBeforePatch = (await gateway.getRequests("sessions.list")).length;
await row.hover();
@ -966,7 +966,7 @@ suite.define(() => {
await expect.poll(() => label.textContent()).toBe(readableTitle);
expect(await link.getAttribute("aria-current")).toBe("page");
expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`);
await captureSessionAccessibilityProof(page, "after-patch-refresh");
await captureSessionAccessibilityProof(suite, page, "after-patch-refresh");
} finally {
await suite.closeBrowserContext(context);
}

View file

@ -1,3 +1,4 @@
import path from "node:path";
import { expect, it } from "vitest";
import {
captureUiProofEnabled,
@ -104,7 +105,7 @@ suite.define(() => {
it("dispatches after its settings refresh while a later roster refresh is still pending", async () => {
const context = await suite.newBrowserContext({
...(captureUiProofEnabled
? { recordVideo: { dir: ".artifacts/send-settings-wait/video" } }
? { recordVideo: { dir: path.join(suite.artifactDir, "send-settings-wait", "video") } }
: {}),
locale: "en-US",
serviceWorkers: "block",
@ -158,7 +159,9 @@ suite.define(() => {
.getByText("Settings applied and message delivered.")
.waitFor();
if (captureUiProofEnabled) {
await page.screenshot({ path: ".artifacts/send-settings-wait/browser-after.png" });
await page.screenshot({
path: path.join(suite.artifactDir, "send-settings-wait", "browser-after.png"),
});
}
await gateway.resolveDeferred("sessions.list");
} finally {

View file

@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
createChatFlowE2eSuite,
expectRequestCountStable,
@ -160,7 +161,10 @@ suite.define(() => {
});
it("keeps edit, remove, and reorder outcomes exact through reconnect", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.queue-edit", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir

View file

@ -10,12 +10,6 @@ import {
const suite = createChatFlowE2eSuite();
const QUEUED = ["review the migration", "then update the docs", "finally run the smoke"] as const;
const failureReloadProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"queue-reorder-failure-reload",
);
suite.define(() => {
it("reorders offline queued messages from the keyboard-focused handle", async () => {
@ -65,7 +59,9 @@ suite.define(() => {
it("keeps the queue order consistent through a reload after a mid-reorder storage failure", async () => {
if (captureUiProofEnabled) {
await mkdir(failureReloadProofDir, { recursive: true });
await mkdir(path.join(suite.artifactDir, "queue-reorder-failure-reload"), {
recursive: true,
});
}
const context = await suite.newBrowserContext({
locale: "en-US",
@ -102,7 +98,9 @@ suite.define(() => {
const queueText = () => page.locator(".chat-queue__item .chat-queue__text").allTextContents();
expect(await queueText()).toEqual([...QUEUED]);
if (captureUiProofEnabled) {
await page.screenshot({ path: `${failureReloadProofDir}/01-queued-before-failure.png` });
await page.screenshot({
path: `${path.join(suite.artifactDir, "queue-reorder-failure-reload")}/01-queued-before-failure.png`,
});
}
// Break durable storage for the rest of this page's lifetime, then attempt
@ -126,7 +124,7 @@ suite.define(() => {
expect(await queueText()).toEqual([...QUEUED]);
if (captureUiProofEnabled) {
await page.screenshot({
path: `${failureReloadProofDir}/02-order-unchanged-after-failure.png`,
path: `${path.join(suite.artifactDir, "queue-reorder-failure-reload")}/02-order-unchanged-after-failure.png`,
});
}
@ -173,7 +171,7 @@ suite.define(() => {
expect(await queueText()).toEqual([...QUEUED]);
if (captureUiProofEnabled) {
await page.screenshot({
path: `${failureReloadProofDir}/03-consistent-order-after-reload.png`,
path: `${path.join(suite.artifactDir, "queue-reorder-failure-reload")}/03-consistent-order-after-reload.png`,
});
}
} finally {

View file

@ -14,36 +14,23 @@ import {
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
const terminalMetadataProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"remote-session-sidebar-metadata",
);
const sessionSecondRowProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-status-second-row-implementation",
);
const subtitleStabilityProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"sidebar-subtitle-stability",
);
suite.define(() => {
it("keeps a running subtitle and row height stable when its session is opened", async () => {
if (captureUiProofEnabled) {
await mkdir(subtitleStabilityProofDir, { recursive: true });
await mkdir(path.join(suite.artifactDir, "sidebar-subtitle-stability"), { recursive: true });
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(captureUiProofEnabled
? { recordVideo: { dir: subtitleStabilityProofDir, size: { height: 900, width: 1280 } } }
? {
recordVideo: {
dir: path.join(suite.artifactDir, "sidebar-subtitle-stability"),
size: { height: 900, width: 1280 },
},
}
: {}),
});
const page = await context.newPage();
@ -100,7 +87,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.waitForTimeout(800);
await secondRow.screenshot({
path: path.join(subtitleStabilityProofDir, "01-running-before-open.png"),
path: path.join(
path.join(suite.artifactDir, "sidebar-subtitle-stability"),
"01-running-before-open.png",
),
});
}
@ -116,14 +106,20 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.waitForTimeout(800);
await secondRow.screenshot({
path: path.join(subtitleStabilityProofDir, "02-running-after-open.png"),
path: path.join(
path.join(suite.artifactDir, "sidebar-subtitle-stability"),
"02-running-after-open.png",
),
});
}
} finally {
await suite.closeBrowserContext(context);
if (proofVideo) {
await proofVideo.saveAs(
path.join(subtitleStabilityProofDir, "sidebar-subtitle-stability.webm"),
path.join(
path.join(suite.artifactDir, "sidebar-subtitle-stability"),
"sidebar-subtitle-stability.webm",
),
);
}
}
@ -131,14 +127,21 @@ suite.define(() => {
it("replaces an intermediate running subtitle with the unread final digest", async () => {
if (captureUiProofEnabled) {
await mkdir(terminalMetadataProofDir, { recursive: true });
await mkdir(path.join(suite.artifactDir, "remote-session-sidebar-metadata"), {
recursive: true,
});
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(captureUiProofEnabled
? { recordVideo: { dir: terminalMetadataProofDir, size: { height: 900, width: 1280 } } }
? {
recordVideo: {
dir: path.join(suite.artifactDir, "remote-session-sidebar-metadata"),
size: { height: 900, width: 1280 },
},
}
: {}),
});
const page = await context.newPage();
@ -198,7 +201,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.screenshot({
fullPage: true,
path: path.join(terminalMetadataProofDir, "01-running-subtitle.png"),
path: path.join(
path.join(suite.artifactDir, "remote-session-sidebar-metadata"),
"01-running-subtitle.png",
),
});
}
await gateway.setMethodResponse("sessions.list", completed);
@ -223,7 +229,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.screenshot({
fullPage: true,
path: path.join(terminalMetadataProofDir, "02-final-reply-subtitle.png"),
path: path.join(
path.join(suite.artifactDir, "remote-session-sidebar-metadata"),
"02-final-reply-subtitle.png",
),
});
}
const listRequests = await gateway.getRequests("sessions.list");
@ -328,14 +337,21 @@ suite.define(() => {
it("keeps session titles on the first line and collapses rows that have no second line", async () => {
if (captureUiProofEnabled) {
await mkdir(sessionSecondRowProofDir, { recursive: true });
await mkdir(path.join(suite.artifactDir, "session-status-second-row-implementation"), {
recursive: true,
});
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(captureUiProofEnabled
? { recordVideo: { dir: sessionSecondRowProofDir, size: { height: 900, width: 1280 } } }
? {
recordVideo: {
dir: path.join(suite.artifactDir, "session-status-second-row-implementation"),
size: { height: 900, width: 1280 },
},
}
: {}),
});
const page = await context.newPage();
@ -407,7 +423,10 @@ suite.define(() => {
expect(await busyRow.getAttribute("class")).toContain("sidebar-recent-session--single-line");
if (captureUiProofEnabled) {
await page.locator(".shell-nav").screenshot({
path: path.join(sessionSecondRowProofDir, "00-default-hidden-preview.png"),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
"00-default-hidden-preview.png",
),
});
}
await page.locator(".sidebar-session-toolbar .sidebar-session-sort").click();
@ -442,7 +461,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.locator(".shell-nav").screenshot({
animations: "disabled",
path: path.join(sessionSecondRowProofDir, `indicators-${colorScheme}.png`),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
`indicators-${colorScheme}.png`,
),
});
}
}
@ -462,11 +484,17 @@ suite.define(() => {
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(sessionSecondRowProofDir, `01-second-row-endcap-${sidebarWidth}.png`),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
`01-second-row-endcap-${sidebarWidth}.png`,
),
});
await shellNav.screenshot({
animations: "disabled",
path: path.join(sessionSecondRowProofDir, `01-sidebar-${sidebarWidth}.png`),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
`01-sidebar-${sidebarWidth}.png`,
),
});
}
badgeSizes.push(
@ -586,7 +614,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await shellNav.screenshot({
animations: "disabled",
path: path.join(sessionSecondRowProofDir, "03-unread-hover.png"),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
"03-unread-hover.png",
),
});
}
await unreadDot.waitFor({ state: "hidden" });
@ -624,7 +655,10 @@ suite.define(() => {
if (captureUiProofEnabled) {
await page.screenshot({
fullPage: true,
path: path.join(sessionSecondRowProofDir, "02-hover-actions.png"),
path: path.join(
path.join(suite.artifactDir, "session-status-second-row-implementation"),
"02-hover-actions.png",
),
});
}
await plainRow.waitFor();

View file

@ -1,8 +1,8 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import type { ChatHost } from "../pages/chat/chat-send-contract.ts";
import { CHAT_TRANSCRIPT_END_THRESHOLD_PX } from "../pages/chat/scroll.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatThreadDistanceFromBottom,
createChatFlowE2eSuite,
@ -22,7 +22,10 @@ suite.define(() => {
{ label: "desktop hover", mobile: false, viewport: { height: 900, width: 1280 } },
{ label: "mobile tap", mobile: true, viewport: { height: 844, width: 390 } },
])("shows turn metadata only after completion on $label", async ({ mobile, viewport }) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.streaming", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
hasTouch: mobile,
isMobile: mobile,
@ -55,7 +58,6 @@ suite.define(() => {
: { opacity: "1", pointerEvents: "auto" },
);
if (artifactDir && !mobile) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "before-user-follow-up-actions-visible.png"),
@ -152,7 +154,10 @@ suite.define(() => {
});
it("keeps a bottom-anchored transcript pinned while the composer grows", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.streaming", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -194,7 +199,6 @@ suite.define(() => {
).toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX);
}
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
fullPage: true,
path: path.join(artifactDir, "composer-resize-pinned.png"),
@ -354,7 +358,10 @@ suite.define(() => {
])(
"keeps streamed text visible when a chat error terminates the turn on $label",
async ({ label, viewport }) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDirParent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactDirParent
? createControlUiE2eArtifactDir("chat-flow.streaming", artifactDirParent)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -434,7 +441,6 @@ suite.define(() => {
await page.locator(".chat-thread-inner").getByText(partialText, { exact: true }).count(),
).toBe(1);
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({ path: path.join(artifactDir, `terminal-partial-${label}.png`) });
}
const alert = page.locator(".chat-error");

View file

@ -23,25 +23,7 @@ export {
pauseVirtualClock,
};
export const managedImageCacheProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"managed-image-cache",
);
export const channelStopProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"channel-stop",
);
export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const sessionAccessibilityProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-accessibility",
);
export function createChatFlowE2eSuite() {
return createControlUiE2eSuite({
@ -178,11 +160,15 @@ export async function scrollChatThreadToTop(page: Page): Promise<void> {
});
}
export async function captureSessionAccessibilityProof(page: Page, name: string): Promise<void> {
export async function captureSessionAccessibilityProof(
owner: { readonly artifactDir: string },
page: Page,
name: string,
): Promise<void> {
if (!captureUiProofEnabled) {
return;
}
await mkdir(sessionAccessibilityProofDir, { recursive: true });
const sessionAccessibilityProofDir = path.join(owner.artifactDir, "session-accessibility");
const sidebar = page.locator("openclaw-app-sidebar");
await page.screenshot({
fullPage: true,

View file

@ -1,19 +1,21 @@
// Real-browser proof of frozen goal timing on the chat composer.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "Control UI goal elapsed time" });
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const artifactDir = path.resolve(".artifacts/control-ui-e2e/goal-elapsed");
let artifactDir: string;
beforeEach(() => {
if (captureProof) {
artifactDir = createControlUiE2eArtifactDir("goal-elapsed");
}
});
suite.define(() => {
it("shows a paused goal's frozen duration before and after opening details and reloading", async () => {
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
}
await suite.withPage(
{
colorScheme: "light",

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { requireRecord, requireString } from "./chat-flow.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -10,14 +10,16 @@ const suite = createControlUiE2eSuite({
trackBrowserContexts: true,
});
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts/control-ui-e2e/codex-guardian-review");
let proofDir: string;
beforeEach(() => {
if (captureProof) {
proofDir = createControlUiE2eArtifactDir("codex-guardian-review");
}
});
const viewport = { height: 900, width: 1280 };
suite.define(() => {
it("shows and settles a strict Guardian review in the active chat", async () => {
if (captureProof) {
await mkdir(proofDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",

View file

@ -1,4 +1,4 @@
import { mkdir, readFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
@ -248,15 +248,10 @@ suite.define(() => {
await icon.waitFor();
await icon.locator("svg").waitFor();
await icon.evaluate((element) => element.setAttribute("data-recovery-host", "mounted"));
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"workspace-icon-recovery",
);
if (captureUiProofEnabled) {
await mkdir(proofDir, { recursive: true });
await page.screenshot({ path: path.join(proofDir, "fallback.png") });
await page.screenshot({
path: path.join(suite.artifactDir, "workspace-icon-recovery", "fallback.png"),
});
}
await icon.locator(".workspace-icon").waitFor({ timeout: 10_000 });
@ -264,7 +259,9 @@ suite.define(() => {
expect(requests).toBe(2);
expect(await icon.getAttribute("data-recovery-host")).toBe("mounted");
if (captureUiProofEnabled) {
await page.screenshot({ path: path.join(proofDir, "recovered.png") });
await page.screenshot({
path: path.join(suite.artifactDir, "workspace-icon-recovery", "recovered.png"),
});
}
} finally {
await suite.closeBrowserContext(context);

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -9,15 +9,14 @@ const suite = createControlUiE2eSuite({
startServerBeforeBrowser: true,
});
const outputDir = path.resolve(
process.cwd(),
process.env.OPENCLAW_CHAT_HEADER_CAPTURE_OUTPUT_DIR ??
".artifacts/control-ui-e2e/chat-header-owner-presence",
);
suite.define(() => {
it.each(["chat", "dashboard"] as const)(
"deduplicates participants watching the %s",
async (face) => {
const outputDir = createControlUiE2eArtifactDir(
"chat-header-owner-presence",
process.env.OPENCLAW_CHAT_HEADER_CAPTURE_OUTPUT_DIR,
);
await suite.withPage(
{
locale: "en-US",
@ -138,7 +137,6 @@ suite.define(() => {
if (!clip) {
throw new Error("Chat header did not expose a screenshot bounding box");
}
await mkdir(outputDir, { recursive: true });
await page.screenshot({ animations: "disabled", clip, path: screenshotPath });
process.stdout.write(`Chat header screenshot: ${screenshotPath}\n`);

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { beforeAll, expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
chatSessionListResponse,
controlUiSessionPath,
@ -12,12 +12,12 @@ import {
const suite = createChatFlowE2eSuite();
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"header-outcomes-followup",
);
let proofDir: string;
beforeEach(() => {
if (captureProof) {
proofDir = createControlUiE2eArtifactDir("header-outcomes-followup");
}
});
async function capture(page: Page, name: string): Promise<void> {
if (captureProof) {
@ -97,12 +97,6 @@ async function startPlacementReclaim(
}
suite.define(() => {
beforeAll(async () => {
if (captureProof) {
await mkdir(proofDir, { recursive: true });
}
});
it("does not resurrect a reveal failure after navigating away", async () => {
const context = await suite.newBrowserContext(proofContextOptions());
const page = await context.newPage();

View file

@ -1,7 +1,15 @@
// Control UI E2E covers pooling reply-less wake activity (heartbeats) into one rollup row.
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
let artifactDir: string | undefined;
beforeEach(() => {
const parent = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
artifactDir = parent
? createControlUiE2eArtifactDir("chat-heartbeat-activity-rollup", parent)
: undefined;
});
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { waitForChatScrollIdle } from "./chat-flow.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -12,11 +20,9 @@ const suite = createControlUiE2eSuite({
});
async function captureProof(page: import("playwright").Page, name: string) {
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
if (!artifactDir) {
return;
}
await fs.mkdir(artifactDir, { recursive: true });
await page.screenshot({ path: path.join(artifactDir, `${name}.png`), fullPage: true });
}

View file

@ -1,10 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
CHAT_SNAPSHOT_DB_NAME,
CHAT_SNAPSHOT_STORE_NAME,
} from "../pages/chat/session-snapshot-database.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
createChatFlowE2eSuite,
installMockGateway,
@ -27,10 +27,10 @@ function historyMessage(seq: number, text: string) {
suite.define(() => {
it("discards stale transcript geometry before restored history bootstrap", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
}
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-history-stale-geometry", artifactRoot)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",

View file

@ -1,8 +1,9 @@
import { mkdir, readFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { finishElementAnimations } from "../test-helpers/animations.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -17,7 +18,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "image-lightbox");
let proofDir: string;
beforeEach(() => {
if (captureUiProofEnabled) {
proofDir = createControlUiE2eArtifactDir("image-lightbox");
}
});
let server: ControlUiE2eServer;
let browser: Browser;
@ -57,9 +63,6 @@ describeControlUiE2e("Control UI image lightbox", () => {
const banner = await readFile(path.join(process.cwd(), "docs/assets/openclaw-banner-dark.png"));
const bannerBase64 = banner.toString("base64");
const dataUrl = `data:image/png;base64,${bannerBase64}`;
if (captureUiProofEnabled) {
await mkdir(proofDir, { recursive: true });
}
const context = await newContext({
locale: "en-US",
recordVideo: captureUiProofEnabled

View file

@ -1,10 +1,11 @@
// Control UI regression proof for #99213: paste a large screenshot-like PNG through the
// real chat composer and verify chat.send receives it without overflowing base64 handling.
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { copyFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiE2eWaitTimeoutMs,
@ -18,7 +19,6 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-large-paste-99213");
const viewport = { height: 900, width: 1280 };
let server: ControlUiE2eServer;
@ -113,11 +113,8 @@ function toBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64");
}
async function newRecordedPage(label: string): Promise<RecordedPage> {
await mkdir(artifactDir, { recursive: true });
const rawVideoDir = path.join(artifactDir, `${label}-raw`);
await rm(rawVideoDir, { force: true, recursive: true });
await mkdir(rawVideoDir, { recursive: true });
async function newRecordedPage(artifactDir: string, label: string): Promise<RecordedPage> {
const rawVideoDir = createControlUiE2eArtifactDir(`${label}-raw`, artifactDir);
const browser = await chromium.launch({ executablePath: chromiumExecutablePath });
let context: BrowserContext | undefined;
let page: Page | undefined;
@ -139,12 +136,15 @@ async function newRecordedPage(label: string): Promise<RecordedPage> {
await page?.close().catch(() => {});
await context?.close().catch(() => {});
await browser.close().catch(() => {});
await rm(rawVideoDir, { force: true, recursive: true });
throw error;
}
}
async function closeRecordedPage(recorded: RecordedPage, label: string): Promise<string[]> {
async function closeRecordedPage(
recorded: RecordedPage,
artifactDir: string,
label: string,
): Promise<string[]> {
const video = recorded.page.video();
const videos: string[] = [];
try {
@ -154,10 +154,11 @@ async function closeRecordedPage(recorded: RecordedPage, label: string): Promise
const videoPath = path.join(artifactDir, `${label}.webm`);
await copyFile(rawVideoPath, videoPath);
videos.push(videoPath);
// Failed finalization leaves the raw recording available for inspection.
await rm(recorded.rawVideoDir, { force: true, recursive: true });
}
} finally {
await recorded.browser.close().catch(() => {});
await rm(recorded.rawVideoDir, { force: true, recursive: true });
}
return videos;
}
@ -177,13 +178,12 @@ describeControlUiE2e("Control UI #99213 large screenshot paste proof", () => {
});
it("sends a roughly 2 MB PNG without overlapping transcript rows", async () => {
await rm(artifactDir, { force: true, recursive: true });
await mkdir(artifactDir, { recursive: true });
const artifactDir = createControlUiE2eArtifactDir("chat-large-paste-99213");
const pngBytes = createLargePngBytes(1_901_669);
const imageBase64 = toBase64(pngBytes);
const dataUrl = `data:image/png;base64,${imageBase64}`;
const prompt = "proof: large Control UI clipboard image";
const recorded = await newRecordedPage("large-paste");
const recorded = await newRecordedPage(artifactDir, "large-paste");
const screenshots: string[] = [];
let videos: string[];
@ -270,7 +270,7 @@ describeControlUiE2e("Control UI #99213 large screenshot paste proof", () => {
await recorded.page.screenshot({ fullPage: true, path: sentScreenshot });
screenshots.push(sentScreenshot);
} finally {
videos = await closeRecordedPage(recorded, "large-paste");
videos = await closeRecordedPage(recorded, artifactDir, "large-paste");
}
const summary = {

View file

@ -1,6 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
createChatFlowE2eSuite,
installMockGateway,
@ -11,10 +11,10 @@ const suite = createChatFlowE2eSuite();
suite.define(() => {
it("keeps multiple live replies after their delayed prompt before history catches up", async () => {
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
const artifactRoot = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-live-final-order", artifactRoot)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@ -233,9 +233,11 @@ suite.define(() => {
)
.toBe(true);
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-live-final-order", artifactRoot)
: undefined;
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
path: path.join(artifactDir, "live-final-transcript-order.png"),
fullPage: true,

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -15,10 +15,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const artifactDir = path.resolve(
process.cwd(),
".artifacts/control-ui-e2e/chat-markdown-table-interactions",
);
let artifactDir: string;
beforeEach(() => {
if (captureProof) {
artifactDir = createControlUiE2eArtifactDir("chat-markdown-table-interactions");
}
});
const wideTable = `| Service | Owner | Region | Status | Version | Deploy | Incidents | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- |
@ -32,9 +34,6 @@ describeControlUiE2e("Control UI Markdown table interactions", () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
}
server = await startControlUiE2eServer(undefined, { source: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});

View file

@ -1,8 +1,8 @@
// Real-browser proof for inline and context-menu chat message actions.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Locator, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@ -16,7 +16,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-message-actions");
let artifactDir: string;
beforeEach(() => {
if (captureUiProof) {
artifactDir = createControlUiE2eArtifactDir("chat-message-actions");
}
});
// Neutral filler line repeated to push the fixture past the transport
// preview limit without embedding stale implementation narrative; the
@ -154,9 +159,6 @@ describeControlUiE2e("Control UI chat message actions", () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
if (captureUiProof) {
await mkdir(artifactDir, { recursive: true });
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});

View file

@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -129,7 +130,10 @@ suite.define(() => {
await expect
.poll(() => model.evaluate((node) => node === document.activeElement))
.toBe(true);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-model-controls", artifactRoot)
: undefined;
if (artifactDir && [320, 393, 560, 1280].includes(width)) {
await page.screenshot({
path: `${artifactDir}/${route}-model-effort-${width}.png`,
@ -209,7 +213,10 @@ suite.define(() => {
),
)
.toBe(true);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-model-controls", artifactRoot)
: undefined;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/chat-model-effort-split.png`,
@ -309,7 +316,10 @@ suite.define(() => {
await expect
.poll(() => effort.evaluate((node) => node === document.activeElement))
.toBe(true);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-model-controls", artifactRoot)
: undefined;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/chat-speed-only-320.png`,

View file

@ -1,14 +1,17 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
const sessionKey = "agent:main:main";
const proofDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model")
: null;
let proofDir: string | null;
beforeEach(() => {
proofDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? createControlUiE2eArtifactDir("chat-only-model")
: null;
});
const models = [
{
@ -59,9 +62,6 @@ function sessionsList(model: string, modelProvider: string) {
suite.define(() => {
it("explains chat-only models and keeps model switching as the recovery path", async () => {
if (proofDir) {
await mkdir(proofDir, { recursive: true });
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",

View file

@ -1,6 +1,6 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import {
activateChatHeaderPanelAction,
@ -14,7 +14,6 @@ const suite = createControlUiE2eSuite({
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
});
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-panel-reflow");
const chatSessionKey = "agent:main:main";
const longReply =
@ -65,8 +64,7 @@ async function expectMessagesNotToOverlap(page: import("playwright").Page): Prom
suite.define(() => {
it("keeps transcript rows separate across background-task, file, and diff panel toggles", async () => {
await rm(artifactDir, { force: true, recursive: true });
await mkdir(artifactDir, { recursive: true });
const artifactDir = createControlUiE2eArtifactDir("chat-panel-reflow");
await suite.withPage(
{
locale: "en-US",

View file

@ -1,10 +1,10 @@
// Control UI tests cover session pull request chips above the chat composer.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js";
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiSessionUrl,
@ -20,18 +20,18 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const publicationProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"github-publication",
);
const stackingProofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"pr-chip-stacking",
);
let publicationProofDir: string;
beforeEach(() => {
if (captureUiProof) {
publicationProofDir = createControlUiE2eArtifactDir("github-publication");
}
});
let stackingProofDir: string;
beforeEach(() => {
if (captureUiProof) {
stackingProofDir = createControlUiE2eArtifactDir("pr-chip-stacking");
}
});
let server: ControlUiE2eServer;
// Browser contexts preserve test isolation; keep one process warm for this file.
@ -317,7 +317,6 @@ describeControlUiE2e("session pull request chips", () => {
await overlapLastTranscriptRowWithPullRequestChip(page);
if (captureUiProof) {
await mkdir(stackingProofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(stackingProofDir, `${label}.png`),
@ -391,9 +390,6 @@ describeControlUiE2e("session pull request chips", () => {
});
it("publishes through the Gateway and renders the terminal pull request URL", async () => {
if (captureUiProof) {
await mkdir(publicationProofDir, { recursive: true });
}
const context = await browser.newContext({
colorScheme: "light",
locale: "en-US",
@ -672,7 +668,6 @@ describeControlUiE2e("session pull request chips", () => {
)
.toBe("https://github.com/openclaw/openclaw/pull/new/openclaw/rejected-publication");
if (captureUiProof) {
await mkdir(publicationProofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
@ -815,7 +810,6 @@ describeControlUiE2e("session pull request chips", () => {
.toContain("Start a live agent turn");
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0);
if (captureUiProof) {
await mkdir(publicationProofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,

View file

@ -1,9 +1,9 @@
// Real-browser proof + regression for #93041: provider usage from models.authStatus remains
// available in the desktop composer's context popover. Screenshots go to the ignored artifacts tree.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { BrowserContext, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiE2eWaitTimeoutMs,
controlUiSessionUrl,
@ -17,14 +17,10 @@ const suite = createControlUiE2eSuite({
});
const baseTime = 1_700_000_000_000;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-quota-pill-93041");
let artifactDir: string;
const captureOwnershipProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const ownershipProofPhase = process.env.OPENCLAW_UI_PROOF_PHASE?.trim() || "candidate";
const ownershipProofDir = path.resolve(
process.cwd(),
".artifacts/ui-visual-proof/agent-quota-ownership",
ownershipProofPhase,
);
let ownershipProofDir: string;
const authStatusWithUsage = {
ts: baseTime,
@ -333,6 +329,12 @@ async function openVisibleQuotaPopover(page: Page) {
}
suite.define(() => {
beforeEach(() => {
artifactDir = createControlUiE2eArtifactDir("chat-quota-pill-93041");
if (captureOwnershipProof) {
ownershipProofDir = path.join(artifactDir, "agent-quota-ownership", ownershipProofPhase);
}
});
it("shows high context pressure without a compact action", async () => {
const fixture = await openChat(authStatusWithUsage, {
"sessions.list": highPressureSessions,
@ -462,9 +464,6 @@ suite.define(() => {
});
it("binds delayed auth and quota presentation to the selected global agent", async () => {
if (captureOwnershipProof) {
await mkdir(ownershipProofDir, { recursive: true });
}
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
@ -737,7 +736,7 @@ suite.define(() => {
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(ownershipProofDir, "03-work-settled.png"),
path: path.join(ownershipProofDir, "04-work-quota-popover.png"),
});
}
} finally {

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiBundledSettingsStorageKey,
controlUiSessionUrl,
@ -17,8 +17,22 @@ const suite = createControlUiE2eSuite({
});
const sessionKey = "agent:main:rail-tabs";
const proofDir = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
const videoDir = process.env.OPENCLAW_UI_RAIL_VIDEO_DIR?.trim();
const proofDirParent = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
let proofDir: string | undefined;
beforeEach(() => {
proofDir = proofDirParent
? createControlUiE2eArtifactDir("chat-rail-columns", proofDirParent)
: undefined;
});
const videoDirParent = process.env.OPENCLAW_UI_RAIL_VIDEO_DIR?.trim();
let videoDir: string | undefined;
beforeEach(() => {
videoDir = videoDirParent
? proofDir && proofDirParent && path.resolve(videoDirParent) === path.resolve(proofDirParent)
? proofDir
: createControlUiE2eArtifactDir("chat-rail-columns", videoDirParent)
: undefined;
});
const historyMessages = Array.from({ length: 10 }, (_, index) => ({
id: `rail-tabs-${index}`,
@ -276,7 +290,6 @@ async function captureRichPanel(page: Page, name: string) {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
const clip = await page.evaluate(() => {
const elements = [
document.querySelector<HTMLElement>(".chat-pane__header"),

View file

@ -1,6 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
createChatFlowE2eSuite,
expectRequestCountStable,
@ -31,10 +32,10 @@ suite.define(() => {
])(
"settles a $name without repeatedly loading the reply preview",
async ({ artifact, response }) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-reply-preview-recovery", artifactRoot)
: undefined;
const context = await suite.newBrowserContext({
locale: "en-US",
...(artifactDir

View file

@ -1,5 +1,4 @@
// Control UI E2E tests cover chat run lifecycle behavior through the Gateway WebSocket.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { afterEach, expect, it } from "vitest";
@ -232,8 +231,7 @@ suite.define(() => {
expect(await currentPage.locator(".chat-reading-indicator").count()).toBe(0);
expect(await assistantGroup.getByText("Working…", { exact: true }).count()).toBe(1);
const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-single-turn-status");
await mkdir(artifactDir, { recursive: true });
const artifactDir = path.join(suite.artifactDir, "chat-single-turn-status");
await currentPage.screenshot({
path: path.join(artifactDir, "continuing-reply.png"),
fullPage: true,
@ -275,11 +273,11 @@ suite.define(() => {
});
it("restores only the unpersisted assistant response after reconnecting", async () => {
const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-inflight-reconnect");
const artifactDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? path.join(suite.artifactDir, "chat-inflight-reconnect")
: "";
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
}
const context = await suite.newBrowserContext({
viewport: { height: 800, width: 1200 },
...(captureProof
@ -586,11 +584,11 @@ suite.define(() => {
});
it("renders a safe self-abort diagnostic without leaving stale composer status", async () => {
const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-abort-diagnostic");
const artifactDir =
process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"
? path.join(suite.artifactDir, "chat-abort-diagnostic")
: "";
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
}
const context = await suite.newBrowserContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiSessionUrl,
installMockGateway,
@ -16,7 +16,13 @@ const suite = createControlUiE2eSuite({
startServerBeforeBrowser: true,
});
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
let artifactDir: string | undefined;
beforeEach(() => {
artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-session-companion-clear", artifactRoot)
: undefined;
});
const answer = "Keep this companion answer visible until the reset succeeds.";
const initiatingSessionKey = "agent:main:companion-clear";
const nextSessionKey = "agent:main:companion-next";
@ -30,9 +36,6 @@ type CompanionSurface = {
};
async function withCompanion(run: (surface: CompanionSurface) => Promise<void>): Promise<void> {
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
}
await suite.withPage(
{
locale: "en-US",

View file

@ -1,10 +1,10 @@
// Control UI tests cover the session diff panel (sessions.diff RPC).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js";
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiBundledSettingsStorageKey,
@ -25,7 +25,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/diff-highlighting");
let artifactDir: string;
beforeEach(() => {
if (captureProof) {
artifactDir = createControlUiE2eArtifactDir("diff-highlighting");
}
});
let server: ControlUiE2eServer;
// Browser contexts preserve test isolation; keep one process warm for this file.
@ -607,7 +612,6 @@ describeControlUiE2e("session diff panel", () => {
),
).toBe(true);
if (captureProof) {
await mkdir(artifactDir, { recursive: true });
await panelSurface.screenshot({ path: path.join(artifactDir, "unified-light.png") });
}

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { beforeEach, afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
canRunPlaywrightChromium,
controlUiSessionUrl,
@ -17,12 +17,12 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-discussion-toggle",
);
let proofDir: string;
beforeEach(() => {
if (captureUiProof) {
proofDir = createControlUiE2eArtifactDir("session-discussion-toggle");
}
});
let server: ControlUiE2eServer;
let browser: Browser;
@ -41,9 +41,6 @@ describeControlUiE2e("session discussion toggle", () => {
}
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
server = await startControlUiE2eServer();
if (captureUiProof) {
await mkdir(proofDir, { recursive: true });
}
});
afterEach(closeOpenContexts);

View file

@ -1,7 +1,7 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
controlUiBundledSettingsStorageKey,
installMockGateway,
@ -19,7 +19,13 @@ const suite = createControlUiE2eSuite({
});
const sessionKey = "agent:main:side-panel-clearance";
const proofDir = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
const proofDirParent = process.env.OPENCLAW_UI_RAIL_PROOF_DIR?.trim();
let proofDir: string | undefined;
beforeEach(() => {
proofDir = proofDirParent
? createControlUiE2eArtifactDir("chat-side-panel-clearance", proofDirParent)
: undefined;
});
const limitedScopes = ["operator.read", "operator.write"];
const historyMessages = [
{
@ -196,7 +202,6 @@ async function capturePanel(page: Page, name: string): Promise<void> {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(proofDir, `${name}.png`) });
}

View file

@ -1,6 +1,7 @@
// Control UI E2E tests cover composable skill references in the chat composer.
import path from "node:path";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -10,7 +11,10 @@ const suite = createControlUiE2eSuite({
suite.define(() => {
it("references multiple skills inside a normal prompt and sends their raw names", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-skill-references", artifactRoot)
: undefined;
await suite.withPage(
{
viewport: { width: 1280, height: 900 },

View file

@ -2,6 +2,7 @@
import path from "node:path";
import { text } from "node:stream/consumers";
import { expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -13,7 +14,10 @@ suite.define(() => {
it.each(["/export-session", "/export"])(
"shows an empty export result and retains staged attachments for %s",
async (command) => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-slash-command-ranking", artifactRoot)
: undefined;
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
const gateway = await installMockGateway(page, { historyMessages: [] });
const downloads: string[] = [];
@ -99,7 +103,10 @@ suite.define(() => {
});
it("keeps visible search results and keyboard selection in relevance order", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactRoot = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const artifactDir = artifactRoot
? createControlUiE2eArtifactDir("chat-slash-command-ranking", artifactRoot)
: undefined;
await suite.withPage(
{
viewport: { width: 1280, height: 900 },

Some files were not shown because too many files have changed in this diff Show more