@shipvo/cli 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/dist/agents.d.ts +14 -0
- package/dist/agents.js +124 -0
- package/dist/audit.d.ts +13 -0
- package/dist/audit.js +75 -0
- package/dist/ci.d.ts +3 -0
- package/dist/ci.js +42 -0
- package/dist/cli.js +28 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +25 -10
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +25 -1
- package/dist/eas.d.ts +29 -0
- package/dist/eas.js +258 -0
- package/dist/ensure-playwright.d.ts +21 -0
- package/dist/ensure-playwright.js +64 -0
- package/dist/explore.d.ts +84 -0
- package/dist/explore.js +247 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +24 -3
- package/dist/init.js +31 -14
- package/dist/policy.d.ts +2 -16
- package/dist/policy.js +2 -18
- package/dist/preview-recorder.d.ts +1 -0
- package/dist/preview-recorder.js +62 -0
- package/dist/preview.d.ts +3 -3
- package/dist/preview.js +110 -13
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +29 -2
- package/dist/publish.d.ts +1 -1
- package/dist/publish.js +12 -1
- package/dist/report-md.d.ts +26 -0
- package/dist/report-md.js +58 -0
- package/dist/run-bridge.d.ts +7 -0
- package/dist/run-bridge.js +374 -0
- package/dist/run-protocol.d.ts +32 -0
- package/dist/run-protocol.js +22 -0
- package/dist/run-ws.d.ts +8 -0
- package/dist/run-ws.js +89 -0
- package/dist/run.d.ts +86 -0
- package/dist/run.js +301 -0
- package/dist/sim.d.ts +4 -0
- package/dist/sim.js +19 -5
- package/dist/status.d.ts +48 -0
- package/dist/status.js +97 -4
- package/dist/store.d.ts +36 -0
- package/dist/store.js +266 -3
- package/dist/templates/capacitor/github-actions/build.yml +122 -0
- package/dist/test.d.ts +46 -0
- package/dist/test.js +190 -0
- package/package.json +6 -4
package/dist/publish.js
CHANGED
|
@@ -74,7 +74,7 @@ function localKv(dir) {
|
|
|
74
74
|
},
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "out"];
|
|
77
|
+
const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "dry-run-policy", "out"];
|
|
78
78
|
export const PUBLISH_HELP = `Usage: shipvo publish [flags]
|
|
79
79
|
|
|
80
80
|
Build, sign and publish an over-the-air release.
|
|
@@ -89,6 +89,7 @@ Flags (write a value after a space, not =; on/off flags can be bare, or true/fal
|
|
|
89
89
|
--skip-export Reuse the last build in dist-ota/
|
|
90
90
|
--skip-web Do not publish the web copy for the simulator
|
|
91
91
|
--dry-run Build and sign, write files locally, send nothing
|
|
92
|
+
--dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing
|
|
92
93
|
--out <dir> Where --dry-run writes files (default .shipvo/dry-run)
|
|
93
94
|
--help Show this help
|
|
94
95
|
`;
|
|
@@ -99,6 +100,16 @@ export async function publish(argv) {
|
|
|
99
100
|
}
|
|
100
101
|
checkFlags(argv, PUBLISH_FLAGS);
|
|
101
102
|
const a = parse(argv), cfg = loadConfig(), SEED = seed();
|
|
103
|
+
if (switchFlag(a, "dry-run-policy")) {
|
|
104
|
+
const verdict = checkPublishPolicy(cfg.policy, (a.bands || "b0,b1,b2").split(","));
|
|
105
|
+
if (verdict.ok)
|
|
106
|
+
console.log(cfg.policy === undefined ? "No policy in shipvo.json: nothing to check." : "Policy allows this publish.");
|
|
107
|
+
else {
|
|
108
|
+
console.error(`Refused by policy rule ${verdict.rule}: ${verdict.reason}`);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
102
113
|
const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
|
|
103
114
|
if (a.out !== undefined && !dry)
|
|
104
115
|
throw new Error("--out only works with --dry-run");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type Step = {
|
|
2
|
+
kind?: string;
|
|
3
|
+
target?: string;
|
|
4
|
+
text?: string;
|
|
5
|
+
enter?: boolean;
|
|
6
|
+
line?: number;
|
|
7
|
+
};
|
|
8
|
+
type Flow = {
|
|
9
|
+
name?: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
steps?: Step[];
|
|
12
|
+
failedStep?: Step | null;
|
|
13
|
+
message?: string | null;
|
|
14
|
+
screenshots?: string[];
|
|
15
|
+
screenshot?: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type TestReport = {
|
|
18
|
+
app?: string | null;
|
|
19
|
+
commit?: string | null;
|
|
20
|
+
release?: string | null;
|
|
21
|
+
time?: string | null;
|
|
22
|
+
parityLimits?: string[];
|
|
23
|
+
flows?: Flow[];
|
|
24
|
+
};
|
|
25
|
+
export declare function renderReportMarkdown(report: TestReport): string;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const one = (s) => s.replace(/\s+/g, " ").trim();
|
|
2
|
+
/** Inline code that survives backticks in the text. */
|
|
3
|
+
function code(s) {
|
|
4
|
+
const t = one(s);
|
|
5
|
+
const fence = "`".repeat(Math.max(0, ...[...t.matchAll(/`+/g)].map((m) => m[0].length)) + 1);
|
|
6
|
+
return `${fence}${t.startsWith("`") || t.endsWith("`") ? ` ${t} ` : t}${fence}`;
|
|
7
|
+
}
|
|
8
|
+
function stepText(s) {
|
|
9
|
+
const kind = s.kind ?? "step";
|
|
10
|
+
if (kind === "tap")
|
|
11
|
+
return `tap "${s.target ?? ""}"`;
|
|
12
|
+
if (kind === "type")
|
|
13
|
+
return `type "${s.text ?? ""}" into "${s.target ?? ""}"${s.enter ? " and press enter" : ""}`;
|
|
14
|
+
const arg = s.text ?? s.target;
|
|
15
|
+
return arg !== undefined ? `${kind} "${arg}"` : kind;
|
|
16
|
+
}
|
|
17
|
+
function block(s) {
|
|
18
|
+
const fence = "`".repeat(Math.max(2, ...[...s.matchAll(/`+/g)].map((m) => m[0].length)) + 1);
|
|
19
|
+
return `${fence}\n${s}\n${fence}`;
|
|
20
|
+
}
|
|
21
|
+
export function renderReportMarkdown(report) {
|
|
22
|
+
const flows = Array.isArray(report.flows) ? report.flows : [];
|
|
23
|
+
const failed = flows.filter((f) => f.status === "failed");
|
|
24
|
+
const out = [`# Shipvo test report: ${failed.length ? "failed" : "all flows passed"}`, ""];
|
|
25
|
+
const meta = [];
|
|
26
|
+
if (report.app)
|
|
27
|
+
meta.push(`- App: ${code(report.app)}`);
|
|
28
|
+
meta.push(`- Release: ${report.release ? code(report.release) : "not set"}`);
|
|
29
|
+
meta.push(`- Commit: ${report.commit ? code(report.commit) : "unknown"}`);
|
|
30
|
+
if (report.time)
|
|
31
|
+
meta.push(`- Time: ${report.time}`);
|
|
32
|
+
meta.push(`- Flows: ${flows.length - failed.length} passed, ${failed.length} failed`);
|
|
33
|
+
out.push(...meta, "");
|
|
34
|
+
if (!failed.length)
|
|
35
|
+
out.push(flows.length ? `All ${flows.length} flow${flows.length === 1 ? "" : "s"} passed.` : "No flows were run.", "");
|
|
36
|
+
for (const f of failed) {
|
|
37
|
+
out.push(`## Failing flow: ${code(f.name ?? "unnamed flow")}`, "");
|
|
38
|
+
const bad = f.failedStep;
|
|
39
|
+
if (bad)
|
|
40
|
+
out.push(`Failed step${bad.line ? ` (line ${bad.line})` : ""}: ${code(stepText(bad))}`, "");
|
|
41
|
+
if (f.message)
|
|
42
|
+
out.push("Error:", "", block(f.message), "");
|
|
43
|
+
const shots = [...(f.screenshots ?? []), ...(f.screenshot ? [f.screenshot] : [])];
|
|
44
|
+
if (shots.length)
|
|
45
|
+
out.push(`Screenshots: ${shots.map(code).join(", ")}`, "");
|
|
46
|
+
if (f.steps?.length) {
|
|
47
|
+
out.push("Steps:", "");
|
|
48
|
+
f.steps.forEach((s, i) => {
|
|
49
|
+
const mark = bad && s.line !== undefined && s.line === bad.line ? " ← failed" : "";
|
|
50
|
+
out.push(`${i + 1}. ${code(stepText(s))}${mark}`);
|
|
51
|
+
});
|
|
52
|
+
out.push("");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (report.parityLimits?.length)
|
|
56
|
+
out.push("## Parity limits", "", ...report.parityLimits.map((l) => `- ${one(l)}`), "");
|
|
57
|
+
return out.join("\n");
|
|
58
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared screen rules (`@shipvo/sim`), as source that the page can run. Same copy the simulator uses.
|
|
3
|
+
* Only plain `export function` / `export const` lines are supported: `export default` and `export {…}` would not be stripped.
|
|
4
|
+
*/
|
|
5
|
+
export declare function screenRulesSource(): string;
|
|
6
|
+
/** The script text served at `/__shipvo/bridge.js`. It reads the session token from its own `data-token` attribute and then removes the attribute. */
|
|
7
|
+
export declare function bridgeScript(rules?: string): string;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// The page script of `shipvo run`. The frame page serves it at `/__shipvo/bridge.js`. It connects to the agent
|
|
2
|
+
// channel as role `screen` and answers `tree` requests from the same-origin app iframe. No Playwright: the tree is
|
|
3
|
+
// built from the DOM, in the shape of the simulator's `- role "name" [ref=eN]` lines (slice 3b of #448).
|
|
4
|
+
// Differences from the simulator tree are listed in docs/run-bridge.md.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
/**
|
|
8
|
+
* The shared screen rules (`@shipvo/sim`), as source that the page can run. Same copy the simulator uses.
|
|
9
|
+
* Only plain `export function` / `export const` lines are supported: `export default` and `export {…}` would not be stripped.
|
|
10
|
+
*/
|
|
11
|
+
export function screenRulesSource() {
|
|
12
|
+
const path = createRequire(import.meta.url).resolve("@shipvo/sim/screen-rules.mjs");
|
|
13
|
+
const src = readFileSync(path, "utf8");
|
|
14
|
+
if (/^export\s+(default\b|\{|\*)/m.test(src))
|
|
15
|
+
throw new Error("screen-rules.mjs may only use `export function` or `export const`");
|
|
16
|
+
return src.replace(/^export /gm, "");
|
|
17
|
+
}
|
|
18
|
+
/** Runs in the page. Everything it needs is nested inside, because it is serialised with `String(fn)`. */
|
|
19
|
+
function bridgeMain(win, token) {
|
|
20
|
+
const doc = win.document;
|
|
21
|
+
// A ref names one element with one role and name. If the element's content changes, it gets a new ref and the old one is stale.
|
|
22
|
+
const refs = new WeakMap();
|
|
23
|
+
const byRef = new Map();
|
|
24
|
+
let counter = 0;
|
|
25
|
+
const refOf = (el, sig) => {
|
|
26
|
+
let r = refs.get(el);
|
|
27
|
+
if (!r || r.sig !== sig) {
|
|
28
|
+
r = { n: ++counter, sig };
|
|
29
|
+
refs.set(el, r);
|
|
30
|
+
}
|
|
31
|
+
byRef.set("e" + r.n, { el, sig });
|
|
32
|
+
return "e" + r.n;
|
|
33
|
+
};
|
|
34
|
+
const INPUT_ROLE = { checkbox: "checkbox", radio: "radio", range: "slider", button: "button", submit: "button", reset: "button", image: "button", search: "searchbox", number: "spinbutton" };
|
|
35
|
+
const TAG_ROLE = { button: "button", textarea: "textbox", select: "combobox", img: "img", ul: "list", ol: "list", li: "listitem", nav: "navigation", main: "main", dialog: "dialog", form: "form", table: "table", tr: "row", td: "cell", th: "columnheader", p: "paragraph", progress: "progressbar", article: "article", aside: "complementary" };
|
|
36
|
+
const FROM_CONTENT = new Set(["button", "link", "heading", "tab", "checkbox", "radio", "switch", "menuitem", "option", "cell", "columnheader"]);
|
|
37
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
38
|
+
const hidden = (el, cs) => el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse";
|
|
39
|
+
function roleOf(el) {
|
|
40
|
+
const explicit = norm(el.getAttribute("role")).split(" ")[0];
|
|
41
|
+
if (explicit && explicit !== "presentation" && explicit !== "none")
|
|
42
|
+
return explicit;
|
|
43
|
+
const tag = el.tagName.toLowerCase();
|
|
44
|
+
if (tag === "a")
|
|
45
|
+
return el.hasAttribute("href") ? "link" : "";
|
|
46
|
+
if (/^h[1-6]$/.test(tag))
|
|
47
|
+
return "heading";
|
|
48
|
+
if (tag === "input") {
|
|
49
|
+
const t = (el.getAttribute("type") || "text").toLowerCase();
|
|
50
|
+
return t === "hidden" ? "" : INPUT_ROLE[t] || "textbox";
|
|
51
|
+
}
|
|
52
|
+
if (tag === "section")
|
|
53
|
+
return el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") ? "region" : "";
|
|
54
|
+
if (tag === "img" && el.getAttribute("alt") === "")
|
|
55
|
+
return "";
|
|
56
|
+
return TAG_ROLE[tag] || "";
|
|
57
|
+
}
|
|
58
|
+
function textOf(node) {
|
|
59
|
+
if (node.nodeType === 3)
|
|
60
|
+
return node.nodeValue || "";
|
|
61
|
+
if (node.nodeType !== 1)
|
|
62
|
+
return "";
|
|
63
|
+
const tag = node.tagName.toLowerCase();
|
|
64
|
+
if (tag === "script" || tag === "style" || tag === "noscript")
|
|
65
|
+
return "";
|
|
66
|
+
if (hidden(node, win.getComputedStyle(node)))
|
|
67
|
+
return "";
|
|
68
|
+
if (tag === "img")
|
|
69
|
+
return node.getAttribute("alt") || "";
|
|
70
|
+
const own = node.getAttribute("aria-label");
|
|
71
|
+
if (own && roleOf(node))
|
|
72
|
+
return own;
|
|
73
|
+
let out = "";
|
|
74
|
+
for (const c of node.childNodes)
|
|
75
|
+
out += textOf(c) + (c.nodeType === 1 && win.getComputedStyle(c).display !== "inline" ? " " : "");
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Accessible name and whether it came from the element's own content. */
|
|
79
|
+
function nameOf(el, role) {
|
|
80
|
+
const by = norm(el.getAttribute("aria-labelledby"));
|
|
81
|
+
if (by) {
|
|
82
|
+
const t = by.split(" ").map((id) => { const r = doc.getElementById(id); return r ? norm(textOf(r)) : ""; }).filter(Boolean).join(" ");
|
|
83
|
+
if (t)
|
|
84
|
+
return { name: t, fromContent: false };
|
|
85
|
+
}
|
|
86
|
+
const label = norm(el.getAttribute("aria-label"));
|
|
87
|
+
if (label)
|
|
88
|
+
return { name: label, fromContent: false };
|
|
89
|
+
const tag = el.tagName.toLowerCase();
|
|
90
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
91
|
+
const ls = el.labels ? [...el.labels] : [];
|
|
92
|
+
const t = norm(ls.map((l) => textOf(l)).join(" "));
|
|
93
|
+
if (t)
|
|
94
|
+
return { name: t, fromContent: false };
|
|
95
|
+
if (tag === "input" && /^(button|submit|reset)$/.test(el.type) && el.value)
|
|
96
|
+
return { name: norm(el.value), fromContent: false };
|
|
97
|
+
const ph = norm(el.getAttribute("placeholder")) || norm(el.getAttribute("title"));
|
|
98
|
+
return { name: ph, fromContent: false };
|
|
99
|
+
}
|
|
100
|
+
if (tag === "img")
|
|
101
|
+
return { name: norm(el.getAttribute("alt")) || norm(el.getAttribute("title")), fromContent: false };
|
|
102
|
+
if (FROM_CONTENT.has(role)) {
|
|
103
|
+
const t = norm(textOf(el));
|
|
104
|
+
if (t)
|
|
105
|
+
return { name: t, fromContent: true };
|
|
106
|
+
}
|
|
107
|
+
return { name: norm(el.getAttribute("title")), fromContent: false };
|
|
108
|
+
}
|
|
109
|
+
function states(el, role) {
|
|
110
|
+
let s = "";
|
|
111
|
+
const aria = (n) => el.getAttribute(n);
|
|
112
|
+
const on = (n) => aria(n) === "true";
|
|
113
|
+
if (role === "heading") {
|
|
114
|
+
const l = aria("aria-level") || (/^h([1-6])$/i.exec(el.tagName)?.[1] ?? "2");
|
|
115
|
+
s += " [level=" + l + "]";
|
|
116
|
+
}
|
|
117
|
+
if (/^(checkbox|radio|switch|menuitemcheckbox|menuitemradio)$/.test(role)) {
|
|
118
|
+
if (el.tagName === "INPUT" ? el.checked : on("aria-checked"))
|
|
119
|
+
s += " [checked]";
|
|
120
|
+
else if (aria("aria-checked") === "mixed")
|
|
121
|
+
s += " [checked=mixed]";
|
|
122
|
+
}
|
|
123
|
+
if (el.disabled === true || on("aria-disabled") || (el.closest && el.closest("fieldset[disabled]")))
|
|
124
|
+
s += " [disabled]";
|
|
125
|
+
if (on("aria-expanded"))
|
|
126
|
+
s += " [expanded]";
|
|
127
|
+
if (/^(tab|option|treeitem|row|gridcell)$/.test(role) && on("aria-selected"))
|
|
128
|
+
s += " [selected]";
|
|
129
|
+
if (on("aria-pressed"))
|
|
130
|
+
s += " [pressed]";
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
const quote = (s) => JSON.stringify(s);
|
|
134
|
+
// The simulator's snapshot is YAML-like: a key with ": " inside is wrapped in single quotes, a value with ": " in double quotes.
|
|
135
|
+
const needs = (t) => /: | #|:$|^[\[\]{}&*!|>'"%@`#?,-]/.test(t);
|
|
136
|
+
const keyLine = (pad, key, tail) => pad + "- " + (needs(key) ? "'" + key.replace(/'/g, "''") + "'" : key) + tail;
|
|
137
|
+
const val = (t) => (needs(t) ? JSON.stringify(t) : t);
|
|
138
|
+
function walk(el, depth, out) {
|
|
139
|
+
const tag = el.tagName.toLowerCase();
|
|
140
|
+
if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template")
|
|
141
|
+
return;
|
|
142
|
+
if (hidden(el, win.getComputedStyle(el)))
|
|
143
|
+
return;
|
|
144
|
+
const role = roleOf(el);
|
|
145
|
+
const pad = " ".repeat(depth);
|
|
146
|
+
if (role) {
|
|
147
|
+
const { name, fromContent } = nameOf(el, role);
|
|
148
|
+
const key = role + (name ? " " + quote(name) : "") + states(el, role) + " [ref=" + refOf(el, role + "|" + name) + "]";
|
|
149
|
+
if (tag === "input" || tag === "textarea") {
|
|
150
|
+
const v = el.value;
|
|
151
|
+
out.push(keyLine(pad, key, v && !/^(checkbox|radio|button|submit|reset)$/.test(el.type) ? ": " + val(v) : ""));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (fromContent || tag === "img") {
|
|
155
|
+
out.push(keyLine(pad, key, ""));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const mark = out.length;
|
|
159
|
+
out.push("");
|
|
160
|
+
for (const c of el.childNodes)
|
|
161
|
+
child(c, depth + 1, out);
|
|
162
|
+
out[mark] = keyLine(pad, key, out.length > mark + 1 ? ":" : "");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// No role: keep the text, drop the wrapper. A wrapper with only text becomes one generic line.
|
|
166
|
+
const hasEl = [...el.children].length > 0;
|
|
167
|
+
if (!hasEl) {
|
|
168
|
+
const t = norm(el.textContent);
|
|
169
|
+
if (t)
|
|
170
|
+
out.push(pad + "- generic [ref=" + refOf(el, "generic|" + t) + "]: " + val(t));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const c of el.childNodes)
|
|
174
|
+
child(c, depth, out);
|
|
175
|
+
}
|
|
176
|
+
function child(c, depth, out) {
|
|
177
|
+
if (c.nodeType === 1)
|
|
178
|
+
walk(c, depth, out);
|
|
179
|
+
else if (c.nodeType === 3) {
|
|
180
|
+
const t = norm(c.nodeValue);
|
|
181
|
+
if (t)
|
|
182
|
+
out.push(" ".repeat(depth) + "- text: " + val(t));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function tree() {
|
|
186
|
+
const frame = doc.getElementById("app");
|
|
187
|
+
const d = frame && frame.contentDocument;
|
|
188
|
+
if (!d || !d.body)
|
|
189
|
+
throw new Error("the app is not loaded yet");
|
|
190
|
+
globalThis.__shipvoRules.clearScreenRules(d);
|
|
191
|
+
globalThis.__shipvoRules.applyScreenRules(d);
|
|
192
|
+
try {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const c of d.body.childNodes)
|
|
195
|
+
child(c, 0, out);
|
|
196
|
+
return globalThis.__shipvoRules.filterTree(out.join("\n"));
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
globalThis.__shipvoRules.clearScreenRules(d);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Same code and message shape as the simulator's typed errors. */
|
|
203
|
+
class ActError extends Error {
|
|
204
|
+
code;
|
|
205
|
+
constructor(code, msg) { super(msg); this.code = code; }
|
|
206
|
+
}
|
|
207
|
+
const sigOf = (el) => { const role = roleOf(el); return role ? role + "|" + nameOf(el, role).name : "generic|" + norm(el.textContent); };
|
|
208
|
+
const appDoc = () => { const f = doc.getElementById("app"); return f && f.contentDocument; };
|
|
209
|
+
/** The element for a ref from a snapshot, or `stale_ref` if it is unknown, gone, left the page, or changed role or name. */
|
|
210
|
+
function resolve(ref) {
|
|
211
|
+
const stale = () => new ActError("stale_ref", `stale ref ${ref}: the screen changed. Take a new snapshot (action "screen") and use a ref from it.`);
|
|
212
|
+
const d = appDoc();
|
|
213
|
+
const rec = byRef.get(String(ref));
|
|
214
|
+
if (!d || !rec || !rec.el.isConnected || rec.el.ownerDocument !== d)
|
|
215
|
+
throw stale();
|
|
216
|
+
if (sigOf(rec.el) !== rec.sig || refs.get(rec.el)?.sig !== rec.sig)
|
|
217
|
+
throw stale();
|
|
218
|
+
return rec.el;
|
|
219
|
+
}
|
|
220
|
+
/** Say why an element cannot receive a tap. Nothing has reached the app when this throws. */
|
|
221
|
+
function ready(el, ref) {
|
|
222
|
+
const fail = (code, why) => new ActError(code, `${ref} ${why}, so nothing was sent to the app.`);
|
|
223
|
+
if (hidden(el, win.getComputedStyle(el)) || el.getClientRects().length === 0)
|
|
224
|
+
throw fail("not_visible", "is not visible");
|
|
225
|
+
if (el.disabled === true || el.getAttribute("aria-disabled") === "true" || (el.closest && el.closest("fieldset[disabled]")))
|
|
226
|
+
throw fail("disabled", "is disabled");
|
|
227
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
228
|
+
const r = el.getBoundingClientRect();
|
|
229
|
+
const w = el.ownerDocument.defaultView;
|
|
230
|
+
if (r.right <= 0 || r.bottom <= 0 || r.left >= w.innerWidth || r.top >= w.innerHeight)
|
|
231
|
+
throw fail("not_visible", "is not visible");
|
|
232
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
233
|
+
const top = el.ownerDocument.elementFromPoint(x, y);
|
|
234
|
+
if (top && top !== el && !el.contains(top) && !top.contains(el))
|
|
235
|
+
throw fail("covered", "is covered by another element");
|
|
236
|
+
return { x, y, target: top && el.contains(top) ? top : el };
|
|
237
|
+
}
|
|
238
|
+
function tap(el, ref) {
|
|
239
|
+
const { x, y, target } = ready(el, ref);
|
|
240
|
+
const w = el.ownerDocument.defaultView;
|
|
241
|
+
const init = { bubbles: true, cancelable: true, composed: true, view: w, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
242
|
+
const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons }));
|
|
243
|
+
fire(w.PointerEvent, "pointerdown", 1);
|
|
244
|
+
fire(w.MouseEvent, "mousedown", 1);
|
|
245
|
+
if (typeof el.focus === "function")
|
|
246
|
+
el.focus({ preventScroll: true });
|
|
247
|
+
fire(w.PointerEvent, "pointerup", 0);
|
|
248
|
+
fire(w.MouseEvent, "mouseup", 0);
|
|
249
|
+
fire(w.MouseEvent, "click", 0);
|
|
250
|
+
}
|
|
251
|
+
function type(el, ref, text, enter) {
|
|
252
|
+
const field = /^(INPUT|TEXTAREA)$/.test(el.tagName) ? el : el.querySelector("input, textarea");
|
|
253
|
+
if (!field)
|
|
254
|
+
throw new ActError("not_a_field", `${ref} is not a text field. Type into a "textbox" ref.`);
|
|
255
|
+
ready(field, ref);
|
|
256
|
+
const w = field.ownerDocument.defaultView;
|
|
257
|
+
field.focus();
|
|
258
|
+
const proto = field.tagName === "TEXTAREA" ? w.HTMLTextAreaElement.prototype : w.HTMLInputElement.prototype;
|
|
259
|
+
Object.getOwnPropertyDescriptor(proto, "value").set.call(field, text);
|
|
260
|
+
field.dispatchEvent(new w.InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
|
|
261
|
+
field.dispatchEvent(new w.Event("change", { bubbles: true }));
|
|
262
|
+
if (enter)
|
|
263
|
+
for (const t of ["keydown", "keypress", "keyup"])
|
|
264
|
+
field.dispatchEvent(new w.KeyboardEvent(t, { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true }));
|
|
265
|
+
return field.value;
|
|
266
|
+
}
|
|
267
|
+
function scroll(ref, direction) {
|
|
268
|
+
const d = appDoc();
|
|
269
|
+
const w = d.defaultView;
|
|
270
|
+
const dx = direction === "right" ? 300 : direction === "left" ? -300 : 0;
|
|
271
|
+
const dy = direction === "down" ? 600 : direction === "up" ? -600 : 0;
|
|
272
|
+
if (!dx && !dy)
|
|
273
|
+
throw new ActError("bad_direction", "scroll needs a direction: up, down, left or right.");
|
|
274
|
+
let el = ref ? resolve(ref) : d.body;
|
|
275
|
+
// The nearest ancestor that can scroll that way. Fall back to the page.
|
|
276
|
+
for (; el && el !== d.body && el !== d.documentElement; el = el.parentElement) {
|
|
277
|
+
const cs = w.getComputedStyle(el);
|
|
278
|
+
if (dy && /auto|scroll/.test(cs.overflowY) && el.scrollHeight > el.clientHeight)
|
|
279
|
+
break;
|
|
280
|
+
if (dx && /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth)
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
if (!el || el === d.body || el === d.documentElement)
|
|
284
|
+
el = d.scrollingElement || d.documentElement;
|
|
285
|
+
el.scrollBy({ left: dx, top: dy, behavior: "instant" });
|
|
286
|
+
}
|
|
287
|
+
function back() {
|
|
288
|
+
const d = appDoc();
|
|
289
|
+
for (const el of d.querySelectorAll("button, [role=button], input[type=button], input[type=submit]")) {
|
|
290
|
+
const role = roleOf(el);
|
|
291
|
+
if (role === "button" && !hidden(el, win.getComputedStyle(el)) && nameOf(el, role).name === "Back") {
|
|
292
|
+
tap(el, "Back");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw new Error("no Back button on this screen");
|
|
297
|
+
}
|
|
298
|
+
const sleep = (ms) => new Promise((r) => win.setTimeout(r, ms));
|
|
299
|
+
/** Give the app a moment to react, and wait for a page load a tap started. */
|
|
300
|
+
async function settle() {
|
|
301
|
+
await sleep(250);
|
|
302
|
+
for (let i = 0; i < 30; i++) {
|
|
303
|
+
const d = appDoc();
|
|
304
|
+
if (d && d.readyState === "complete" && d.body)
|
|
305
|
+
break;
|
|
306
|
+
await sleep(100);
|
|
307
|
+
}
|
|
308
|
+
await sleep(50);
|
|
309
|
+
}
|
|
310
|
+
const screenOrUndefined = () => { try {
|
|
311
|
+
return tree();
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return undefined;
|
|
315
|
+
} };
|
|
316
|
+
async function act(m) {
|
|
317
|
+
switch (m.type) {
|
|
318
|
+
case "tap":
|
|
319
|
+
tap(resolve(m.ref), m.ref);
|
|
320
|
+
break;
|
|
321
|
+
case "type": {
|
|
322
|
+
const value = type(resolve(m.ref), m.ref, String(m.text ?? ""), !!m.enter);
|
|
323
|
+
await settle();
|
|
324
|
+
return { delivery: "delivered", value, screen: tree() };
|
|
325
|
+
}
|
|
326
|
+
case "scroll":
|
|
327
|
+
scroll(m.ref, m.direction);
|
|
328
|
+
break;
|
|
329
|
+
case "back":
|
|
330
|
+
back();
|
|
331
|
+
break;
|
|
332
|
+
default: throw new Error("unknown request: " + m.type);
|
|
333
|
+
}
|
|
334
|
+
await settle();
|
|
335
|
+
return { delivery: "delivered", screen: tree() };
|
|
336
|
+
}
|
|
337
|
+
const ws = new win.WebSocket((win.location.protocol === "https:" ? "wss://" : "ws://") + win.location.host + "/__shipvo/agent?role=screen&token=" + encodeURIComponent(token));
|
|
338
|
+
ws.onmessage = (e) => {
|
|
339
|
+
let m;
|
|
340
|
+
try {
|
|
341
|
+
m = JSON.parse(String(e.data));
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!m || typeof m.id !== "number")
|
|
347
|
+
return;
|
|
348
|
+
try {
|
|
349
|
+
if (m.type === "tree")
|
|
350
|
+
ws.send(JSON.stringify({ id: m.id, type: "tree", ok: true, tree: tree() }));
|
|
351
|
+
else if (/^(tap|type|scroll|back)$/.test(m.type)) {
|
|
352
|
+
act(m).then((r) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: true, ...r })), (err) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, ...(err && err.code ? { code: err.code } : {}), error: String(err && err.message || err), screen: screenOrUndefined() })));
|
|
353
|
+
}
|
|
354
|
+
else
|
|
355
|
+
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: "unknown request: " + m.type }));
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: String(err && err.message || err) }));
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** The script text served at `/__shipvo/bridge.js`. It reads the session token from its own `data-token` attribute and then removes the attribute. */
|
|
363
|
+
export function bridgeScript(rules = screenRulesSource()) {
|
|
364
|
+
return `(function(){
|
|
365
|
+
var __rules=(function(){${rules}
|
|
366
|
+
return {clearScreenRules:clearScreenRules,applyScreenRules:applyScreenRules,filterTree:filterTree};})();
|
|
367
|
+
globalThis.__shipvoRules=__rules;
|
|
368
|
+
var tag=document.currentScript;
|
|
369
|
+
var tok=tag?tag.getAttribute("data-token")||"":"";
|
|
370
|
+
if(tag)tag.removeAttribute("data-token");
|
|
371
|
+
(${bridgeMain.toString()})(window, tok);
|
|
372
|
+
})();
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const AGENT_PATH = "/__shipvo/agent";
|
|
2
|
+
export type AgentRole = "screen" | "driver";
|
|
3
|
+
/** Contents of `.shipvo/run.json` while `shipvo run` is up. */
|
|
4
|
+
export type RunInfo = {
|
|
5
|
+
port: number;
|
|
6
|
+
platform: "android" | "ios";
|
|
7
|
+
app: string;
|
|
8
|
+
token: string;
|
|
9
|
+
pid: number;
|
|
10
|
+
};
|
|
11
|
+
/** Driver to screen: ask the page to do something. `id` pairs the reply with the request. */
|
|
12
|
+
export type DriverMessage = {
|
|
13
|
+
id: number;
|
|
14
|
+
type: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
/** Screen to driver: the reply to `id`, or an event the page pushes on its own (no `id`). */
|
|
18
|
+
export type ScreenMessage = {
|
|
19
|
+
id?: number;
|
|
20
|
+
type: string;
|
|
21
|
+
ok?: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
};
|
|
25
|
+
export type AgentMessage = DriverMessage | ScreenMessage;
|
|
26
|
+
export declare const isAgentRole: (v: string | null) => v is AgentRole;
|
|
27
|
+
export declare function isLoopback(addr: string | undefined): boolean;
|
|
28
|
+
/** True when the `Host` header names this server on loopback. Blocks DNS rebinding. */
|
|
29
|
+
export declare function hostAllowed(host: string | undefined, port: number): boolean;
|
|
30
|
+
/** True when there is no `Origin` (not a browser) or it is this server's own origin. */
|
|
31
|
+
export declare function originAllowed(origin: string | undefined, port: number): boolean;
|
|
32
|
+
export declare function tokenMatches(given: string | null, token: string): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Messages on the `shipvo run` agent channel (`/__shipvo/agent`). The host only relays them between the
|
|
2
|
+
// `screen` (the browser tab) and the `driver` (the agent). Slices 2 and 3 of #448 build on these types.
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
export const AGENT_PATH = "/__shipvo/agent";
|
|
5
|
+
export const isAgentRole = (v) => v === "screen" || v === "driver";
|
|
6
|
+
export function isLoopback(addr) {
|
|
7
|
+
return addr === "127.0.0.1" || addr === "::1" || (addr?.startsWith("::ffff:127.") ?? false);
|
|
8
|
+
}
|
|
9
|
+
/** True when the `Host` header names this server on loopback. Blocks DNS rebinding. */
|
|
10
|
+
export function hostAllowed(host, port) {
|
|
11
|
+
return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
|
|
12
|
+
}
|
|
13
|
+
/** True when there is no `Origin` (not a browser) or it is this server's own origin. */
|
|
14
|
+
export function originAllowed(origin, port) {
|
|
15
|
+
return origin === undefined || origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
|
|
16
|
+
}
|
|
17
|
+
export function tokenMatches(given, token) {
|
|
18
|
+
if (!given)
|
|
19
|
+
return false;
|
|
20
|
+
const a = Buffer.from(given), b = Buffer.from(token);
|
|
21
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
22
|
+
}
|
package/dist/run-ws.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
export type Peer = {
|
|
4
|
+
send(text: string): void;
|
|
5
|
+
close(): void;
|
|
6
|
+
};
|
|
7
|
+
/** Completes the handshake and returns a peer. `onText` gets each complete text message; `onClose` fires once. */
|
|
8
|
+
export declare function acceptWebSocket(req: IncomingMessage, socket: Duplex, onText: (t: string) => void, onClose: () => void): Peer | undefined;
|
package/dist/run-ws.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A small server-side WebSocket (RFC 6455) on `node:http` upgrade, enough for JSON text messages.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
const MAX = 1 << 20;
|
|
4
|
+
function frame(op, payload) {
|
|
5
|
+
const n = payload.length;
|
|
6
|
+
const head = n < 126 ? Buffer.from([0x80 | op, n]) : n < 65536 ? Buffer.from([0x80 | op, 126, n >> 8, n & 255]) : (() => {
|
|
7
|
+
const h = Buffer.alloc(10);
|
|
8
|
+
h[0] = 0x80 | op;
|
|
9
|
+
h[1] = 127;
|
|
10
|
+
h.writeBigUInt64BE(BigInt(n), 2);
|
|
11
|
+
return h;
|
|
12
|
+
})();
|
|
13
|
+
return Buffer.concat([head, payload]);
|
|
14
|
+
}
|
|
15
|
+
/** Completes the handshake and returns a peer. `onText` gets each complete text message; `onClose` fires once. */
|
|
16
|
+
export function acceptWebSocket(req, socket, onText, onClose) {
|
|
17
|
+
const key = req.headers["sec-websocket-key"];
|
|
18
|
+
if (typeof key !== "string" || req.headers.upgrade?.toLowerCase() !== "websocket") {
|
|
19
|
+
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
|
|
23
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
24
|
+
let buf = Buffer.alloc(0);
|
|
25
|
+
let parts = [];
|
|
26
|
+
let closed = false;
|
|
27
|
+
const done = () => { if (!closed) {
|
|
28
|
+
closed = true;
|
|
29
|
+
onClose();
|
|
30
|
+
} };
|
|
31
|
+
const shut = () => { if (!closed)
|
|
32
|
+
socket.write(frame(8, Buffer.alloc(0))); socket.end(); done(); };
|
|
33
|
+
socket.on("close", done);
|
|
34
|
+
socket.on("error", () => { socket.destroy(); done(); });
|
|
35
|
+
socket.on("data", (chunk) => {
|
|
36
|
+
buf = Buffer.concat([buf, chunk]);
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (buf.length < 2)
|
|
39
|
+
return;
|
|
40
|
+
const op = buf[0] & 15, fin = (buf[0] & 128) !== 0, masked = (buf[1] & 128) !== 0;
|
|
41
|
+
let len = buf[1] & 127, off = 2;
|
|
42
|
+
if (len === 126) {
|
|
43
|
+
if (buf.length < 4)
|
|
44
|
+
return;
|
|
45
|
+
len = buf.readUInt16BE(2);
|
|
46
|
+
off = 4;
|
|
47
|
+
}
|
|
48
|
+
else if (len === 127) {
|
|
49
|
+
if (buf.length < 10)
|
|
50
|
+
return;
|
|
51
|
+
const big = buf.readBigUInt64BE(2);
|
|
52
|
+
if (big > BigInt(MAX))
|
|
53
|
+
return shut();
|
|
54
|
+
len = Number(big);
|
|
55
|
+
off = 10;
|
|
56
|
+
}
|
|
57
|
+
if (!masked || len > MAX)
|
|
58
|
+
return shut(); // clients must mask
|
|
59
|
+
if (buf.length < off + 4 + len)
|
|
60
|
+
return;
|
|
61
|
+
const mask = buf.subarray(off, off + 4);
|
|
62
|
+
const data = Buffer.from(buf.subarray(off + 4, off + 4 + len));
|
|
63
|
+
for (let i = 0; i < len; i++)
|
|
64
|
+
data[i] ^= mask[i & 3];
|
|
65
|
+
buf = buf.subarray(off + 4 + len);
|
|
66
|
+
if (op === 8)
|
|
67
|
+
return shut();
|
|
68
|
+
if (op === 9) {
|
|
69
|
+
socket.write(frame(10, data));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (op === 10)
|
|
73
|
+
continue;
|
|
74
|
+
if (op === 1 || op === 2 || op === 0) {
|
|
75
|
+
parts.push(data);
|
|
76
|
+
if (parts.reduce((s, p) => s + p.length, 0) > MAX)
|
|
77
|
+
return shut();
|
|
78
|
+
if (fin) {
|
|
79
|
+
const text = Buffer.concat(parts).toString("utf8");
|
|
80
|
+
parts = [];
|
|
81
|
+
if (op !== 2)
|
|
82
|
+
onText(text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return { send: (t) => { if (!closed)
|
|
88
|
+
socket.write(frame(1, Buffer.from(t))); }, close: shut };
|
|
89
|
+
}
|