@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/eas.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// `shipvo eas setup` and `shipvo eas build`: make a Capacitor repo ready for `eas build` and `eas submit`
|
|
2
|
+
// with the customer's own Expo account. Shipvo writes files; it never holds Expo credentials.
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
export const ANDROID_IMAGE = "latest";
|
|
7
|
+
export const IOS_IMAGE = "macos-tahoe-26.5-xcode-26.6";
|
|
8
|
+
export const NODE_VERSION = "22";
|
|
9
|
+
export const JDK_VERSION = "21";
|
|
10
|
+
const SCHEME_PATH = "ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme";
|
|
11
|
+
const SHIM = "ios/App.xcodeproj";
|
|
12
|
+
const SHIM_TARGET = "App/App.xcodeproj";
|
|
13
|
+
/** Look for a Capacitor project at `root`. Returns undefined when it is not one. */
|
|
14
|
+
export function detectCapacitor(root) {
|
|
15
|
+
const configFile = readdirSync(root).find((f) => /^capacitor\.config\.(ts|js|json|mjs|cjs)$/.test(f));
|
|
16
|
+
const android = existsSync(join(root, "android"));
|
|
17
|
+
const ios = existsSync(join(root, "ios/App/App.xcodeproj"));
|
|
18
|
+
if (!configFile || (!android && !ios))
|
|
19
|
+
return undefined;
|
|
20
|
+
return { configFile, android, ios };
|
|
21
|
+
}
|
|
22
|
+
const androidYaml = `# Written by \`shipvo eas setup\`. Safe to edit; setup will not overwrite this file.
|
|
23
|
+
# Docs: https://docs.expo.dev/custom-builds/get-started/
|
|
24
|
+
build:
|
|
25
|
+
name: Capacitor Android release
|
|
26
|
+
steps:
|
|
27
|
+
- eas/checkout
|
|
28
|
+
- eas/install_node_modules
|
|
29
|
+
- run:
|
|
30
|
+
name: Install JDK ${JDK_VERSION}
|
|
31
|
+
command: |
|
|
32
|
+
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
|
33
|
+
sdk install java ${JDK_VERSION}-tem < /dev/null
|
|
34
|
+
set-env JAVA_HOME "$(sdk home java ${JDK_VERSION}-tem)"
|
|
35
|
+
- run:
|
|
36
|
+
name: Build the web app
|
|
37
|
+
command: npm run build
|
|
38
|
+
- run:
|
|
39
|
+
name: Sync Capacitor
|
|
40
|
+
command: npx cap sync android
|
|
41
|
+
- eas/inject_android_credentials
|
|
42
|
+
- eas/run_gradle:
|
|
43
|
+
inputs:
|
|
44
|
+
command: :app:bundleRelease
|
|
45
|
+
- eas/find_and_upload_build_artifacts
|
|
46
|
+
`;
|
|
47
|
+
const iosYaml = `# Written by \`shipvo eas setup\`. Safe to edit; setup will not overwrite this file.
|
|
48
|
+
# Docs: https://docs.expo.dev/custom-builds/get-started/
|
|
49
|
+
# Before the first build, run \`shipvo eas build\`: it creates the ios/App.xcodeproj link that the EAS CLI scheme check needs on your machine before upload.
|
|
50
|
+
build:
|
|
51
|
+
name: Capacitor iOS release
|
|
52
|
+
steps:
|
|
53
|
+
- eas/checkout
|
|
54
|
+
- eas/install_node_modules
|
|
55
|
+
- run:
|
|
56
|
+
name: Build the web app
|
|
57
|
+
command: npm run build
|
|
58
|
+
- run:
|
|
59
|
+
name: Sync Capacitor
|
|
60
|
+
command: npx cap sync ios
|
|
61
|
+
- run:
|
|
62
|
+
name: Install CocoaPods dependencies
|
|
63
|
+
command: cd ios/App && pod install
|
|
64
|
+
- eas/resolve_apple_team_id_from_credentials:
|
|
65
|
+
id: resolve_apple_team_id
|
|
66
|
+
- eas/prebuild_credentials_ios:
|
|
67
|
+
inputs:
|
|
68
|
+
project_dir: ios
|
|
69
|
+
- eas/configure_ios_credentials
|
|
70
|
+
- run:
|
|
71
|
+
name: Write ExportOptions.plist
|
|
72
|
+
# Nothing else creates this file. The team id comes from the EAS credentials step above;
|
|
73
|
+
# the profile name is read from the Xcode project that eas/configure_ios_credentials updated.
|
|
74
|
+
command: |
|
|
75
|
+
BUNDLE_ID="$(xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -showBuildSettings | awk -F' = ' '/ PRODUCT_BUNDLE_IDENTIFIER /{print $2; exit}')"
|
|
76
|
+
PROFILE="$(xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -showBuildSettings | awk -F' = ' '/ PROVISIONING_PROFILE_SPECIFIER /{print $2; exit}')"
|
|
77
|
+
cat > ios/App/ExportOptions.plist <<PLIST
|
|
78
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
79
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
80
|
+
<plist version="1.0">
|
|
81
|
+
<dict>
|
|
82
|
+
<key>method</key><string>app-store</string>
|
|
83
|
+
<key>teamID</key><string>\${{ steps.resolve_apple_team_id.outputs.apple_team_id }}</string>
|
|
84
|
+
<key>signingStyle</key><string>manual</string>
|
|
85
|
+
<key>provisioningProfiles</key>
|
|
86
|
+
<dict><key>$BUNDLE_ID</key><string>$PROFILE</string></dict>
|
|
87
|
+
</dict>
|
|
88
|
+
</plist>
|
|
89
|
+
PLIST
|
|
90
|
+
- run:
|
|
91
|
+
name: Archive with distribution signing
|
|
92
|
+
command: |
|
|
93
|
+
xcodebuild archive \\
|
|
94
|
+
-workspace ios/App/App.xcworkspace \\
|
|
95
|
+
-scheme App \\
|
|
96
|
+
-configuration Release \\
|
|
97
|
+
-archivePath build/App.xcarchive \\
|
|
98
|
+
DEVELOPMENT_TEAM="\${{ steps.resolve_apple_team_id.outputs.apple_team_id }}" \\
|
|
99
|
+
CODE_SIGN_STYLE=Manual
|
|
100
|
+
xcodebuild -exportArchive \\
|
|
101
|
+
-archivePath build/App.xcarchive \\
|
|
102
|
+
-exportPath build/export \\
|
|
103
|
+
-exportOptionsPlist ios/App/ExportOptions.plist
|
|
104
|
+
- eas/find_and_upload_build_artifacts
|
|
105
|
+
`;
|
|
106
|
+
const scheme = `<?xml version="1.0" encoding="UTF-8"?>
|
|
107
|
+
<Scheme LastUpgradeVersion="1600" version="1.7">
|
|
108
|
+
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
|
109
|
+
<BuildActionEntries>
|
|
110
|
+
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
|
111
|
+
<BuildableReference BuildableIdentifier="primary" BlueprintName="App" BuildableName="App.app" BlueprintIdentifier="504EC3011FED79650016851F" ReferencedContainer="container:App.xcodeproj"/>
|
|
112
|
+
</BuildActionEntry>
|
|
113
|
+
</BuildActionEntries>
|
|
114
|
+
</BuildAction>
|
|
115
|
+
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"/>
|
|
116
|
+
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
|
117
|
+
</Scheme>
|
|
118
|
+
`;
|
|
119
|
+
function readJson(path) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (e.code === "ENOENT")
|
|
125
|
+
return {};
|
|
126
|
+
throw new Error(`${path} is not valid JSON. Fix it and run the command again.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Merge our profiles into an existing eas.json without touching the customer's own settings. */
|
|
130
|
+
function easJson(root, p) {
|
|
131
|
+
const j = readJson(join(root, "eas.json"));
|
|
132
|
+
for (const k of ["cli", "build", "submit"]) {
|
|
133
|
+
if (j[k] !== undefined && (j[k] === null || typeof j[k] !== "object" || Array.isArray(j[k]))) {
|
|
134
|
+
throw new Error(`${join(root, "eas.json")} is not valid: "${k}" must be an object. Fix it and run the command again.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
j.cli = { appVersionSource: "remote", ...(j.cli ?? {}) };
|
|
138
|
+
j.build ??= {};
|
|
139
|
+
if (p.android)
|
|
140
|
+
j.build["shipvo-android"] ??= { android: { image: ANDROID_IMAGE, buildType: "app-bundle", config: "android.yml" }, distribution: "store" };
|
|
141
|
+
if (p.ios)
|
|
142
|
+
j.build["shipvo-ios"] ??= { ios: { image: IOS_IMAGE, node: NODE_VERSION, config: "ios.yml" }, distribution: "store", autoIncrement: true };
|
|
143
|
+
j.submit ??= {};
|
|
144
|
+
if (p.android)
|
|
145
|
+
j.submit["shipvo-android"] ??= { android: {} };
|
|
146
|
+
if (p.ios)
|
|
147
|
+
j.submit["shipvo-ios"] ??= { ios: {} };
|
|
148
|
+
return JSON.stringify(j, null, 2) + "\n";
|
|
149
|
+
}
|
|
150
|
+
function gitignore(root) {
|
|
151
|
+
const path = join(root, ".gitignore");
|
|
152
|
+
const cur = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
153
|
+
if (cur.split(/\r?\n/).some((l) => l.trim() === SHIM))
|
|
154
|
+
return undefined;
|
|
155
|
+
const sep = cur === "" || cur.endsWith("\n") ? "" : "\n";
|
|
156
|
+
return `${cur}${sep}# Created by \`shipvo eas build\` for EAS; never commit\n${SHIM}\n`;
|
|
157
|
+
}
|
|
158
|
+
/** Work out which files need writing. Existing build YAMLs and schemes are never overwritten. */
|
|
159
|
+
export function planEasSetup(root, p) {
|
|
160
|
+
const out = [];
|
|
161
|
+
const add = (path, content, overwrite) => {
|
|
162
|
+
const exists = existsSync(join(root, path));
|
|
163
|
+
if (exists && !overwrite)
|
|
164
|
+
return;
|
|
165
|
+
if (exists && readFileSync(join(root, path), "utf8") === content)
|
|
166
|
+
return;
|
|
167
|
+
out.push({ path, content, action: exists ? "update" : "create" });
|
|
168
|
+
};
|
|
169
|
+
add("eas.json", easJson(root, p), true);
|
|
170
|
+
if (p.android)
|
|
171
|
+
add(".eas/build/android.yml", androidYaml, false);
|
|
172
|
+
if (p.ios) {
|
|
173
|
+
add(".eas/build/ios.yml", iosYaml, false);
|
|
174
|
+
add(SCHEME_PATH, scheme, false);
|
|
175
|
+
}
|
|
176
|
+
const gi = p.ios ? gitignore(root) : undefined;
|
|
177
|
+
if (gi !== undefined)
|
|
178
|
+
out.push({ path: ".gitignore", content: gi, action: existsSync(join(root, ".gitignore")) ? "update" : "create" });
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** Create the `ios/App.xcodeproj -> App/App.xcodeproj` link the EAS CLI scheme check needs. Returns true if it made one. */
|
|
182
|
+
export function ensureSchemeShim(root) {
|
|
183
|
+
const link = join(root, SHIM);
|
|
184
|
+
try {
|
|
185
|
+
lstatSync(link);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
catch { /* missing: create it */ }
|
|
189
|
+
try {
|
|
190
|
+
symlinkSync(SHIM_TARGET, link, "dir");
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
throw new Error(`Could not create the link ${SHIM} -> ${SHIM_TARGET} (${e.message}). Create it yourself (on Windows, run as administrator or turn on Developer Mode), then run the command again.`);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
export const ONE_TIME_STEPS = [
|
|
198
|
+
["eas-cli", "init"],
|
|
199
|
+
["eas-cli", "build:version:set", "-p", "ios"],
|
|
200
|
+
];
|
|
201
|
+
export const IOS_UNPROVEN = "Note: the iOS signing steps in .eas/build/ios.yml are unproven. The step names were not checked against Expo's custom build functions (https://docs.expo.dev/custom-builds/schema/), so your first iOS build is the check.";
|
|
202
|
+
const defaultRunner = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, stdio: "inherit" }).status ?? 1;
|
|
203
|
+
export async function easCmd(args, o = {}) {
|
|
204
|
+
const cwd = o.cwd ?? process.cwd();
|
|
205
|
+
const log = o.log ?? ((s) => console.log(s));
|
|
206
|
+
const runner = o.runner ?? defaultRunner;
|
|
207
|
+
const [sub, ...rest] = args;
|
|
208
|
+
if (sub !== "setup" && sub !== "build") {
|
|
209
|
+
throw new Error("Usage: shipvo eas setup [--dry-run] [--run] | shipvo eas build [eas build args]");
|
|
210
|
+
}
|
|
211
|
+
const project = detectCapacitor(cwd);
|
|
212
|
+
if (!project)
|
|
213
|
+
throw new Error("This is not a Capacitor project. Run this in a folder with capacitor.config.* and an android/ or ios/App/App.xcodeproj folder.");
|
|
214
|
+
if (sub === "build") {
|
|
215
|
+
if (project.ios && ensureSchemeShim(cwd))
|
|
216
|
+
log(`Linked ${SHIM} -> ${SHIM_TARGET} (git-ignored).`);
|
|
217
|
+
return runner("npx", ["eas-cli", "build", ...rest], cwd);
|
|
218
|
+
}
|
|
219
|
+
const known = ["--dry-run", "--run"];
|
|
220
|
+
const bad = rest.filter((a) => !known.includes(a));
|
|
221
|
+
if (bad.length)
|
|
222
|
+
throw new Error(`Unknown option for shipvo eas setup: ${bad.join(" ")}. Use --dry-run or --run.`);
|
|
223
|
+
const dry = rest.includes("--dry-run");
|
|
224
|
+
const runSteps = rest.includes("--run") && !dry;
|
|
225
|
+
const plan = planEasSetup(cwd, project);
|
|
226
|
+
for (const f of plan) {
|
|
227
|
+
log(`${dry ? "Would " + f.action : f.action === "create" ? "Created" : "Updated"} ${f.path}`);
|
|
228
|
+
if (dry)
|
|
229
|
+
log(f.content.replace(/^/gm, " "));
|
|
230
|
+
else {
|
|
231
|
+
mkdirSync(dirname(join(cwd, f.path)), { recursive: true });
|
|
232
|
+
writeFileSync(join(cwd, f.path), f.content);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!plan.length)
|
|
236
|
+
log("Nothing to change: this repo is already set up for EAS.");
|
|
237
|
+
if (project.ios)
|
|
238
|
+
log(IOS_UNPROVEN);
|
|
239
|
+
if (dry)
|
|
240
|
+
return 0;
|
|
241
|
+
log("\nRemaining one-time steps:");
|
|
242
|
+
for (const s of ONE_TIME_STEPS)
|
|
243
|
+
log(` npx ${s.join(" ")}`);
|
|
244
|
+
if (project.ios)
|
|
245
|
+
log("Before an iOS build, build with `shipvo eas build -p ios --profile shipvo-ios`: it creates the git-ignored ios/App.xcodeproj link first.");
|
|
246
|
+
if (runSteps) {
|
|
247
|
+
for (const s of ONE_TIME_STEPS) {
|
|
248
|
+
if (s[1] === "build:version:set" && !project.ios)
|
|
249
|
+
continue;
|
|
250
|
+
const code = runner("npx", [...s], cwd);
|
|
251
|
+
if (code !== 0) {
|
|
252
|
+
log(`\`npx ${s.join(" ")}\` failed (exit ${code}). Fix that and run it again.`);
|
|
253
|
+
return code;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type PackageManager = "pnpm" | "yarn" | "bun" | "npm";
|
|
2
|
+
export interface EnsurePlaywrightDeps {
|
|
3
|
+
cwd: string;
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
/** Does this path exist? Used to find the lockfile. */
|
|
6
|
+
exists: (path: string) => boolean;
|
|
7
|
+
/** Runs a command in `cwd`. Throws if it fails. */
|
|
8
|
+
run: (cmd: string, args: string[], cwd: string) => void;
|
|
9
|
+
log: (line: string) => void;
|
|
10
|
+
/** True when `playwright` resolves from the app. Defaults to the same lookup order as `doctor`. */
|
|
11
|
+
resolves?: (cwd: string) => boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function detectPackageManager(cwd: string, exists: (path: string) => boolean): PackageManager;
|
|
14
|
+
export declare function installCommand(pm: PackageManager): {
|
|
15
|
+
cmd: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
};
|
|
18
|
+
/** Installs `playwright` as a dev dependency with the app's package manager when it is missing. */
|
|
19
|
+
export declare function ensurePlaywright(deps: Partial<EnsurePlaywrightDeps> & {
|
|
20
|
+
cwd: string;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const LOCKFILES = [
|
|
6
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
7
|
+
["yarn.lock", "yarn"],
|
|
8
|
+
["bun.lock", "bun"],
|
|
9
|
+
["bun.lockb", "bun"],
|
|
10
|
+
];
|
|
11
|
+
export function detectPackageManager(cwd, exists) {
|
|
12
|
+
for (const [file, pm] of LOCKFILES)
|
|
13
|
+
if (exists(join(cwd, file)))
|
|
14
|
+
return pm;
|
|
15
|
+
return "npm";
|
|
16
|
+
}
|
|
17
|
+
export function installCommand(pm) {
|
|
18
|
+
switch (pm) {
|
|
19
|
+
case "pnpm": return { cmd: "pnpm", args: ["add", "-D", "playwright"] };
|
|
20
|
+
case "yarn": return { cmd: "yarn", args: ["add", "-D", "playwright"] };
|
|
21
|
+
case "bun": return { cmd: "bun", args: ["add", "-d", "playwright"] };
|
|
22
|
+
default: return { cmd: "npm", args: ["install", "--save-dev", "playwright"] };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Same lookup order as doctor's playwrightChromiumPath: the app, this package, @shipvo/sim, then the global npm root. */
|
|
26
|
+
function playwrightResolves(cwd) {
|
|
27
|
+
const tries = [
|
|
28
|
+
() => createRequire(join(cwd, "package.json")).resolve("playwright"),
|
|
29
|
+
() => createRequire(import.meta.url).resolve("playwright"),
|
|
30
|
+
() => createRequire(createRequire(import.meta.url).resolve("@shipvo/sim/package.json")).resolve("playwright"),
|
|
31
|
+
() => createRequire(import.meta.url).resolve(join(execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(), "playwright")),
|
|
32
|
+
];
|
|
33
|
+
for (const t of tries) {
|
|
34
|
+
try {
|
|
35
|
+
t();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Installs `playwright` as a dev dependency with the app's package manager when it is missing. */
|
|
43
|
+
export function ensurePlaywright(deps) {
|
|
44
|
+
const { cwd } = deps;
|
|
45
|
+
const env = deps.env ?? process.env;
|
|
46
|
+
const exists = deps.exists ?? existsSync;
|
|
47
|
+
const run = deps.run ?? ((cmd, args, dir) => { execFileSync(cmd, args, { cwd: dir, stdio: "inherit" }); });
|
|
48
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
49
|
+
if ((deps.resolves ?? playwrightResolves)(cwd))
|
|
50
|
+
return;
|
|
51
|
+
const { cmd, args } = installCommand(detectPackageManager(cwd, exists));
|
|
52
|
+
const line = [cmd, ...args].join(" ");
|
|
53
|
+
if (env.SHIPVO_NO_AUTO_INSTALL === "1") {
|
|
54
|
+
throw new Error(`playwright is not installed and SHIPVO_NO_AUTO_INSTALL=1 is set. Run \`${line}\` in ${cwd}.`);
|
|
55
|
+
}
|
|
56
|
+
log(`playwright is not installed. Running: ${line}`);
|
|
57
|
+
try {
|
|
58
|
+
run(cmd, args, cwd);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
62
|
+
throw new Error(`\`${line}\` failed: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type Step = {
|
|
2
|
+
action: "open";
|
|
3
|
+
} | {
|
|
4
|
+
action: "open_url";
|
|
5
|
+
url: string;
|
|
6
|
+
} | {
|
|
7
|
+
action: "tap";
|
|
8
|
+
role: string;
|
|
9
|
+
name: string;
|
|
10
|
+
nth: number;
|
|
11
|
+
} | {
|
|
12
|
+
action: "back";
|
|
13
|
+
};
|
|
14
|
+
export type Reply = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
screen?: string | null;
|
|
17
|
+
error?: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
delivery?: string;
|
|
20
|
+
reason?: string;
|
|
21
|
+
};
|
|
22
|
+
/** What the walker needs from a simulator session. */
|
|
23
|
+
export type Driver = {
|
|
24
|
+
/** Start a fresh phone on the app. */
|
|
25
|
+
open(): Promise<Reply>;
|
|
26
|
+
openUrl(url: string): Promise<Reply>;
|
|
27
|
+
tap(ref: string): Promise<Reply>;
|
|
28
|
+
back(): Promise<Reply>;
|
|
29
|
+
/** Uncaught page errors since the last call. */
|
|
30
|
+
errors(): Promise<string[]>;
|
|
31
|
+
};
|
|
32
|
+
export type Kind = "error" | "not_delivered" | "action_failed" | "dead_end" | "unnamed";
|
|
33
|
+
export type Finding = {
|
|
34
|
+
kind: Kind;
|
|
35
|
+
message: string;
|
|
36
|
+
screen: string;
|
|
37
|
+
steps: Step[];
|
|
38
|
+
};
|
|
39
|
+
export type Report = {
|
|
40
|
+
app: string;
|
|
41
|
+
generatedAt: string;
|
|
42
|
+
steps: number;
|
|
43
|
+
screens: number;
|
|
44
|
+
maxSteps: number;
|
|
45
|
+
maxDepth: number;
|
|
46
|
+
stoppedEarly: string | null;
|
|
47
|
+
error: string | null;
|
|
48
|
+
findings: Finding[];
|
|
49
|
+
parityLimits: string[];
|
|
50
|
+
};
|
|
51
|
+
export declare const PARITY_LIMITS: string[];
|
|
52
|
+
type El = {
|
|
53
|
+
role: string;
|
|
54
|
+
name: string;
|
|
55
|
+
ref: string;
|
|
56
|
+
nth: number;
|
|
57
|
+
};
|
|
58
|
+
/** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
|
|
59
|
+
export declare function elements(tree: string): El[];
|
|
60
|
+
/** The tree without refs, to tell whether two screens are the same. */
|
|
61
|
+
export declare const signature: (tree: string) => string;
|
|
62
|
+
/** Steps in plain lines, for the report. */
|
|
63
|
+
export declare const describeStep: (s: Step) => string;
|
|
64
|
+
export declare function walk(driver: Driver, o?: {
|
|
65
|
+
routes?: string[];
|
|
66
|
+
maxDepth?: number;
|
|
67
|
+
maxSteps?: number;
|
|
68
|
+
app?: string;
|
|
69
|
+
}): Promise<Report>;
|
|
70
|
+
export declare function renderMarkdown(r: Report): string;
|
|
71
|
+
/** Driver for a running simulator. */
|
|
72
|
+
export declare function simDriver(base: string, app: string, session?: string): Driver;
|
|
73
|
+
/** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
|
|
74
|
+
export declare function startSim(a: Record<string, string | undefined>, cfg: {
|
|
75
|
+
name: string;
|
|
76
|
+
appId: string;
|
|
77
|
+
controlPlane: string;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
base: string;
|
|
80
|
+
stop: () => void;
|
|
81
|
+
}>;
|
|
82
|
+
/** Returns the exit code. */
|
|
83
|
+
export declare function explore(argv: string[]): Promise<number>;
|
|
84
|
+
export {};
|
package/dist/explore.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `shipvo explore`: a deterministic walker over the simulator's accessibility tree. No LLM call.
|
|
2
|
+
// The walker (`walk`) only needs a `Driver`, so tests run it against a fake app. `explore` wires it to the simulator.
|
|
3
|
+
import { execSync, spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { args as parse, loadConfig } from "./config.js";
|
|
7
|
+
import { portFree, waitListening } from "./sim.js";
|
|
8
|
+
import { ensurePlaywright } from "./ensure-playwright.js";
|
|
9
|
+
export const PARITY_LIMITS = [
|
|
10
|
+
"The simulator runs the app's web build in headless Chromium. It is not a phone and not the native app.",
|
|
11
|
+
"Camera, location, biometrics, payments and notifications are simulated. Native crashes and native-only bugs cannot show up here.",
|
|
12
|
+
"The walker only taps. It does not type, scroll or swipe, so content below the fold and rails are only seen if on screen.",
|
|
13
|
+
"Layout, speed and memory are not measured.",
|
|
14
|
+
"See docs/parity-results.md for what was measured against Android emulators and the iOS Simulator.",
|
|
15
|
+
];
|
|
16
|
+
const ACTIONABLE = new Set(["button", "link", "tab", "checkbox", "switch", "radio", "menuitem"]);
|
|
17
|
+
const unquote = (raw) => { const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw); return q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw; };
|
|
18
|
+
/** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
|
|
19
|
+
export function elements(tree) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const seen = new Map();
|
|
22
|
+
for (const raw of tree.split("\n")) {
|
|
23
|
+
const l = unquote(raw);
|
|
24
|
+
const m = /^\s*- ([\w-]+)(?: ("(?:[^"\\]|\\.)*"))?[^\n]*?\[ref=(\w+)\]/.exec(l);
|
|
25
|
+
if (!m || !ACTIONABLE.has(m[1]) || /\[disabled\]/.test(l))
|
|
26
|
+
continue;
|
|
27
|
+
let name = "";
|
|
28
|
+
try {
|
|
29
|
+
name = m[2] ? JSON.parse(m[2]).trim() : "";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
name = m[2].slice(1, -1).trim();
|
|
33
|
+
}
|
|
34
|
+
const key = `${m[1]}|${name}`;
|
|
35
|
+
const nth = seen.get(key) ?? 0;
|
|
36
|
+
seen.set(key, nth + 1);
|
|
37
|
+
out.push({ role: m[1], name, ref: m[3], nth });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** The tree without refs, to tell whether two screens are the same. */
|
|
42
|
+
export const signature = (tree) => tree.replace(/\s*\[ref=\w+\]/g, "");
|
|
43
|
+
const redScreen = (tree) => /Uncaught (\w*Error)|Unhandled Runtime Error|Application error|Something went wrong/i.exec(tree)?.[0];
|
|
44
|
+
// Accepts "close" too, but the sim `back` action only clicks a button named exactly "Back"; the replay fallback covers the rest.
|
|
45
|
+
const hasBack = (els) => els.some((e) => /^(back|go back|close)$/i.test(e.name));
|
|
46
|
+
const label = (e) => `${e.role} "${e.name}"`;
|
|
47
|
+
/** Steps in plain lines, for the report. */
|
|
48
|
+
export const describeStep = (s) => s.action === "open" ? "open the app" : s.action === "open_url" ? `open_url ${s.url}` : s.action === "tap" ? `tap ${label(s)}${s.nth ? ` (number ${s.nth + 1} with that name)` : ""}` : "back";
|
|
49
|
+
export async function walk(driver, o = {}) {
|
|
50
|
+
const maxDepth = o.maxDepth ?? 3, maxSteps = o.maxSteps ?? 100;
|
|
51
|
+
const findings = [];
|
|
52
|
+
const seenFinding = new Set();
|
|
53
|
+
const seenScreens = new Set();
|
|
54
|
+
let steps = 0;
|
|
55
|
+
let stopped = null;
|
|
56
|
+
let cut = 0;
|
|
57
|
+
const add = (f) => { const k = `${f.kind}|${f.message}|${signature(f.screen)}`; if (!seenFinding.has(k)) {
|
|
58
|
+
seenFinding.add(k);
|
|
59
|
+
findings.push(f);
|
|
60
|
+
} };
|
|
61
|
+
const need = (r, what) => { if (!r.ok || r.screen == null)
|
|
62
|
+
throw new Error(`explore: ${what} failed: ${r.error ?? "no screen"}`); return r.screen; };
|
|
63
|
+
/** Put the phone back on the screen reached by `path`. Replays are not counted as steps. */
|
|
64
|
+
async function replay(path) {
|
|
65
|
+
let screen = "";
|
|
66
|
+
for (const s of path) {
|
|
67
|
+
const r = s.action === "open" ? await driver.open() : s.action === "open_url" ? await driver.openUrl(s.url) : s.action === "back" ? await driver.back()
|
|
68
|
+
: await driver.tap(find(screen, s)?.ref ?? "missing");
|
|
69
|
+
screen = need(r, `replaying "${describeStep(s)}"`);
|
|
70
|
+
}
|
|
71
|
+
// Errors raised while replaying are drained here. On a later replay they would repeat an earlier finding.
|
|
72
|
+
// The caller of the first replay of a start path keeps them.
|
|
73
|
+
return { screen, errors: await driver.errors() };
|
|
74
|
+
}
|
|
75
|
+
const find = (tree, s) => elements(tree).find((e) => e.role === s.role && e.name === s.name && e.nth === s.nth);
|
|
76
|
+
async function checkErrors(screen, path) {
|
|
77
|
+
for (const e of await driver.errors())
|
|
78
|
+
add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
|
|
79
|
+
const red = redScreen(screen);
|
|
80
|
+
if (red)
|
|
81
|
+
add({ kind: "error", message: `Red screen: the screen shows "${red}"`, screen, steps: path });
|
|
82
|
+
}
|
|
83
|
+
async function visit(screen, path, depth) {
|
|
84
|
+
const sig = signature(screen);
|
|
85
|
+
if (seenScreens.has(sig))
|
|
86
|
+
return;
|
|
87
|
+
seenScreens.add(sig);
|
|
88
|
+
await checkErrors(screen, path);
|
|
89
|
+
const els = elements(screen);
|
|
90
|
+
if (!els.length)
|
|
91
|
+
add({ kind: "dead_end", message: "Dead end: the screen has no actionable element", screen, steps: path });
|
|
92
|
+
for (const e of els)
|
|
93
|
+
if (!e.name)
|
|
94
|
+
add({ kind: "unnamed", message: `Unnamed ${e.role}: it has no accessible name`, screen, steps: path });
|
|
95
|
+
let current = screen;
|
|
96
|
+
for (const e of els) {
|
|
97
|
+
if (!e.name || /^(back|go back)$/i.test(e.name))
|
|
98
|
+
continue;
|
|
99
|
+
if (steps >= maxSteps) {
|
|
100
|
+
stopped ??= `max-steps (${maxSteps}) reached`;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const target = find(current, e);
|
|
104
|
+
if (!target) {
|
|
105
|
+
current = (await replay(path)).screen;
|
|
106
|
+
if (!find(current, e))
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const step = { action: "tap", role: e.role, name: e.name, nth: e.nth };
|
|
110
|
+
const here = [...path, step];
|
|
111
|
+
steps++;
|
|
112
|
+
const r = await driver.tap(find(current, e).ref);
|
|
113
|
+
if (r.delivery === "not_delivered")
|
|
114
|
+
add({ kind: "not_delivered", message: `Not delivered (${r.reason ?? "unknown reason"}): ${r.error ?? "the app did not receive the tap"}`, screen: current, steps: here });
|
|
115
|
+
else if (!r.ok)
|
|
116
|
+
add({ kind: "action_failed", message: `Action failed${r.code ? ` [${r.code}]` : ""}: ${r.error ?? "unknown"}`, screen: current, steps: here });
|
|
117
|
+
const after = r.screen ?? current;
|
|
118
|
+
await checkErrors(after, here);
|
|
119
|
+
const crashed = !r.ok || r.screen == null || !!redScreen(after);
|
|
120
|
+
const next = signature(after);
|
|
121
|
+
if (!crashed && next !== signature(current) && !seenScreens.has(next)) {
|
|
122
|
+
if (depth >= maxDepth) {
|
|
123
|
+
cut++;
|
|
124
|
+
seenScreens.add(next);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
await visit(after, here, depth + 1);
|
|
128
|
+
if (steps >= maxSteps && stopped)
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Back to this screen: Back button first, a full replay if that does not land here.
|
|
133
|
+
if (next !== signature(current) || crashed) {
|
|
134
|
+
let b;
|
|
135
|
+
if (!crashed && hasBack(elements(after)))
|
|
136
|
+
b = await driver.back();
|
|
137
|
+
current = b?.ok && b.screen != null && signature(b.screen) === sig ? b.screen : (await replay(path)).screen;
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
current = after;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const starts = [[{ action: "open" }], ...(o.routes ?? []).map((url) => [{ action: "open" }, { action: "open_url", url }])];
|
|
144
|
+
let error = null;
|
|
145
|
+
try {
|
|
146
|
+
for (const path of starts) {
|
|
147
|
+
if (stopped)
|
|
148
|
+
break;
|
|
149
|
+
const { screen, errors } = await replay(path);
|
|
150
|
+
// The first replay of a start path is the only time load-time errors are seen.
|
|
151
|
+
for (const e of errors)
|
|
152
|
+
add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
|
|
153
|
+
await visit(screen, path, 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
error = e instanceof Error ? e.message : String(e);
|
|
158
|
+
stopped = `the walk failed: ${error}`;
|
|
159
|
+
}
|
|
160
|
+
if (!stopped && cut)
|
|
161
|
+
stopped = `max-depth (${maxDepth}) reached: ${cut} screen${cut === 1 ? "" : "s"} were not walked`;
|
|
162
|
+
return { app: o.app ?? "", generatedAt: new Date().toISOString(), steps, screens: seenScreens.size, maxSteps, maxDepth, stoppedEarly: stopped, error, findings, parityLimits: PARITY_LIMITS };
|
|
163
|
+
}
|
|
164
|
+
export function renderMarkdown(r) {
|
|
165
|
+
const L = [`# Explore report: ${r.app}`, "", "## Simulator limits", "", ...r.parityLimits.map((p) => `- ${p}`), "", "## Run", "",
|
|
166
|
+
`- Steps taken: ${r.steps} (limit ${r.maxSteps}). Screens seen: ${r.screens}. Depth limit: ${r.maxDepth}.`,
|
|
167
|
+
`- ${r.stoppedEarly ? `Stopped early: ${r.stoppedEarly}.` : "Stopped early: no. The walk ended because every reachable screen within the limits was visited."}`, ""];
|
|
168
|
+
L.push("## Findings", "");
|
|
169
|
+
if (!r.findings.length)
|
|
170
|
+
L.push(`No findings in ${r.steps} steps.`, "");
|
|
171
|
+
r.findings.forEach((f, i) => {
|
|
172
|
+
L.push(`### ${i + 1}. ${f.message}`, "", "Steps to reproduce:", "", ...f.steps.map((s, j) => `${j + 1}. ${describeStep(s)}`), "", "Typed steps:", "", "```json", JSON.stringify(f.steps), "```", "");
|
|
173
|
+
});
|
|
174
|
+
return L.join("\n");
|
|
175
|
+
}
|
|
176
|
+
/** Driver for a running simulator. */
|
|
177
|
+
export function simDriver(base, app, session = `explore-${Date.now().toString(36)}`) {
|
|
178
|
+
const post = async (path, body) => {
|
|
179
|
+
const r = await fetch(base.replace(/\/$/, "") + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
|
|
180
|
+
if (!r.ok)
|
|
181
|
+
throw new Error(`simulator ${path} -> ${r.status}`);
|
|
182
|
+
return (await r.json());
|
|
183
|
+
};
|
|
184
|
+
const act = (body) => post("/act", { session, ...body });
|
|
185
|
+
return {
|
|
186
|
+
open: () => post("/session", { session, app }),
|
|
187
|
+
openUrl: (url) => act({ action: "open_url", url }),
|
|
188
|
+
tap: (ref) => act({ action: "tap", ref }),
|
|
189
|
+
back: () => act({ action: "back" }),
|
|
190
|
+
errors: async () => (await act({ action: "errors" })).errors ?? [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
|
|
194
|
+
export async function startSim(a, cfg) {
|
|
195
|
+
let base = a.sim;
|
|
196
|
+
let child;
|
|
197
|
+
if (!base) {
|
|
198
|
+
const port = a.port || "4546";
|
|
199
|
+
if (!(await portFree(Number(port))))
|
|
200
|
+
throw new Error(`Port ${port} is in use. Stop the other simulator, pass --port <n>, or pass --sim <url> to use one that is running.`);
|
|
201
|
+
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
202
|
+
console.error(`Building ${cfg.name} for the simulator…`);
|
|
203
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
204
|
+
}
|
|
205
|
+
ensurePlaywright({ cwd: process.cwd() });
|
|
206
|
+
let server;
|
|
207
|
+
try {
|
|
208
|
+
server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new Error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
|
|
212
|
+
}
|
|
213
|
+
let exited = false;
|
|
214
|
+
child = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId], { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
|
|
215
|
+
child.on("exit", () => { exited = true; });
|
|
216
|
+
if (!(await waitListening(Number(port), () => exited)))
|
|
217
|
+
throw new Error("the simulator exited before it was ready");
|
|
218
|
+
base = `http://localhost:${port}`;
|
|
219
|
+
}
|
|
220
|
+
return { base, stop: () => { child?.kill(); } };
|
|
221
|
+
}
|
|
222
|
+
/** Returns the exit code. */
|
|
223
|
+
export async function explore(argv) {
|
|
224
|
+
const a = parse(argv);
|
|
225
|
+
const cfg = loadConfig();
|
|
226
|
+
const num = (k, d) => { if (a[k] === undefined)
|
|
227
|
+
return d; const n = Number(a[k]); if (!Number.isInteger(n) || n < 1)
|
|
228
|
+
throw new Error(`--${k} must be a whole number of 1 or more`); return n; };
|
|
229
|
+
const maxDepth = num("max-depth", 3), maxSteps = num("max-steps", 100);
|
|
230
|
+
const sim = await startSim(a, cfg);
|
|
231
|
+
const base = sim.base;
|
|
232
|
+
try {
|
|
233
|
+
const report = await walk(simDriver(base, cfg.appId), { routes: cfg.routes, maxDepth, maxSteps, app: cfg.appId });
|
|
234
|
+
const out = a.out ?? "explore-report";
|
|
235
|
+
writeFileSync(`${out}.md`, renderMarkdown(report));
|
|
236
|
+
writeFileSync(`${out}.json`, JSON.stringify(report, null, 2) + "\n");
|
|
237
|
+
console.log(`${report.findings.length ? `${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}` : `No findings in ${report.steps} steps`}${report.stoppedEarly ? `. Stopped early: ${report.stoppedEarly}` : ""}. Wrote ${out}.md and ${out}.json`);
|
|
238
|
+
if (report.error) {
|
|
239
|
+
console.error(report.error);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
return report.findings.length && a["fail-on-findings"] === "true" ? 1 : 0;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
sim.stop();
|
|
246
|
+
}
|
|
247
|
+
}
|