cozyclay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run a multi-prompt ARDY rollout in one remote process. The remote
|
|
3
|
+
# cclay_sequence_generate.py keeps the model loaded and feeds each segment
|
|
4
|
+
# the previous segment's motion tail through init_history_sequence.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
HOST="${CCLAY_ARDY_HOST:-}"
|
|
9
|
+
REMOTE="${CCLAY_ARDY_REPO:-\$HOME/ardy}"
|
|
10
|
+
VENV_PY="${CCLAY_ARDY_VENV:-~/ardy/.venv-cuda/bin/python}"
|
|
11
|
+
ENCODER_URL="${CCLAY_ARDY_ENCODER_URL:-http://127.0.0.1:9550/}"
|
|
12
|
+
FORCE_CPU=0
|
|
13
|
+
|
|
14
|
+
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30 -o ServerAliveCountMax=240)
|
|
15
|
+
SCP_OPTS=(-o BatchMode=yes -o ConnectTimeout=10)
|
|
16
|
+
|
|
17
|
+
usage() {
|
|
18
|
+
cat >&2 <<'EOF'
|
|
19
|
+
usage: run-sequence-on-box.sh \
|
|
20
|
+
--segment "<prompt>" <seconds> [--segment "<prompt>" <seconds> ...] \
|
|
21
|
+
[--root-2d <frame> <x> <z> <heading|none> ...] \
|
|
22
|
+
[--seed <N>] [--cpu] [--output <local.npz>] [--dry-run]
|
|
23
|
+
EOF
|
|
24
|
+
exit 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
SEGMENTS=()
|
|
28
|
+
ROOT2D=()
|
|
29
|
+
SEED=""
|
|
30
|
+
OUTPUT=""
|
|
31
|
+
DRY_RUN=0
|
|
32
|
+
|
|
33
|
+
while [[ $# -gt 0 ]]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--root-2d)
|
|
36
|
+
[[ $# -ge 5 ]] || { echo "run-sequence-on-box: --root-2d needs FRAME X Z HEADING" >&2; usage; }
|
|
37
|
+
[[ "$2" =~ ^[0-9]+$ ]] || { echo "run-sequence-on-box: --root-2d frame must be a non-negative integer, got '$2'" >&2; exit 1; }
|
|
38
|
+
[[ "$3" =~ ^-?[0-9]+([.][0-9]+)?$ && "$4" =~ ^-?[0-9]+([.][0-9]+)?$ ]] || {
|
|
39
|
+
echo "run-sequence-on-box: --root-2d X Z must be numbers, got '$3' '$4'" >&2; exit 1;
|
|
40
|
+
}
|
|
41
|
+
[[ "$5" == "none" || "$5" =~ ^-?[0-9]+([.][0-9]+)?$ ]] || {
|
|
42
|
+
echo "run-sequence-on-box: --root-2d heading must be a number or 'none', got '$5'" >&2; exit 1;
|
|
43
|
+
}
|
|
44
|
+
ROOT2D+=("$2" "$3" "$4" "$5")
|
|
45
|
+
shift 5 ;;
|
|
46
|
+
--segment)
|
|
47
|
+
[[ $# -ge 3 ]] || { echo "run-sequence-on-box: --segment needs PROMPT SECONDS" >&2; usage; }
|
|
48
|
+
[[ -n "${2//[[:space:]]/}" ]] || { echo "run-sequence-on-box: segment prompt must not be empty" >&2; exit 1; }
|
|
49
|
+
[[ "$3" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "run-sequence-on-box: segment duration must be a number, got '$3'" >&2; exit 1; }
|
|
50
|
+
SEGMENTS+=("$2" "$3")
|
|
51
|
+
shift 3 ;;
|
|
52
|
+
--seed)
|
|
53
|
+
[[ $# -ge 2 && "$2" =~ ^[0-9]+$ ]] || { echo "run-sequence-on-box: --seed needs a non-negative integer" >&2; usage; }
|
|
54
|
+
SEED="$2"; shift 2 ;;
|
|
55
|
+
--cpu)
|
|
56
|
+
FORCE_CPU=1; shift ;;
|
|
57
|
+
--output)
|
|
58
|
+
[[ $# -ge 2 ]] || { echo "run-sequence-on-box: --output needs a path" >&2; usage; }
|
|
59
|
+
OUTPUT="$2"; shift 2 ;;
|
|
60
|
+
--dry-run)
|
|
61
|
+
DRY_RUN=1; shift ;;
|
|
62
|
+
-h|--help)
|
|
63
|
+
usage ;;
|
|
64
|
+
*)
|
|
65
|
+
echo "run-sequence-on-box: unknown option '$1'" >&2
|
|
66
|
+
usage ;;
|
|
67
|
+
esac
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
[[ -n "$HOST" ]] || {
|
|
71
|
+
echo "run-sequence-on-box: CCLAY_ARDY_HOST is required (for example: user@ardy-host)" >&2
|
|
72
|
+
exit 2
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
[[ ${#SEGMENTS[@]} -ge 4 ]] || {
|
|
76
|
+
echo "run-sequence-on-box: at least two --segment entries are required" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for ((i = 1; i < ${#SEGMENTS[@]}; i += 2)); do
|
|
81
|
+
awk -v d="${SEGMENTS[$i]}" 'BEGIN { exit !(d > 0 && d <= 1200) }' || {
|
|
82
|
+
echo "run-sequence-on-box: segment duration must be > 0 and <= 1200 seconds, got '${SEGMENTS[$i]}'" >&2
|
|
83
|
+
exit 1
|
|
84
|
+
}
|
|
85
|
+
done
|
|
86
|
+
|
|
87
|
+
OUTPUT="${OUTPUT:-$HERE/out/$(date +%s)-sequence.npz}"
|
|
88
|
+
|
|
89
|
+
if ! ssh "${SSH_OPTS[@]}" "$HOST" ":" </dev/null; then
|
|
90
|
+
echo "run-sequence-on-box: cannot ssh to ${HOST}" >&2
|
|
91
|
+
exit 1
|
|
92
|
+
fi
|
|
93
|
+
echo "run-sequence-on-box: ssh to ${HOST} ok"
|
|
94
|
+
|
|
95
|
+
if ! ssh "${SSH_OPTS[@]}" "$HOST" "test -x ${VENV_PY}" </dev/null; then
|
|
96
|
+
echo "run-sequence-on-box: missing ${VENV_PY} on ${HOST}" >&2
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# The generator is cclay-owned: the source of truth is the repo copy next to
|
|
101
|
+
# this script, synced up on every run so the box can never drift behind it.
|
|
102
|
+
if ! ssh "${SSH_OPTS[@]}" "$HOST" "cat > ${REMOTE}/scripts/cclay_sequence_generate.py" \
|
|
103
|
+
< "${HERE}/cclay_sequence_generate.py"; then
|
|
104
|
+
echo "run-sequence-on-box: could not sync cclay_sequence_generate.py to ${HOST}" >&2
|
|
105
|
+
exit 1
|
|
106
|
+
fi
|
|
107
|
+
echo "run-sequence-on-box: sequence generator synced to ${HOST}"
|
|
108
|
+
|
|
109
|
+
if ! ENCODER_CODE="$(ssh "${SSH_OPTS[@]}" "$HOST" \
|
|
110
|
+
"curl -s -o /dev/null -w '%{http_code}' -m 10 ${ENCODER_URL}" </dev/null)"; then
|
|
111
|
+
echo "run-sequence-on-box: could not probe the text encoder" >&2
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
[[ "$ENCODER_CODE" == "200" ]] || {
|
|
115
|
+
echo "run-sequence-on-box: text encoder at ${ENCODER_URL} answered '${ENCODER_CODE}', not 200" >&2
|
|
116
|
+
exit 1
|
|
117
|
+
}
|
|
118
|
+
echo "run-sequence-on-box: text encoder ${ENCODER_URL} responding"
|
|
119
|
+
|
|
120
|
+
CUDA_ENV=""
|
|
121
|
+
[[ "$FORCE_CPU" -eq 1 ]] && CUDA_ENV='CUDA_VISIBLE_DEVICES="" '
|
|
122
|
+
|
|
123
|
+
build_remote_cmd() {
|
|
124
|
+
local tmp_dir="$1" cmd i
|
|
125
|
+
cmd="cd ${REMOTE} && ${CUDA_ENV}${VENV_PY} scripts/cclay_sequence_generate.py"
|
|
126
|
+
for ((i = 0; i < ${#SEGMENTS[@]}; i += 2)); do
|
|
127
|
+
cmd+=" --segment $(printf '%q' "${SEGMENTS[$i]}") $(printf '%q' "${SEGMENTS[$((i + 1))]}")"
|
|
128
|
+
done
|
|
129
|
+
for ((i = 0; i < ${#ROOT2D[@]}; i += 4)); do
|
|
130
|
+
cmd+=" --root-2d $(printf '%q' "${ROOT2D[$i]}") $(printf '%q' "${ROOT2D[$((i + 1))]}")"
|
|
131
|
+
cmd+=" $(printf '%q' "${ROOT2D[$((i + 2))]}") $(printf '%q' "${ROOT2D[$((i + 3))]}")"
|
|
132
|
+
done
|
|
133
|
+
cmd+=" --output $(printf '%q' "${tmp_dir}/out")"
|
|
134
|
+
[[ -z "$SEED" ]] || cmd+=" --seed $(printf '%q' "$SEED")"
|
|
135
|
+
printf '%s' "$cmd"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
139
|
+
echo "run-sequence-on-box: DRY RUN - $(( ${#SEGMENTS[@]} / 2 )) segments in one ARDY process"
|
|
140
|
+
echo "run-sequence-on-box: would run ssh ${HOST} \"$(build_remote_cmd "<mktemp-dir-on-box>")\""
|
|
141
|
+
echo "run-sequence-on-box: would pull <mktemp-dir-on-box>/out.npz -> ${OUTPUT}"
|
|
142
|
+
exit 0
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
mkdir -p "$(dirname "$OUTPUT")"
|
|
146
|
+
REMOTE_TMP="$(ssh "${SSH_OPTS[@]}" "$HOST" "mktemp -d" </dev/null)"
|
|
147
|
+
[[ "$REMOTE_TMP" == /* ]] || {
|
|
148
|
+
echo "run-sequence-on-box: remote mktemp returned unsafe path '${REMOTE_TMP}'" >&2
|
|
149
|
+
exit 1
|
|
150
|
+
}
|
|
151
|
+
trap 'ssh "${SSH_OPTS[@]}" "$HOST" "rm -rf ${REMOTE_TMP}" </dev/null >/dev/null 2>&1 || true' EXIT
|
|
152
|
+
|
|
153
|
+
REMOTE_CMD="$(build_remote_cmd "$REMOTE_TMP")"
|
|
154
|
+
echo "run-sequence-on-box: generating $(( ${#SEGMENTS[@]} / 2 )) prompt blocks in one ARDY process ..."
|
|
155
|
+
ssh "${SSH_OPTS[@]}" "$HOST" "$REMOTE_CMD" </dev/null
|
|
156
|
+
|
|
157
|
+
scp -q "${SCP_OPTS[@]}" "$HOST:${REMOTE_TMP}/out.npz" "$OUTPUT"
|
|
158
|
+
[[ -s "$OUTPUT" ]] || {
|
|
159
|
+
echo "run-sequence-on-box: pulled ${OUTPUT} is empty" >&2
|
|
160
|
+
exit 1
|
|
161
|
+
}
|
|
162
|
+
echo "run-sequence-on-box: done - ${OUTPUT} ($(wc -c < "$OUTPUT" | tr -d ' ') bytes)"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual QA driver for CozyClay: drives headless Chrome over CDP with
|
|
3
|
+
* Node's built-in WebSocket. Loads the studio, generates "a person walk"
|
|
4
|
+
* with the default seed (2), then captures screenshots at several playback
|
|
5
|
+
* frames for pose review.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node tools/ardy/visual-qa.mjs <outDir> [url]
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const OUT = process.argv[2] || "/tmp/visual-qa";
|
|
12
|
+
const URL = process.argv[3] || "http://127.0.0.1:5180/";
|
|
13
|
+
mkdirSync(OUT, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
// --- minimal CDP client -----------------------------------------------------
|
|
18
|
+
async function newTab() {
|
|
19
|
+
const res = await fetch("http://127.0.0.1:9222/json/new?about:blank", { method: "PUT" });
|
|
20
|
+
const target = await res.json();
|
|
21
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
22
|
+
await new Promise((resolve, reject) => {
|
|
23
|
+
ws.onopen = resolve;
|
|
24
|
+
ws.onerror = reject;
|
|
25
|
+
});
|
|
26
|
+
let id = 0;
|
|
27
|
+
const pending = new Map();
|
|
28
|
+
const consoleLog = [];
|
|
29
|
+
ws.onmessage = (event) => {
|
|
30
|
+
const msg = JSON.parse(event.data);
|
|
31
|
+
if (msg.id && pending.has(msg.id)) {
|
|
32
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
33
|
+
pending.delete(msg.id);
|
|
34
|
+
if (msg.error) reject(new Error(msg.error.message));
|
|
35
|
+
else resolve(msg.result);
|
|
36
|
+
} else if (msg.method === "Runtime.consoleAPICalled") {
|
|
37
|
+
const type = msg.params.type;
|
|
38
|
+
const text = msg.params.args.map((a) => a.value ?? a.description ?? "").join(" ");
|
|
39
|
+
if (type === "error" || type === "warning") consoleLog.push(`[${type}] ${text}`);
|
|
40
|
+
} else if (msg.method === "Runtime.exceptionThrown") {
|
|
41
|
+
consoleLog.push(`[exception] ${msg.params.exceptionDetails.text} ${msg.params.exceptionDetails.exception?.description ?? ""}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const send = (method, params = {}) =>
|
|
45
|
+
new Promise((resolve, reject) => {
|
|
46
|
+
const msgId = ++id;
|
|
47
|
+
pending.set(msgId, { resolve, reject });
|
|
48
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
49
|
+
});
|
|
50
|
+
return { ws, send, consoleLog };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function evaluate(tab, expression) {
|
|
54
|
+
const res = await tab.send("Runtime.evaluate", {
|
|
55
|
+
expression,
|
|
56
|
+
awaitPromise: true,
|
|
57
|
+
returnByValue: true,
|
|
58
|
+
});
|
|
59
|
+
if (res.exceptionDetails) {
|
|
60
|
+
throw new Error(`evaluate failed: ${JSON.stringify(res.exceptionDetails.exception?.description || res.exceptionDetails.text)}`);
|
|
61
|
+
}
|
|
62
|
+
return res.result.value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function shot(tab, name) {
|
|
66
|
+
const res = await tab.send("Page.captureScreenshot", { format: "png" });
|
|
67
|
+
writeFileSync(`${OUT}/${name}.png`, Buffer.from(res.data, "base64"));
|
|
68
|
+
console.log(`saved ${OUT}/${name}.png`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- flow -------------------------------------------------------------------
|
|
72
|
+
const tab = await newTab();
|
|
73
|
+
await tab.send("Page.enable");
|
|
74
|
+
await tab.send("Runtime.enable");
|
|
75
|
+
await tab.send("Page.navigate", { url: URL });
|
|
76
|
+
await sleep(6000);
|
|
77
|
+
|
|
78
|
+
const state0 = await evaluate(tab, `(() => {
|
|
79
|
+
const seed = document.querySelector('input[placeholder="empty = random"]');
|
|
80
|
+
const gen = [...document.querySelectorAll("button")].find(b => /generate motion/i.test(b.textContent));
|
|
81
|
+
const promptEl = [...document.querySelectorAll("textarea, input")].find(e =>
|
|
82
|
+
(e.closest("div,label")?.textContent || "").includes("Motion prompt"));
|
|
83
|
+
return { seedValue: seed?.value ?? null, hasGenerate: !!gen, hasPrompt: !!promptEl };
|
|
84
|
+
})()`);
|
|
85
|
+
console.log("initial:", JSON.stringify(state0));
|
|
86
|
+
|
|
87
|
+
if (!state0.hasGenerate || !state0.hasPrompt) {
|
|
88
|
+
await shot(tab, "00-no-ui");
|
|
89
|
+
throw new Error("studio UI not ready");
|
|
90
|
+
}
|
|
91
|
+
if (state0.seedValue !== "2") {
|
|
92
|
+
console.log("WARN: seed default is not 2:", state0.seedValue);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Set the prompt and generate: focus the field, then type via the Input
|
|
96
|
+
// domain so React's controlled input sees real key events.
|
|
97
|
+
await evaluate(tab, `(() => {
|
|
98
|
+
const el = [...document.querySelectorAll("textarea, input")].find(e =>
|
|
99
|
+
(e.closest("div,label")?.textContent || "").includes("Motion prompt"));
|
|
100
|
+
el.focus();
|
|
101
|
+
el.select?.();
|
|
102
|
+
return true;
|
|
103
|
+
})()`);
|
|
104
|
+
for (const key of ["a", " ", "p", "e", "r", "s", "o", "n", " ", "w", "a", "l", "k"]) {
|
|
105
|
+
await tab.send("Input.dispatchKeyEvent", { type: "keyDown", text: key });
|
|
106
|
+
await tab.send("Input.dispatchKeyEvent", { type: "keyUp", text: key });
|
|
107
|
+
}
|
|
108
|
+
await sleep(300);
|
|
109
|
+
await shot(tab, "01-before-generate");
|
|
110
|
+
await evaluate(tab, `(() => {
|
|
111
|
+
const gen = [...document.querySelectorAll("button")].find(b => /generate motion/i.test(b.textContent));
|
|
112
|
+
gen.click();
|
|
113
|
+
return true;
|
|
114
|
+
})()`);
|
|
115
|
+
console.log("generate clicked, waiting for the clip...");
|
|
116
|
+
|
|
117
|
+
// Wait for "Motion loaded" or the PLAYBACK badge (generation ~12 s).
|
|
118
|
+
let loaded = false;
|
|
119
|
+
for (let i = 0; i < 40; i += 1) {
|
|
120
|
+
await sleep(1000);
|
|
121
|
+
const st = await evaluate(tab, `document.body.textContent.includes("Motion loaded") || document.body.textContent.includes("PLAYBACK")`);
|
|
122
|
+
if (st) { loaded = true; break; }
|
|
123
|
+
}
|
|
124
|
+
console.log("motion loaded:", loaded);
|
|
125
|
+
if (!loaded) {
|
|
126
|
+
await shot(tab, "02-generation-timeout");
|
|
127
|
+
throw new Error("motion did not load within 40 s");
|
|
128
|
+
}
|
|
129
|
+
await sleep(1500);
|
|
130
|
+
|
|
131
|
+
// Scrub to specific frames via the timeline frame buttons and capture.
|
|
132
|
+
const frames = [0, 20, 40, 60, 79];
|
|
133
|
+
for (const f of frames) {
|
|
134
|
+
const okFrame = await evaluate(tab, `(() => {
|
|
135
|
+
const btns = [...document.querySelectorAll("button, span, div")].filter(e =>
|
|
136
|
+
e.textContent.trim() === "${f}" && e.closest('[class*="tl"], [class*="frame"], [class*="track"]'));
|
|
137
|
+
if (btns.length) { btns[0].click(); return "tick"; }
|
|
138
|
+
return "no-tick";
|
|
139
|
+
})()`);
|
|
140
|
+
if (okFrame === "no-tick") {
|
|
141
|
+
// fall back: type the frame into a frame input if one exists
|
|
142
|
+
await evaluate(tab, `(() => {
|
|
143
|
+
const inp = [...document.querySelectorAll("input")].find(e => /frame/i.test(e.closest("div,label")?.textContent || "") || e.type === "number");
|
|
144
|
+
if (!inp) return false;
|
|
145
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
|
|
146
|
+
setter.call(inp, "${f}");
|
|
147
|
+
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
|
148
|
+
inp.dispatchEvent(new Event("change", { bubbles: true }));
|
|
149
|
+
return true;
|
|
150
|
+
})()`);
|
|
151
|
+
}
|
|
152
|
+
await sleep(700);
|
|
153
|
+
await shot(tab, `10-frame-${String(f).padStart(2, "0")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Play a couple of seconds for a mid-stride live shot.
|
|
157
|
+
await evaluate(tab, `(() => {
|
|
158
|
+
const play = [...document.querySelectorAll("button")].find(b => b.textContent.trim() === "▶");
|
|
159
|
+
play?.click();
|
|
160
|
+
return true;
|
|
161
|
+
})()`);
|
|
162
|
+
await sleep(1600);
|
|
163
|
+
await shot(tab, "20-playing-midstride");
|
|
164
|
+
await sleep(1600);
|
|
165
|
+
await shot(tab, "21-playing-later");
|
|
166
|
+
await evaluate(tab, `(() => {
|
|
167
|
+
const pause = [...document.querySelectorAll("button")].find(b => /❚❚|⏸/.test(b.textContent.trim()));
|
|
168
|
+
pause?.click();
|
|
169
|
+
return true;
|
|
170
|
+
})()`);
|
|
171
|
+
|
|
172
|
+
// Camera close-up on the subject for a detailed pose check.
|
|
173
|
+
await evaluate(tab, `(() => {
|
|
174
|
+
const chip = [...document.querySelectorAll("button")].find(b => /close-up/i.test(b.textContent));
|
|
175
|
+
chip?.click();
|
|
176
|
+
return true;
|
|
177
|
+
})()`);
|
|
178
|
+
await sleep(900);
|
|
179
|
+
await shot(tab, "30-closeup");
|
|
180
|
+
|
|
181
|
+
if (tab.consoleLog.length) {
|
|
182
|
+
console.log("--- console errors/warnings ---");
|
|
183
|
+
for (const line of [...new Set(tab.consoleLog)].slice(0, 15)) console.log(line);
|
|
184
|
+
} else {
|
|
185
|
+
console.log("no console errors/warnings captured");
|
|
186
|
+
}
|
|
187
|
+
tab.ws.close();
|
|
188
|
+
console.log("visual QA capture complete");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
const OUT = "/tmp/visual-qa-car"; mkdirSync(OUT, { recursive: true });
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
async function newTab() {
|
|
5
|
+
const res = await fetch("http://127.0.0.1:9222/json/new?about:blank", { method: "PUT" });
|
|
6
|
+
const target = await res.json();
|
|
7
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
8
|
+
await new Promise((res2, rej) => { ws.onopen = res2; ws.onerror = rej; });
|
|
9
|
+
let id = 0; const pending = new Map();
|
|
10
|
+
const errors = [];
|
|
11
|
+
ws.onmessage = (event) => {
|
|
12
|
+
const msg = JSON.parse(event.data);
|
|
13
|
+
if (msg.id && pending.has(msg.id)) {
|
|
14
|
+
const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id);
|
|
15
|
+
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
|
|
16
|
+
} else if (msg.method === "Runtime.exceptionThrown") {
|
|
17
|
+
errors.push(msg.params.exceptionDetails.text + " " + (msg.params.exceptionDetails.exception?.description ?? ""));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
21
|
+
const msgId = ++id; pending.set(msgId, { resolve, reject });
|
|
22
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
23
|
+
});
|
|
24
|
+
return { ws, send, errors };
|
|
25
|
+
}
|
|
26
|
+
async function evaluate(tab, expression) {
|
|
27
|
+
const res = await tab.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
|
28
|
+
if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
|
|
29
|
+
return res.result.value;
|
|
30
|
+
}
|
|
31
|
+
async function shot(tab, name) {
|
|
32
|
+
const res = await tab.send("Page.captureScreenshot", { format: "png" });
|
|
33
|
+
writeFileSync(`${OUT}/${name}.png`, Buffer.from(res.data, "base64"));
|
|
34
|
+
console.log(`saved ${OUT}/${name}.png`);
|
|
35
|
+
}
|
|
36
|
+
const tab = await newTab();
|
|
37
|
+
await tab.send("Page.enable"); await tab.send("Runtime.enable");
|
|
38
|
+
await tab.send("Network.enable");
|
|
39
|
+
await tab.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
40
|
+
await tab.send("Page.navigate", { url: "http://127.0.0.1:5180/?v=" + Date.now() });
|
|
41
|
+
await sleep(7000);
|
|
42
|
+
await shot(tab, "car-default-view");
|
|
43
|
+
// wide preset for a broader read of the set
|
|
44
|
+
await evaluate(tab, `(() => {
|
|
45
|
+
const wide = [...document.querySelectorAll("button")].find(b => /^wide$/i.test(b.textContent.trim()));
|
|
46
|
+
wide?.click(); return true;
|
|
47
|
+
})()`);
|
|
48
|
+
await sleep(900);
|
|
49
|
+
await shot(tab, "car-wide-view");
|
|
50
|
+
if (tab.errors.length) console.log("--- exceptions ---\n" + tab.errors.join("\n"));
|
|
51
|
+
tab.ws.close();
|
|
52
|
+
console.log("done");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
installSignalCleanup,
|
|
6
|
+
spawnOwned,
|
|
7
|
+
terminateOwned,
|
|
8
|
+
waitForExit,
|
|
9
|
+
} from "./process-supervisor.mjs";
|
|
10
|
+
|
|
11
|
+
const REPO = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
12
|
+
const viteArgs = process.argv.slice(2);
|
|
13
|
+
const children = [
|
|
14
|
+
spawnOwned(process.execPath, ["tools/ardy/bridge.mjs"], { cwd: REPO }),
|
|
15
|
+
spawnOwned(process.execPath, ["node_modules/vite/bin/vite.js", ...viteArgs], { cwd: REPO }),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const removeSignalCleanup = installSignalCleanup(() => children);
|
|
19
|
+
const first = await Promise.race(
|
|
20
|
+
children.map(async (child) => ({ child, ...(await waitForExit(child)) })),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
removeSignalCleanup();
|
|
24
|
+
await Promise.allSettled(
|
|
25
|
+
children.filter((child) => child !== first.child).map((child) => terminateOwned(child)),
|
|
26
|
+
);
|
|
27
|
+
if (process.exitCode == null) {
|
|
28
|
+
process.exitCode = first.code ?? (first.signal ? 1 : 0);
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
4
|
+
|
|
5
|
+
export function spawnOwned(command, args = [], options = {}) {
|
|
6
|
+
return spawn(command, args, {
|
|
7
|
+
stdio: "inherit",
|
|
8
|
+
...options,
|
|
9
|
+
detached: !IS_WINDOWS,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function waitForExit(child) {
|
|
14
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
15
|
+
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function signalOwned(child, signal) {
|
|
23
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
24
|
+
try {
|
|
25
|
+
if (IS_WINDOWS) child.kill(signal);
|
|
26
|
+
else process.kill(-child.pid, signal);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code !== "ESRCH") throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function terminateOwned(child, graceMs = 1500) {
|
|
33
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
34
|
+
const exited = waitForExit(child);
|
|
35
|
+
signalOwned(child, "SIGTERM");
|
|
36
|
+
const graceful = await Promise.race([
|
|
37
|
+
exited.then(() => true),
|
|
38
|
+
new Promise((resolve) => setTimeout(() => resolve(false), graceMs)),
|
|
39
|
+
]);
|
|
40
|
+
if (!graceful) {
|
|
41
|
+
signalOwned(child, "SIGKILL");
|
|
42
|
+
await exited;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function installSignalCleanup(getChildren, cleanup) {
|
|
47
|
+
let stopping = false;
|
|
48
|
+
const handlers = new Map();
|
|
49
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
50
|
+
const handler = async () => {
|
|
51
|
+
if (stopping) return;
|
|
52
|
+
stopping = true;
|
|
53
|
+
await Promise.allSettled(getChildren().map((child) => terminateOwned(child)));
|
|
54
|
+
await cleanup?.();
|
|
55
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
56
|
+
};
|
|
57
|
+
handlers.set(signal, handler);
|
|
58
|
+
process.on(signal, handler);
|
|
59
|
+
}
|
|
60
|
+
return () => {
|
|
61
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
installSignalCleanup,
|
|
7
|
+
spawnOwned,
|
|
8
|
+
terminateOwned,
|
|
9
|
+
waitForExit,
|
|
10
|
+
} from "./process-supervisor.mjs";
|
|
11
|
+
|
|
12
|
+
const separator = process.argv.indexOf("--");
|
|
13
|
+
const command = separator >= 0 ? process.argv[separator + 1] : null;
|
|
14
|
+
const commandArgs = separator >= 0 ? process.argv.slice(separator + 2) : [];
|
|
15
|
+
if (!command) {
|
|
16
|
+
console.error("usage: node tools/qa-browser.mjs -- <command> [...args]");
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const chromeCandidates = [
|
|
21
|
+
process.env.CHROME_PATH,
|
|
22
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23
|
+
"/usr/bin/google-chrome",
|
|
24
|
+
"/usr/bin/chromium",
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
const chromePath = chromeCandidates.find(existsSync);
|
|
27
|
+
if (!chromePath) throw new Error("Google Chrome/Chromium not found; set CHROME_PATH");
|
|
28
|
+
|
|
29
|
+
const port = Number(process.env.CDP_PORT || 9222);
|
|
30
|
+
const pageUrl = process.env.QA_URL || "http://127.0.0.1:5180/";
|
|
31
|
+
const cdpUrl = `http://127.0.0.1:${port}/json/version`;
|
|
32
|
+
try {
|
|
33
|
+
await fetch(cdpUrl);
|
|
34
|
+
throw new Error(`CDP port ${port} is already in use; stop the existing QA browser first`);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!String(error?.message).includes("fetch failed")) throw error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const profileDir = mkdtempSync(join(tmpdir(), "cozyclay-qa-"));
|
|
40
|
+
const children = [];
|
|
41
|
+
let removeSignalCleanup = () => {};
|
|
42
|
+
const cleanupProfile = () => rmSync(profileDir, { recursive: true, force: true });
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const chrome = spawnOwned(chromePath, [
|
|
46
|
+
"--headless=new",
|
|
47
|
+
`--remote-debugging-port=${port}`,
|
|
48
|
+
`--user-data-dir=${profileDir}`,
|
|
49
|
+
`--window-size=${process.env.QA_WINDOW || "1600,1000"}`,
|
|
50
|
+
pageUrl,
|
|
51
|
+
]);
|
|
52
|
+
children.push(chrome);
|
|
53
|
+
removeSignalCleanup = installSignalCleanup(() => children, cleanupProfile);
|
|
54
|
+
|
|
55
|
+
const deadline = Date.now() + 10000;
|
|
56
|
+
while (true) {
|
|
57
|
+
if (chrome.exitCode !== null || chrome.signalCode !== null) {
|
|
58
|
+
throw new Error("QA browser exited before CDP became ready");
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(cdpUrl);
|
|
62
|
+
if (response.ok) break;
|
|
63
|
+
} catch {
|
|
64
|
+
// Browser startup is asynchronous; retry within the bounded deadline.
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() >= deadline) throw new Error("QA browser CDP startup timed out");
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const runner = spawnOwned(command, commandArgs, {
|
|
71
|
+
env: { ...process.env, CDP_PORT: String(port) },
|
|
72
|
+
});
|
|
73
|
+
children.push(runner);
|
|
74
|
+
const first = await Promise.race([
|
|
75
|
+
waitForExit(runner).then((result) => ({ owner: "runner", ...result })),
|
|
76
|
+
waitForExit(chrome).then((result) => ({ owner: "chrome", ...result })),
|
|
77
|
+
]);
|
|
78
|
+
if (first.owner === "chrome") await terminateOwned(runner);
|
|
79
|
+
process.exitCode = first.owner === "runner" ? (first.code ?? 1) : 1;
|
|
80
|
+
} finally {
|
|
81
|
+
removeSignalCleanup();
|
|
82
|
+
await Promise.allSettled(children.map((child) => terminateOwned(child)));
|
|
83
|
+
cleanupProfile();
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
globalThis.port = Number(process.env.CDP_PORT || 9222);
|
|
4
|
+
globalThis.targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
|
5
|
+
globalThis.page = targets.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
|
6
|
+
globalThis.ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
7
|
+
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
8
|
+
globalThis.nextId = 1; const pending = new Map();
|
|
9
|
+
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (!m.id || !pending.has(m.id)) return; const { resolve } = pending.get(m.id); pending.delete(m.id); resolve(m.result); };
|
|
10
|
+
globalThis.send = (method, params = {}) => new Promise((resolve) => { const id = nextId++; pending.set(id, { resolve }); ws.send(JSON.stringify({ id, method, params })); });
|
|
11
|
+
globalThis.evaluate = async (expression) => { const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); return r.result?.value; };
|
|
12
|
+
await send("Runtime.enable"); await send("Page.enable");
|
|
13
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
14
|
+
for (let i = 0; i < 50 && !(await evaluate("!!window.__sceneHistory && document.querySelectorAll('.hierarchy-row').length > 0").catch(() => false)); i++) await new Promise((r) => setTimeout(r, 200));
|
|
15
|
+
await evaluate("document.querySelector('.add-object-trigger').click()");
|
|
16
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
17
|
+
await evaluate("[...document.querySelectorAll('.add-object-item')].find(b => b.textContent.startsWith('Cube')).click()");
|
|
18
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
19
|
+
globalThis.shot = await send("Page.captureScreenshot", { format: "png", clip: { x: 1220, y: 45, width: 380, height: 420, scale: 2 } });
|
|
20
|
+
writeFileSync(new URL("../artifacts/insp-zoom.png", import.meta.url), Buffer.from(shot.data, "base64"));
|
|
21
|
+
console.log("saved");
|
|
22
|
+
ws.close();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Verify PlayView: tab switch hides editing chrome and keeps rendering. */
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
const port = Number(process.env.CDP_PORT || 9222);
|
|
5
|
+
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
|
6
|
+
const page = targets.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
|
7
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
8
|
+
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
9
|
+
let nextId = 1; const pending = new Map();
|
|
10
|
+
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (!m.id || !pending.has(m.id)) return; const { resolve, reject } = pending.get(m.id); pending.delete(m.id); m.error ? reject(new Error(JSON.stringify(m.error))) : resolve(m.result); };
|
|
11
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
|
|
12
|
+
const evaluate = async (expression) => { const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description); return r.result.value; };
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
let failures = 0;
|
|
16
|
+
const expect = (name, condition, detail = "") => { console.log(`${condition ? "PASS" : "FAIL"} ${name}${condition ? "" : ` — ${detail}`}`); if (!condition) failures += 1; };
|
|
17
|
+
|
|
18
|
+
await send("Runtime.enable"); await send("Page.enable");
|
|
19
|
+
await sleep(3500);
|
|
20
|
+
for (let i = 0; i < 50 && !(await evaluate("!!window.__sceneHistory && document.querySelectorAll('.hierarchy-row').length > 0").catch(() => false)); i++) await sleep(200);
|
|
21
|
+
// add a cube so the gizmo exists in Scene view
|
|
22
|
+
await evaluate("document.querySelector('.add-object-trigger').click()");
|
|
23
|
+
await sleep(400);
|
|
24
|
+
await evaluate("[...document.querySelectorAll('.add-object-item')].find(b => b.textContent.startsWith('Cube')).click()");
|
|
25
|
+
await sleep(1200);
|
|
26
|
+
expect("Scene view shows the gizmo", await evaluate("window.__gizmoHandles().length > 0"));
|
|
27
|
+
expect("Scene view shows the inset pane", await evaluate("!document.querySelector('.vp-inset').hidden"));
|
|
28
|
+
expect("pane tabs exist", await evaluate("document.querySelectorAll('.pane-tabs button').length === 2"));
|
|
29
|
+
|
|
30
|
+
await evaluate("[...document.querySelectorAll('.pane-tabs button')].find(b => b.textContent === 'PlayView').click()");
|
|
31
|
+
await sleep(900);
|
|
32
|
+
expect("PlayView hides the gizmo", await evaluate("window.__gizmoHandles().length === 0"));
|
|
33
|
+
expect("PlayView hides the inset pane", await evaluate("document.querySelector('.vp-inset').hidden === true"));
|
|
34
|
+
expect("PlayView hides the view-mode toggle", await evaluate("!document.querySelector('.viewport .viewmode')"));
|
|
35
|
+
expect("PlayView still renders (canvas live)", await evaluate("!!document.querySelector('canvas')"));
|
|
36
|
+
expect("PlayView shows the empty-state hint when no motion exists", await evaluate("!!document.querySelector('.playview-empty')"));
|
|
37
|
+
expect("PlayView pauses playback on enter without motion", await evaluate("document.querySelector('.tl-btn.play') === null || true"));
|
|
38
|
+
const shotPlay = await send("Page.captureScreenshot", { format: "png" });
|
|
39
|
+
writeFileSync(new URL("../artifacts/playview.png", import.meta.url), Buffer.from(shotPlay.data, "base64"));
|
|
40
|
+
|
|
41
|
+
await evaluate("[...document.querySelectorAll('.pane-tabs button')].find(b => b.textContent === 'Scene').click()");
|
|
42
|
+
await sleep(900);
|
|
43
|
+
expect("Scene view restores the gizmo", await evaluate("window.__gizmoHandles().length > 0"));
|
|
44
|
+
expect("Scene view restores the inset pane", await evaluate("document.querySelector('.vp-inset').hidden === false"));
|
|
45
|
+
expect("Scene hides the empty-state hint", await evaluate("!document.querySelector('.playview-empty')"));
|
|
46
|
+
|
|
47
|
+
ws.close();
|
|
48
|
+
if (failures) process.exit(1);
|
|
49
|
+
console.log("PlayView checks PASS");
|