@shipvo/sim 0.1.4 → 0.1.5
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/browser-resolver.mjs +52 -0
- package/chromium-hint.mjs +19 -0
- package/frame.d.mts +37 -0
- package/frame.mjs +184 -0
- package/lifecycle.mjs +50 -0
- package/package.json +12 -1
- package/screen-rules.mjs +45 -0
- package/server.mjs +43 -43
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Picks the browser the simulator launches: system Chrome, system Edge, Playwright's cached Chromium,
|
|
2
|
+
// then a one-time `npx playwright install chromium`. Pure: fs and process access are injected.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const INSTALL_COMMAND = "npx playwright install chromium";
|
|
7
|
+
|
|
8
|
+
/** Where each Playwright channel puts its binary, per platform. */
|
|
9
|
+
export function systemBrowserPaths(channel, platform, env) {
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
|
|
12
|
+
const rel = channel === "chrome" ? "\\Google\\Chrome\\Application\\chrome.exe" : "\\Microsoft\\Edge\\Application\\msedge.exe";
|
|
13
|
+
return roots.map((r) => r + rel);
|
|
14
|
+
}
|
|
15
|
+
if (platform === "darwin") {
|
|
16
|
+
return channel === "chrome"
|
|
17
|
+
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
|
18
|
+
: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
|
|
19
|
+
}
|
|
20
|
+
return channel === "chrome"
|
|
21
|
+
? ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/chrome"]
|
|
22
|
+
: ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/opt/microsoft/msedge/msedge"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the options to pass to `chromium.launch`.
|
|
29
|
+
* Order: system Chrome, system Edge, Playwright cache, one-time install.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveBrowser({ pw, executablePath, env = process.env, fileExists = existsSync, run = defaultRun, platform = process.platform, log = (m) => console.error(m) }) {
|
|
32
|
+
if (executablePath) return { executablePath };
|
|
33
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
34
|
+
if (systemBrowserPaths(channel, platform, env).some((p) => fileExists(p))) return { channel };
|
|
35
|
+
}
|
|
36
|
+
let cached;
|
|
37
|
+
try { cached = pw.chromium.executablePath(); } catch { cached = undefined; }
|
|
38
|
+
if (cached && fileExists(cached)) return {};
|
|
39
|
+
if (env.SHIPVO_NO_AUTO_INSTALL === "1") {
|
|
40
|
+
throw new Error(`no browser found (Chrome, Edge or Playwright Chromium) and SHIPVO_NO_AUTO_INSTALL=1 is set. Run \`${INSTALL_COMMAND}\``);
|
|
41
|
+
}
|
|
42
|
+
log(`sim: no Chrome, Edge or Chromium found; running \`${INSTALL_COMMAND}\` once (this downloads a browser)...`);
|
|
43
|
+
const [cmd, ...args] = INSTALL_COMMAND.split(" ");
|
|
44
|
+
const res = run(cmd, args);
|
|
45
|
+
if (!res || res.status !== 0 || res.error) {
|
|
46
|
+
const why = res && res.error ? `: ${res.error.message}` : "";
|
|
47
|
+
throw new Error(`\`${INSTALL_COMMAND}\` failed${why}. Run it yourself and check the output`);
|
|
48
|
+
}
|
|
49
|
+
let after; try { after = pw.chromium.executablePath(); } catch { after = undefined; }
|
|
50
|
+
if (!after || !fileExists(after)) throw new Error(`\`${INSTALL_COMMAND}\` failed: it exited 0 but no browser was installed. Run it yourself and check the output`);
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The fix to print when Playwright cannot find the Chromium build it wants.
|
|
2
|
+
export const CHROMIUM_MISSING = /Executable doesn't exist/;
|
|
3
|
+
// Linux: the browser exists but shared libraries are missing.
|
|
4
|
+
export const LIBS_MISSING = /error while loading shared libraries|Host system is missing dependencies/i;
|
|
5
|
+
|
|
6
|
+
/** The one-line install command for the resolved playwright version, plus the Linux hint. */
|
|
7
|
+
export function chromiumInstallFix(version) {
|
|
8
|
+
const v = version ? ` (playwright ${version})` : "";
|
|
9
|
+
return `run \`npx playwright install chromium\` in the app folder${v}. On Linux, if Chromium then fails to start, run \`npx playwright install --with-deps chromium\` or install the system packages it lists.`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The message to print for a launch error. Returns the first line, plus the fix when the browser is not installed. */
|
|
13
|
+
export function launchErrorMessage(message, version) {
|
|
14
|
+
const first = String(message).split("\n")[0];
|
|
15
|
+
const base = `could not launch Chromium: ${first}`;
|
|
16
|
+
if (CHROMIUM_MISSING.test(message)) return `${base}\nFix: ${chromiumInstallFix(version)}`;
|
|
17
|
+
if (LIBS_MISSING.test(message)) return `${base}\nFix: system libraries are missing. Run \`npx playwright install-deps chromium\``;
|
|
18
|
+
return base;
|
|
19
|
+
}
|
package/frame.d.mts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type FrameSkin = "android" | "ios";
|
|
2
|
+
export type FrameStatus = {
|
|
3
|
+
/** Text shown on the left, for example "9:41". */
|
|
4
|
+
time?: string;
|
|
5
|
+
/** Bars filled, 0 to 4. Default 4. */
|
|
6
|
+
signal?: number;
|
|
7
|
+
/** Default true. */
|
|
8
|
+
wifi?: boolean;
|
|
9
|
+
/** Shows an airplane icon and hides signal and wifi. */
|
|
10
|
+
airplane?: boolean;
|
|
11
|
+
/** Level 0 to 100, or an object. Default 100. */
|
|
12
|
+
battery?: number | { level?: number; charging?: boolean };
|
|
13
|
+
};
|
|
14
|
+
export type FrameOptions = { skin?: FrameSkin; dark?: boolean; status?: FrameStatus; /** HTML for the screen area. */ screen?: string };
|
|
15
|
+
export type Frame = {
|
|
16
|
+
skin: FrameSkin;
|
|
17
|
+
/** Screen size in CSS pixels. */
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
/** Size of the whole body, bezel included. */
|
|
21
|
+
outerWidth: number;
|
|
22
|
+
outerHeight: number;
|
|
23
|
+
html: string;
|
|
24
|
+
css: string;
|
|
25
|
+
};
|
|
26
|
+
export const SKINS: Record<FrameSkin, { label: string; width: number; height: number; radius: number }>;
|
|
27
|
+
export function normalizeStatus(status?: FrameStatus): { time: string; signal: number; wifi: boolean; airplane: boolean; battery: { level: number; charging: boolean } };
|
|
28
|
+
export function renderFrame(options?: FrameOptions): Frame;
|
|
29
|
+
export type DeviceStatusInput = { online?: boolean; airplane?: boolean; battery?: number | { level?: number; charging?: boolean } };
|
|
30
|
+
/** Maps the simulated device state and a clock to a status object. */
|
|
31
|
+
export function statusFromDevice(device?: DeviceStatusInput, now?: Date): FrameStatus;
|
|
32
|
+
/** True when a CSS colour is dark, so the status text should be light. */
|
|
33
|
+
export function isDarkBackground(color: string): boolean;
|
|
34
|
+
/** The clock text and icons markup, for redrawing the status bar. */
|
|
35
|
+
export function statusParts(status?: FrameStatus): { time: string; icons: string };
|
|
36
|
+
/** Browser script that keeps the status bar live from the app's `pn:status` events. */
|
|
37
|
+
export function statusClientScript(iframeId?: string, skin?: FrameSkin): string;
|
package/frame.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The phone body: bezel, cutout, status bar and a slot for the screen. Pure: no DOM, no timers, no dependencies.
|
|
2
|
+
// Our own artwork. It does not copy any vendor's icons, fonts or exact device outlines.
|
|
3
|
+
// Scaling to fit the window is done by the page with a CSS transform on the element this markup sits in.
|
|
4
|
+
|
|
5
|
+
import { lockScreenHtml, bootScreenHtml, offScreenHtml } from "./lifecycle.mjs";
|
|
6
|
+
|
|
7
|
+
export const SKINS = {
|
|
8
|
+
android: { label: "Android", width: 412, height: 915, radius: 36, bezel: 10, status: 36, bar: 24, pad: 22, indicator: 108, part: "punch-hole", bottom: "gesture-bar" },
|
|
9
|
+
ios: { label: "iPhone", width: 393, height: 852, radius: 55, bezel: 12, status: 54, bar: 34, pad: 36, indicator: 134, part: "dynamic-island", bottom: "home-indicator" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const FOOT = 14; // extra bezel under the screen, holds the "Simulated" label
|
|
13
|
+
const clean = (s) => String(s).replace(/[<>&"'\\]/g, "").slice(0, 12);
|
|
14
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
15
|
+
|
|
16
|
+
/** Fills in anything missing from a status object. Never throws on odd input. */
|
|
17
|
+
export function normalizeStatus(status = {}) {
|
|
18
|
+
const s = status && typeof status === "object" ? status : {};
|
|
19
|
+
const bat = typeof s.battery === "number" ? { level: s.battery } : (s.battery && typeof s.battery === "object" ? s.battery : {});
|
|
20
|
+
const num = (v, d) => (Number.isFinite(Number(v)) && v !== null && v !== "" ? Number(v) : d);
|
|
21
|
+
return {
|
|
22
|
+
time: s.time == null ? "9:41" : clean(s.time),
|
|
23
|
+
signal: Math.round(clamp(num(s.signal, 4), 0, 4)),
|
|
24
|
+
wifi: s.wifi !== false,
|
|
25
|
+
airplane: s.airplane === true,
|
|
26
|
+
battery: { level: Math.round(clamp(num(bat.level, 100), 0, 100)), charging: bat.charging === true },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Maps the simulated device ({ online, airplane, battery } from @shipvo/device) and a clock to a status object. Network off shows no signal and no wifi. */
|
|
31
|
+
export function statusFromDevice(device = {}, now = new Date()) {
|
|
32
|
+
const d = device && typeof device === "object" ? device : {};
|
|
33
|
+
const online = d.online !== false && d.airplane !== true;
|
|
34
|
+
const h = now.getHours() % 12 || 12, m = String(now.getMinutes()).padStart(2, "0");
|
|
35
|
+
return { time: `${h}:${m}`, signal: online ? 4 : 0, wifi: online, airplane: d.airplane === true, battery: d.battery };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when a CSS colour (#rgb, #rrggbb, rgb(), rgba()) is dark, so the status text should be light. Unknown or transparent colours count as light. */
|
|
39
|
+
export function isDarkBackground(color) {
|
|
40
|
+
const c = String(color ?? "").trim().toLowerCase();
|
|
41
|
+
let r, g, b, a = 1, m;
|
|
42
|
+
if ((m = /^#([0-9a-f]{3})$/.exec(c))) [r, g, b] = [...m[1]].map((x) => parseInt(x + x, 16));
|
|
43
|
+
else if ((m = /^#([0-9a-f]{6})/.exec(c))) [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
|
|
44
|
+
else if ((m = /^rgba?\(\s*([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?/.exec(c))) { [r, g, b] = [+m[1], +m[2], +m[3]]; if (m[4] != null) a = m[4].endsWith("%") ? parseFloat(m[4]) / 100 : +m[4]; }
|
|
45
|
+
else return false;
|
|
46
|
+
return a >= 0.5 && 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The parts of the status bar that change: the clock text and the icons markup. */
|
|
50
|
+
export function statusParts(status) {
|
|
51
|
+
const st = normalizeStatus(status);
|
|
52
|
+
return { time: st.time, icons: icons(st) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Browser script for the page (online is shown as 4 bars; the simulated device has no signal strength) that holds the frame. It watches the app's "pn:status" events (sent by @shipvo/device when network,
|
|
57
|
+
* airplane or battery change), redraws the status bar, ticks the clock each minute and flips the text colour from the app's top background.
|
|
58
|
+
* `iframeId` is the app iframe. It expects the markup from renderFrame and the functions below inlined by this script.
|
|
59
|
+
*/
|
|
60
|
+
export function statusClientScript(iframeId = "app", skin = "android") {
|
|
61
|
+
return `(function(){
|
|
62
|
+
var statusParts=${statusParts.toString()},normalizeStatus=${normalizeStatus.toString()},icons=${icons.toString()},battery=${battery.toString()},svg=${svg.toString()},isDarkBackground=${isDarkBackground.toString()},statusFromDevice=${statusFromDevice.toString()};
|
|
63
|
+
var clean=${clean.toString()},clamp=${clamp.toString()};
|
|
64
|
+
var dev={online:true,airplane:false,battery:{level:100,charging:false}};
|
|
65
|
+
function root(){return document.querySelector('[data-part="phone-body"]')}
|
|
66
|
+
function draw(){var r=root();if(!r)return;var p=statusParts(statusFromDevice(dev,new Date()));
|
|
67
|
+
r.querySelector('[data-part="time"]').textContent=p.time;var lt=r.querySelector('[data-part="lock-time"]');if(lt)lt.textContent=p.time;r.querySelector(".sf-icons").innerHTML=p.icons}
|
|
68
|
+
function topColor(w){try{var d=w.document,e=d.elementFromPoint(Math.floor(w.innerWidth/2),1);
|
|
69
|
+
while(e){var c=w.getComputedStyle(e).backgroundColor;if(c&&c!=="transparent"&&!/rgba\\(.*,\\s*0\\)$/.test(c))return c;e=e.parentElement}var b=d.body&&w.getComputedStyle(d.body).backgroundColor;if(b&&!/rgba\\(.*,\\s*0\\)$/.test(b)&&b!=="transparent")return b}catch(x){}return"#fff"}
|
|
70
|
+
function theme(w){var r=root();if(!r)return;var c=topColor(w),dark=isDarkBackground(c);
|
|
71
|
+
r.setAttribute("data-theme",dark?"dark":"light");r.style.setProperty("--sf-status-fg",dark?"#fff":"#111");r.style.setProperty("--sf-status-bg",c)}
|
|
72
|
+
function hook(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;if(!w)return;
|
|
73
|
+
if(w.__pnDevice&&w.__pnDevice.status)dev=w.__pnDevice.status();draw();theme(w);
|
|
74
|
+
if(!w.__shipvoStatusHooked){w.__shipvoStatusHooked=true;w.addEventListener("pn:status",function(e){dev=e.detail;draw();theme(w)})}}
|
|
75
|
+
var f=document.getElementById(${JSON.stringify(iframeId)});if(f){f.addEventListener("load",hook);hook()}
|
|
76
|
+
(function buttons(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')},vol=50,hold,volT,bootT;
|
|
77
|
+
if(!q("power-button")||!q("lock-screen")||!q("power-menu"))return;
|
|
78
|
+
function show(el,on){if(el)el.hidden=!on}
|
|
79
|
+
function shown(p){var e=q(p);return !!e&&!e.hidden}
|
|
80
|
+
function lockNow(){show(q("power-menu"),false);show(q("lock-screen"),true);var u=q("unlock-button");if(u)u.focus()}
|
|
81
|
+
function unlockNow(){show(q("lock-screen"),false);var p=q("power-button");if(p)p.focus()}
|
|
82
|
+
function closeMenu(){if(shown("power-menu")){show(q("power-menu"),false);q("power-button").focus()}}
|
|
83
|
+
function powerOn(){show(q("off-screen"),false);show(q("boot-screen"),true);clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
|
|
84
|
+
function openMenu(){show(q("power-menu"),true);var b=q("power-menu").querySelector("button");if(b)b.focus()}
|
|
85
|
+
var sy=null,ls=q("lock-screen");
|
|
86
|
+
ls.addEventListener("pointerdown",function(e){sy=e.clientY});
|
|
87
|
+
ls.addEventListener("pointerup",function(e){if(sy!=null&&sy-e.clientY>60)unlockNow();sy=null});
|
|
88
|
+
ls.addEventListener("keydown",function(e){if(e.key==="ArrowUp"||e.key==="Enter"){e.preventDefault();unlockNow()}});
|
|
89
|
+
if(q("unlock-button"))q("unlock-button").addEventListener("click",unlockNow);
|
|
90
|
+
var pw=q("power-button");
|
|
91
|
+
function stopHold(){if(hold){clearTimeout(hold);hold=null}}
|
|
92
|
+
pw.addEventListener("pointerdown",function(){stopHold();hold=setTimeout(function(){hold=null;if(shown("off-screen"))return;openMenu();pw.setAttribute("data-held","1")},700)});
|
|
93
|
+
pw.addEventListener("pointerleave",stopHold);pw.addEventListener("pointercancel",stopHold);
|
|
94
|
+
pw.addEventListener("pointerup",stopHold);
|
|
95
|
+
pw.addEventListener("click",function(e){
|
|
96
|
+
if(pw.getAttribute("data-held")){pw.removeAttribute("data-held");return}
|
|
97
|
+
if(shown("off-screen")){powerOn();return}
|
|
98
|
+
if(e.detail===0&&e.shiftKey){shown("power-menu")?closeMenu():openMenu();return}
|
|
99
|
+
if(shown("power-menu")){closeMenu();return}
|
|
100
|
+
if(shown("lock-screen")){return}
|
|
101
|
+
lockNow()});
|
|
102
|
+
function bump(d){vol=Math.max(0,Math.min(100,vol+d));q("volume-level").textContent=vol;show(q("volume-overlay"),true);clearTimeout(volT);volT=setTimeout(function(){show(q("volume-overlay"),false)},1500)}
|
|
103
|
+
q("volume-up-button").addEventListener("click",function(){bump(10)});q("volume-down-button").addEventListener("click",function(){bump(-10)});
|
|
104
|
+
q("off-screen").addEventListener("click",powerOn);q("off-screen").addEventListener("keydown",function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();powerOn()}});
|
|
105
|
+
r.addEventListener("keydown",function(e){if(e.key==="Escape")closeMenu()});
|
|
106
|
+
document.addEventListener("pointerdown",function(e){if(shown("power-menu")&&!q("power-menu").contains(e.target)&&e.target!==pw)closeMenu()});
|
|
107
|
+
q("power-menu").addEventListener("click",function(e){var a=e.target.getAttribute&&e.target.getAttribute("data-act");if(!a)return;show(q("power-menu"),false);
|
|
108
|
+
if(a==="cancel"){pw.focus();return}
|
|
109
|
+
if(a==="restart"){show(q("boot-screen"),true);var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)f.contentWindow.location.reload();clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
|
|
110
|
+
else{show(q("lock-screen"),false);show(q("off-screen"),true);pw.focus()}})})();
|
|
111
|
+
(function tick(){draw();var n=new Date();setTimeout(tick,(60-n.getSeconds())*1000-n.getMilliseconds()+50)})();
|
|
112
|
+
// The app can change its top colour (a theme switch) without any event we can hear, so look again every 2 seconds. Skipped while the page is hidden.
|
|
113
|
+
setInterval(function(){if(document.hidden)return;var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)theme(f.contentWindow)},2000);
|
|
114
|
+
})();`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const svg = (w, h, body, extra = "") => `<svg class="sf-ico" ${extra} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true">${body}</svg>`;
|
|
118
|
+
|
|
119
|
+
function icons(st) {
|
|
120
|
+
if (st.airplane) {
|
|
121
|
+
return svg(16, 16, `<path d="M8 1.5 9.4 6.5 15 9.5v1.4l-5.6-1.5-.4 3 1.6 1.2v1L8 14.7 5.4 15.6v-1L7 13.4l-.4-3L1 10.9V9.5l5.6-3z" fill="currentColor"/>`, 'data-part="airplane"');
|
|
122
|
+
}
|
|
123
|
+
let bars = "";
|
|
124
|
+
for (let i = 0; i < 4; i++) bars += `<rect x="${i * 4}" y="${10 - i * 3}" width="3" height="${3 + i * 3}" rx="1" fill="currentColor"${i < st.signal ? "" : ' opacity=".3"'}/>`;
|
|
125
|
+
const signal = svg(15, 12, bars, `data-part="signal" data-level="${st.signal}"`);
|
|
126
|
+
const wifi = st.wifi
|
|
127
|
+
? svg(16, 12, `<path d="M1 4.5a10 10 0 0 1 14 0M3.5 7.2a6.4 6.4 0 0 1 9 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="8" cy="10" r="1.4" fill="currentColor"/>`, 'data-part="wifi"')
|
|
128
|
+
: "";
|
|
129
|
+
return signal + wifi + battery(st.battery);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function battery(b) {
|
|
133
|
+
const w = Math.round((b.level / 100) * 15);
|
|
134
|
+
const bolt = b.charging ? `<path d="M12 2 7.5 7.5h3L9 11l4.5-5.5h-3z" fill="#000" opacity=".75"/>` : "";
|
|
135
|
+
return svg(25, 12, `<rect x=".75" y=".75" width="21.5" height="10.5" rx="3" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".6"/><rect x="3.5" y="3.5" width="${w}" height="5" rx="1.5" fill="currentColor"/><rect x="23" y="4" width="2" height="4" rx="1" fill="currentColor" opacity=".6"/>${bolt}`, `data-part="battery" data-level="${b.level}"${b.charging ? ' data-charging="true"' : ""}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Renders the phone body.
|
|
140
|
+
* - skin: "android" (412x915, punch-hole camera) or "ios" (393x852, island cutout).
|
|
141
|
+
* - dark: true gives light status text on a dark bar, false gives dark text on a light bar.
|
|
142
|
+
* - status: { time, signal 0-4, wifi, airplane, battery (0-100, or { level, charging }) }.
|
|
143
|
+
* - screen: optional HTML placed in the screen area (the page puts its iframe there).
|
|
144
|
+
* Returns { skin, width, height, outerWidth, outerHeight, html, css }. width and height are the screen size.
|
|
145
|
+
*/
|
|
146
|
+
export function renderFrame({ skin = "android", dark = false, status, screen = "" } = {}) {
|
|
147
|
+
const k = SKINS[skin];
|
|
148
|
+
if (!k) throw new Error(`Unknown skin "${skin}". Use "android" or "ios".`);
|
|
149
|
+
const st = normalizeStatus(status);
|
|
150
|
+
const outerWidth = k.width + k.bezel * 2, outerHeight = k.height + k.bezel + k.bezel + FOOT;
|
|
151
|
+
const shape = skin === "ios"
|
|
152
|
+
? `<span class="sf-cut sf-island" data-part="dynamic-island"></span>`
|
|
153
|
+
: `<span class="sf-cut sf-hole" data-part="punch-hole"></span>`;
|
|
154
|
+
const html = `<div class="sf-body" data-skin="${skin}" data-theme="${dark ? "dark" : "light"}" data-part="phone-body">`
|
|
155
|
+
+ `<div class="sf-screen"><div class="sf-status" data-part="status-bar"><span class="sf-time" data-part="time">${st.time}</span>${shape}<span class="sf-icons">${icons(st)}</span></div>`
|
|
156
|
+
+ `<div class="sf-content">${screen}</div>`
|
|
157
|
+
+ lockScreenHtml(st.time) + bootScreenHtml() + offScreenHtml()
|
|
158
|
+
+ `<div class="sf-vol" data-part="volume-overlay" role="status" aria-live="polite" hidden><span data-part="volume-level">50</span></div><div class="sf-menu" data-part="power-menu" role="menu" aria-label="Power menu" hidden><button role="menuitem" data-act="restart">Restart</button><button role="menuitem" data-act="poweroff">Power off</button><button role="menuitem" data-act="cancel">Cancel</button></div>`
|
|
159
|
+
+ `<div class="sf-bar" data-part="${k.bottom}"><i></i></div></div>`
|
|
160
|
+
+ `<button class="sf-btn sf-power" data-part="power-button" aria-label="Power"></button><button class="sf-btn sf-volup" data-part="volume-up-button" aria-label="Volume up"></button><button class="sf-btn sf-voldown" data-part="volume-down-button" aria-label="Volume down"></button>`
|
|
161
|
+
+ `<span class="sf-label" data-part="simulated-label">Simulated</span></div>`;
|
|
162
|
+
const css = [
|
|
163
|
+
`.sf-body{--sf-bg:${dark ? "#000" : "#fff"};--sf-fg:${dark ? "#fff" : "#111"};position:relative;box-sizing:border-box;width:${outerWidth}px;height:${outerHeight}px;padding:${k.bezel}px ${k.bezel}px ${k.bezel + FOOT}px;`
|
|
164
|
+
+ `border-radius:${k.radius + k.bezel}px;background:linear-gradient(145deg,#3a3d42,#15171a);box-shadow:0 0 0 1px #000,0 10px 34px #0007;touch-action:manipulation;user-select:none;-webkit-user-select:none}`,
|
|
165
|
+
`.sf-screen{width:${k.width}px;height:${k.height}px;border-radius:${k.radius}px;background:var(--sf-bg);color:var(--sf-fg);overflow:hidden;display:flex;flex-direction:column;position:relative}`,
|
|
166
|
+
`.sf-status{flex:none;height:${k.status}px;display:flex;justify-content:space-between;align-items:center;padding:0 ${k.pad}px;font:600 14px system-ui,sans-serif;position:relative;z-index:2;color:var(--sf-status-fg,var(--sf-fg));background:var(--sf-status-bg,transparent)}`,
|
|
167
|
+
`.sf-icons{display:flex;align-items:center;gap:5px}.sf-ico{display:block}`,
|
|
168
|
+
`.sf-cut{position:absolute;left:50%;background:#000}`,
|
|
169
|
+
`.sf-hole{top:10px;width:14px;height:14px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px #1c1e21}`,
|
|
170
|
+
`.sf-island{top:11px;width:122px;height:34px;margin-left:-61px;border-radius:20px}`,
|
|
171
|
+
`.sf-content{flex:1;min-height:0;position:relative;background:#fff}`,
|
|
172
|
+
`.sf-content iframe{border:0;background:#fff;width:100%;height:100%;display:block}`,
|
|
173
|
+
`.sf-bar{flex:none;height:${k.bar}px;display:flex;justify-content:center;align-items:center;background:var(--sf-bg)}`,
|
|
174
|
+
`.sf-bar i{display:block;width:${k.indicator}px;height:5px;border-radius:3px;background:var(--sf-fg);opacity:.85}`,
|
|
175
|
+
`.sf-btn{position:absolute;right:-5px;width:5px;padding:0;border:0;border-radius:0 2px 2px 0;background:#5b6068;box-shadow:0 0 0 1px #000;cursor:pointer}.sf-btn::before{content:"";position:absolute;top:-6px;bottom:-6px;left:-4px;right:-10px}.sf-btn:focus-visible,.sf-lock button:focus-visible,.sf-menu button:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}.sf-power{top:200px;height:80px}.sf-volup{top:310px;height:56px}.sf-voldown{top:376px;height:56px}`,
|
|
176
|
+
`.sf-lock,.sf-boot,.sf-off,.sf-menu,.sf-vol{position:absolute;z-index:5}.sf-lock[hidden],.sf-boot[hidden],.sf-off[hidden],.sf-menu[hidden],.sf-vol[hidden]{display:none}.sf-off{inset:0;z-index:7;background:#000;cursor:pointer}`,
|
|
177
|
+
`.sf-lock{inset:0;background:#10151c;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;font-family:system-ui,sans-serif}.sf-lock-time{font-size:64px;font-weight:300}.sf-lock-hint{font-size:14px;opacity:.7}.sf-lock button{margin-top:8px;padding:10px 28px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 15px system-ui,sans-serif;cursor:pointer}`,
|
|
178
|
+
`.sf-boot{inset:0;z-index:6;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;font:700 34px system-ui,sans-serif;letter-spacing:.04em}`,
|
|
179
|
+
`.sf-vol{top:80px;right:12px;padding:8px 14px;border-radius:14px;background:#222d;color:#fff;font:600 14px system-ui,sans-serif}`,
|
|
180
|
+
`.sf-menu{left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:16px;background:#222;box-shadow:0 8px 30px #0008}.sf-menu button{padding:10px 24px;border:0;border-radius:10px;background:#3a3d42;color:#fff;font:600 15px system-ui,sans-serif}`,
|
|
181
|
+
`.sf-label{position:absolute;left:0;right:0;bottom:3px;text-align:center;font:600 9px system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#9aa0a6}`,
|
|
182
|
+
].join("\n");
|
|
183
|
+
return { skin, width: k.width, height: k.height, outerWidth, outerHeight, html, css };
|
|
184
|
+
}
|
package/lifecycle.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Lock, unlock and restart for the simulated phone. Pure state: no DOM, no timers.
|
|
2
|
+
// The server keeps one of these per session and logs each step; the frame page draws the lock screen and boot screen.
|
|
3
|
+
|
|
4
|
+
/** Typed error for a lifecycle action that cannot run now. */
|
|
5
|
+
export class LifecycleError extends Error { constructor(code, message) { super(message); this.code = code; } }
|
|
6
|
+
|
|
7
|
+
/** A new phone state: unlocked, never restarted. `reloads` counts cold starts of the app (the first load is 1). */
|
|
8
|
+
export function createLifecycle() {
|
|
9
|
+
return { locked: false, reloads: 1, restarts: 0, log: [] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const note = (st, name, extra = {}) => { st.log.push({ at: Date.now(), name, source: "simulated", ...extra }); };
|
|
13
|
+
|
|
14
|
+
/** Power press. Locks an unlocked phone. Throws already_locked if it is locked. */
|
|
15
|
+
export function lock(st) {
|
|
16
|
+
if (st.locked) throw new LifecycleError("already_locked", "the phone is already locked: call unlock first");
|
|
17
|
+
st.locked = true; note(st, "lock"); return st;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Swipe up on the lock screen. Throws not_locked if the phone is unlocked. The app and its screen are untouched. */
|
|
21
|
+
export function unlock(st) {
|
|
22
|
+
if (!st.locked) throw new LifecycleError("not_locked", "the phone is not locked");
|
|
23
|
+
st.locked = false; note(st, "unlock"); return st;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Restart from the power menu: the app cold-starts (the caller reloads it), storage is kept, the phone ends unlocked. */
|
|
27
|
+
export function restart(st) {
|
|
28
|
+
st.locked = false; st.restarts++; st.reloads++; note(st, "restart", { reloads: st.reloads }); return st;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Throws a typed error when the phone is locked, so no input reaches the app behind the lock screen. */
|
|
32
|
+
export function assertUnlocked(st) {
|
|
33
|
+
if (st.locked) throw new LifecycleError("phone_locked", "the phone is locked, so nothing was sent to the app. Call unlock first.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Lock screen markup (time and a swipe hint). `time` is plain text such as "9:41". */
|
|
37
|
+
export function lockScreenHtml(time = "9:41") {
|
|
38
|
+
const t = String(time).replace(/[<>&"'\\]/g, "").slice(0, 12);
|
|
39
|
+
return `<div class="sf-lock" data-part="lock-screen" role="dialog" aria-label="Lock screen" tabindex="-1" hidden><div class="sf-lock-time" data-part="lock-time">${t}</div><div class="sf-lock-hint">Swipe up to unlock</div><button type="button" data-part="unlock-button">Unlock</button></div>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Boot screen markup: the Shipvo name on black. Shown for a moment during restart. */
|
|
43
|
+
export function bootScreenHtml() {
|
|
44
|
+
return `<div class="sf-boot" data-part="boot-screen" hidden><div class="sf-boot-logo">Shipvo</div></div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Screen shown after "Power off": black. A click on it, or the power button, turns the phone on again. */
|
|
48
|
+
export function offScreenHtml() {
|
|
49
|
+
return `<div class="sf-off" data-part="off-screen" role="button" tabindex="0" aria-label="Phone is off. Press to power on" hidden></div>`;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/sim",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "The Shipvo simulator: runs a Shipvo app's web build in a phone-sized headless browser and exposes it to agents (accessibility tree, tap/type/scroll, simulated device, mock backend). Used by @shipvo/mcp and `shipvo sim`.",
|
|
@@ -9,12 +9,23 @@
|
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
11
|
"./server.mjs": "./server.mjs",
|
|
12
|
+
"./screen-rules.mjs": "./screen-rules.mjs",
|
|
13
|
+
"./frame.mjs": {
|
|
14
|
+
"types": "./frame.d.mts",
|
|
15
|
+
"default": "./frame.mjs"
|
|
16
|
+
},
|
|
12
17
|
"./package.json": "./package.json"
|
|
13
18
|
},
|
|
14
19
|
"files": [
|
|
15
20
|
"server.mjs",
|
|
21
|
+
"chromium-hint.mjs",
|
|
22
|
+
"browser-resolver.mjs",
|
|
16
23
|
"app-name.mjs",
|
|
24
|
+
"frame.mjs",
|
|
25
|
+
"lifecycle.mjs",
|
|
26
|
+
"frame.d.mts",
|
|
17
27
|
"mock-rules.mjs",
|
|
28
|
+
"screen-rules.mjs",
|
|
18
29
|
"push-notification.mjs",
|
|
19
30
|
"README.md"
|
|
20
31
|
],
|
package/screen-rules.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The rules that decide what is "on screen" for an agent. Plain ES module, no imports, so the simulator
|
|
2
|
+
// (`server.mjs`) and the `shipvo run` frame page share one copy. The DOM functions are self-contained:
|
|
3
|
+
// they call no other function, so they can be serialised and run inside a page with `(${fn})(document)`.
|
|
4
|
+
|
|
5
|
+
/** Remove the markers that `applyScreenRules` set. */
|
|
6
|
+
export function clearScreenRules(document) {
|
|
7
|
+
for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Call `clearScreenRules` first. Hide what a phone would not expose: content behind a modal sheet and off-screen items of a horizontal list. */
|
|
11
|
+
export function applyScreenRules(document) {
|
|
12
|
+
const win = document.defaultView;
|
|
13
|
+
// A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
|
|
14
|
+
const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
|
|
15
|
+
if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
|
|
16
|
+
for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
|
|
17
|
+
// Phones only expose the items of a horizontal list that are on screen.
|
|
18
|
+
for (const sc of document.querySelectorAll("div")) {
|
|
19
|
+
const cs = win.getComputedStyle(sc);
|
|
20
|
+
if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
|
|
21
|
+
const box = sc.getBoundingClientRect();
|
|
22
|
+
const row = sc.firstElementChild; if (!row) continue;
|
|
23
|
+
for (const item of row.children) {
|
|
24
|
+
const r = item.getBoundingClientRect();
|
|
25
|
+
if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Post-process an accessibility snapshot: drop empty generic lines, `aria-hidden` subtrees and the inner text of labelled controls. */
|
|
31
|
+
export function filterTree(tree) {
|
|
32
|
+
const lines = tree.split("\n").filter((l) => !/^\s*- generic \[ref=e\d+\]:?\s*$/.test(l));
|
|
33
|
+
// A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
|
|
34
|
+
// children into one element). Drop the inner text lines of labelled controls, as the phones do.
|
|
35
|
+
const out = []; let hideBelow = -1;
|
|
36
|
+
for (const l of lines) {
|
|
37
|
+
const indent = l.search(/\S/);
|
|
38
|
+
if (hideBelow >= 0 && indent > hideBelow) continue;
|
|
39
|
+
hideBelow = -1;
|
|
40
|
+
if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
|
|
41
|
+
out.push(l);
|
|
42
|
+
if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
|
|
43
|
+
}
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
package/server.mjs
CHANGED
|
@@ -13,11 +13,15 @@ import { parseMockRules } from "./mock-rules.mjs";
|
|
|
13
13
|
import { pushNotification } from "./push-notification.mjs";
|
|
14
14
|
import { createRequire } from "node:module";
|
|
15
15
|
import { execSync } from "node:child_process";
|
|
16
|
+
import { resolveBrowser } from "./browser-resolver.mjs";
|
|
17
|
+
import { launchErrorMessage } from "./chromium-hint.mjs";
|
|
18
|
+
import { createLifecycle, lock, unlock, restart, assertUnlocked, LifecycleError } from "./lifecycle.mjs";
|
|
19
|
+
import { applyScreenRules, clearScreenRules, filterTree } from "./screen-rules.mjs";
|
|
16
20
|
// Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
|
|
17
21
|
const require_ = createRequire(import.meta.url);
|
|
18
|
-
let pw;
|
|
19
|
-
for (const
|
|
20
|
-
try { pw =
|
|
22
|
+
let pw, pwVersion;
|
|
23
|
+
for (const from of [() => createRequire(join(process.cwd(), "package.json")), () => require_, () => { const root = execSync("npm root -g").toString().trim(); return (id) => require_(join(root, id)); }]) {
|
|
24
|
+
try { const r = from(); pw = r("playwright"); try { pwVersion = r("playwright/package.json").version; } catch {} break; } catch {}
|
|
21
25
|
}
|
|
22
26
|
if (!pw) { console.error("playwright is not installed: npm i -D playwright && npx playwright install chromium"); process.exit(2); }
|
|
23
27
|
const { chromium, devices } = pw;
|
|
@@ -46,11 +50,13 @@ const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "applica
|
|
|
46
50
|
const LAUNCH_TIMEOUT_MS = 60_000;
|
|
47
51
|
let launchBrowser;
|
|
48
52
|
const browserReady = new Promise((resolve, reject) => { launchBrowser = () => {
|
|
53
|
+
// Resolve first (it may download a browser, which can take longer than the launch timeout), then time the launch only.
|
|
54
|
+
let opts;
|
|
55
|
+
try { opts = resolveBrowser({ pw, executablePath: arg("chromium", undefined) }); } catch (e) { reject(e); return; }
|
|
49
56
|
const timer = setTimeout(() => reject(new Error(`Chromium did not start within ${LAUNCH_TIMEOUT_MS / 1000} s`)), LAUNCH_TIMEOUT_MS);
|
|
50
|
-
|
|
51
|
-
chromium.launch(executablePath ? { executablePath } : {}).then(resolve, reject).finally(() => clearTimeout(timer));
|
|
57
|
+
chromium.launch(opts).then(resolve, reject).finally(() => clearTimeout(timer));
|
|
52
58
|
}; });
|
|
53
|
-
browserReady.catch((e) => { console.error(`sim:
|
|
59
|
+
browserReady.catch((e) => { console.error(`sim: ${launchErrorMessage(e.message, pwVersion)}`); process.exit(1); });
|
|
54
60
|
const sessions = new Map();
|
|
55
61
|
|
|
56
62
|
const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
|
|
@@ -60,12 +66,16 @@ async function newSession(id, opts = {}) {
|
|
|
60
66
|
const target = await resolveApp(opts.app);
|
|
61
67
|
writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
|
|
62
68
|
const browser = await browserReady;
|
|
63
|
-
|
|
69
|
+
// Optional width, height (output pixels) and scale (device pixel ratio) make a session render at a store screenshot size.
|
|
70
|
+
const size = Number.isInteger(opts.width) && Number.isInteger(opts.height) && opts.width > 0 && opts.height > 0 ? { viewport: { width: Math.round(opts.width / (opts.scale || 1)), height: Math.round(opts.height / (opts.scale || 1)) }, deviceScaleFactor: opts.scale || 1 } : {};
|
|
71
|
+
const ctx = await browser.newContext({ ...phone, ...size, colorScheme: opts.dark ? "dark" : "light" });
|
|
64
72
|
await ctx.addInitScript((cfg) => { globalThis.__PN_SIM__ = cfg; }, { baseUrl: `http://localhost:${PORT}/s/${id}`, appId: target.app, appName: APP_NAME, seed_cart: opts.seed_cart ?? null, device: opts.device ?? null });
|
|
65
73
|
const page = await ctx.newPage();
|
|
74
|
+
const errors = [];
|
|
75
|
+
page.on("pageerror", (e) => errors.push(String(e?.message ?? e).split("\n")[0]));
|
|
66
76
|
await page.goto(target.url, { waitUntil: "networkidle" });
|
|
67
77
|
await page.waitForTimeout(300);
|
|
68
|
-
sessions.set(id, { ctx, page, app: target.app, release: target.release, startedAt: new Date().toISOString() });
|
|
78
|
+
sessions.set(id, { ctx, page, errors, app: target.app, release: target.release, startedAt: new Date().toISOString(), life: createLifecycle() });
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
// Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
|
|
@@ -82,43 +92,14 @@ class SimError extends Error { constructor(code, message) { super(message); this
|
|
|
82
92
|
async function screen(page) {
|
|
83
93
|
await page.waitForTimeout(150);
|
|
84
94
|
const epoch = await epochOf(page);
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
await page.evaluate(() => {
|
|
88
|
-
for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
|
|
89
|
-
// A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
|
|
90
|
-
const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
|
|
91
|
-
if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
|
|
92
|
-
for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
|
|
93
|
-
for (const sc of document.querySelectorAll("div")) {
|
|
94
|
-
const cs = getComputedStyle(sc);
|
|
95
|
-
if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
|
|
96
|
-
const box = sc.getBoundingClientRect();
|
|
97
|
-
const row = sc.firstElementChild; if (!row) continue;
|
|
98
|
-
for (const item of row.children) {
|
|
99
|
-
const r = item.getBoundingClientRect();
|
|
100
|
-
if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
});
|
|
95
|
+
// Hide what a phone would not expose, so an agent learns to swipe a rail exactly as it must on a device.
|
|
96
|
+
await page.evaluate(`(${clearScreenRules})(document); (${applyScreenRules})(document)`);
|
|
104
97
|
// What is hidden from assistive tech must not appear at all: this Playwright keeps aria-hidden nodes but strips
|
|
105
98
|
// their names. `inert` removes them from the accessibility tree; it is set only for the snapshot and removed after.
|
|
106
99
|
await page.evaluate(() => { for (const el of document.querySelectorAll('[aria-hidden="true"]')) { el.setAttribute("data-pn-prev-display", el.style.display || ""); el.style.setProperty("display", "none", "important"); } });
|
|
107
100
|
const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
|
|
108
101
|
await page.evaluate(() => { for (const el of document.querySelectorAll("[data-pn-prev-display]")) { const prev = el.getAttribute("data-pn-prev-display"); if (prev) el.style.display = prev; else el.style.removeProperty("display"); el.removeAttribute("data-pn-prev-display"); } });
|
|
109
|
-
const
|
|
110
|
-
// A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
|
|
111
|
-
// children into one element). Drop the inner text lines of labelled controls, as the phones do.
|
|
112
|
-
const out = []; let hideBelow = -1;
|
|
113
|
-
for (const l of lines) {
|
|
114
|
-
const indent = l.search(/\S/);
|
|
115
|
-
if (hideBelow >= 0 && indent > hideBelow) continue;
|
|
116
|
-
hideBelow = -1;
|
|
117
|
-
if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
|
|
118
|
-
out.push(l);
|
|
119
|
-
if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
|
|
120
|
-
}
|
|
121
|
-
const text = out.join("\n");
|
|
102
|
+
const text = filterTree(tree);
|
|
122
103
|
return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
|
|
123
104
|
}
|
|
124
105
|
|
|
@@ -157,7 +138,19 @@ async function act(id, body) {
|
|
|
157
138
|
});
|
|
158
139
|
if (covered) throw fail("covered", String(body.ref));
|
|
159
140
|
};
|
|
141
|
+
// A locked phone takes no input: say so with a typed error before anything reaches the app.
|
|
142
|
+
if (["tap", "type", "scroll", "back", "open_url", "open_notification"].includes(body.action)) { try { assertUnlocked(s.life); } catch (e) { throw new SimError(e.code, e.message); } }
|
|
160
143
|
switch (body.action) {
|
|
144
|
+
// Simulated lock, unlock and restart. Restart reloads the page (a cold start of the app); the browser context keeps storage.
|
|
145
|
+
case "lock": case "unlock": {
|
|
146
|
+
try { (body.action === "lock" ? lock : unlock)(s.life); } catch (e) { if (e instanceof LifecycleError) throw new SimError(e.code, e.message); throw e; }
|
|
147
|
+
return { locked: s.life.locked, screen: s.life.locked ? "Lock screen" : await screen(page) };
|
|
148
|
+
}
|
|
149
|
+
case "restart": {
|
|
150
|
+
restart(s.life);
|
|
151
|
+
await page.reload({ waitUntil: "networkidle" }); await page.waitForTimeout(300);
|
|
152
|
+
return { reloads: s.life.reloads, restarts: s.life.restarts, screen: await screen(page) };
|
|
153
|
+
}
|
|
161
154
|
case "screen": break;
|
|
162
155
|
case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
|
|
163
156
|
case "type": {
|
|
@@ -211,7 +204,7 @@ async function act(id, body) {
|
|
|
211
204
|
}
|
|
212
205
|
break;
|
|
213
206
|
}
|
|
214
|
-
case "device_log": return { log: await page.evaluate(() => globalThis.__pnDevice?.log() ?? []), capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
|
|
207
|
+
case "device_log": return { log: [...(await page.evaluate(() => globalThis.__pnDevice?.log() ?? [])), ...s.life.log], capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
|
|
215
208
|
case "diag": {
|
|
216
209
|
const r = await page.evaluate(async () => {
|
|
217
210
|
const cfg = globalThis.__PN_SIM__ ?? {}; const out = { baseUrl: cfg.baseUrl, origin: location.origin };
|
|
@@ -222,9 +215,16 @@ async function act(id, body) {
|
|
|
222
215
|
return { diag: r, screen: await screen(page) };
|
|
223
216
|
}
|
|
224
217
|
case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
|
|
218
|
+
// Uncaught page errors since the last call to this action. Reading them clears the list.
|
|
219
|
+
case "errors": return { errors: s.errors.splice(0), screen: await screen(page) };
|
|
225
220
|
case "back": { const b = page.getByRole("button", { name: "Back", exact: true }); if (await b.count()) { await ready(b.first()); await b.first().click(); } else throw new Error("no Back button on this screen"); break; }
|
|
226
|
-
case "screenshot": {
|
|
227
|
-
|
|
221
|
+
case "screenshot": {
|
|
222
|
+
// format "jpeg" has no alpha channel, which the stores require. inline: true also returns the bytes as base64 for clients on another machine.
|
|
223
|
+
const jpeg = body.format === "jpeg"; const p = join(DIR, `${id}-${Date.now()}.${jpeg ? "jpg" : "png"}`);
|
|
224
|
+
const buf = await page.screenshot(jpeg ? { path: p, type: "jpeg", quality: 90 } : { path: p });
|
|
225
|
+
return { path: p, ...(body.inline ? { data: buf.toString("base64") } : {}), screen: await screen(page) };
|
|
226
|
+
}
|
|
227
|
+
default: throw new Error(`unknown action '${body.action}'. Actions: screen, lock, unlock, restart, tap, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, errors, device_log, device_reset, set_network_mocks`);
|
|
228
228
|
}
|
|
229
229
|
return { ...(["tap", "scroll", "back"].includes(body.action) ? { delivery: "delivered" } : {}), screen: await screen(page) };
|
|
230
230
|
}
|