@aarwitz/tapp 0.15.0 → 0.15.1
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/Harness/OCQAHarness/AppDelegate.swift +1 -1
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +2 -2
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +2 -2
- package/Harness/generate-harness-xcodeproj.rb +3 -3
- package/README.md +6 -7
- package/bin/tapp.js +16 -16
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +1 -6
- package/mcp-server/src/browser-product.js +1 -1
- package/mcp-server/src/enrich.js +1 -1
- package/mcp-server/src/index.js +35 -35
- package/mcp-server/src/product-execution.js +1 -1
- package/mcp-server/src/product-operations.js +1 -1
- package/mcp-server/src/report.js +3 -3
- package/package.json +15 -3
- package/scripts/ci-gate.sh +3 -3
- package/scripts/flow_ai_judge.py +1 -1
- package/scripts/quick-capture.sh +15 -14
- package/scripts/run-flow.sh +6 -6
- package/scripts/android-corpus-e2e.sh +0 -30
- package/scripts/cleanup-xcode.sh +0 -157
- package/scripts/corpus-apps.txt +0 -9
- package/scripts/corpus-sweep.sh +0 -121
- package/scripts/coverage-eval.sh +0 -92
- package/scripts/coverage_eval_parse.py +0 -95
- package/scripts/deploy-and-build.sh +0 -99
- package/scripts/mutation-recall-desktop.sh +0 -186
- package/scripts/mutation-recall.sh +0 -121
- package/scripts/mutation_lib.py +0 -128
- package/scripts/mutation_operators.py +0 -144
- package/scripts/validation-matrix.sh +0 -146
- package/scripts/vision-fp-eval.sh +0 -206
- package/scripts/vision_escalation_responder.py +0 -147
- package/scripts/vision_fp_probe.py +0 -221
|
@@ -8,7 +8,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
8
8
|
let vc = UIViewController()
|
|
9
9
|
vc.view.backgroundColor = .black
|
|
10
10
|
let label = UILabel()
|
|
11
|
-
label.text = "
|
|
11
|
+
label.text = "Tapp Harness"
|
|
12
12
|
label.textColor = .white
|
|
13
13
|
label.textAlignment = .center
|
|
14
14
|
label.frame = vc.view.bounds
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
CODE_SIGN_STYLE = Automatic;
|
|
167
167
|
DEVELOPMENT_TEAM = "";
|
|
168
168
|
INFOPLIST_FILE = OCQAHarness/Info.plist;
|
|
169
|
-
PRODUCT_BUNDLE_IDENTIFIER =
|
|
169
|
+
PRODUCT_BUNDLE_IDENTIFIER = io.github.aarwitz.tapp.harness;
|
|
170
170
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
171
171
|
SWIFT_VERSION = 5.0;
|
|
172
172
|
TARGETED_DEVICE_FAMILY = "1,2";
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
CODE_SIGN_STYLE = Automatic;
|
|
187
187
|
DEVELOPMENT_TEAM = "";
|
|
188
188
|
INFOPLIST_FILE = OCQAHarnessUITests/Info.plist;
|
|
189
|
-
PRODUCT_BUNDLE_IDENTIFIER =
|
|
189
|
+
PRODUCT_BUNDLE_IDENTIFIER = io.github.aarwitz.tapp.harness.uitests;
|
|
190
190
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
191
191
|
SWIFT_VERSION = 5.0;
|
|
192
192
|
TARGETED_DEVICE_FAMILY = "1,2";
|
|
@@ -670,7 +670,7 @@ class ExplorerTests: XCTestCase {
|
|
|
670
670
|
|
|
671
671
|
/// Replays an explicit, config-driven login flow (OCQA_LOGIN_STEPS) before exploration — for the
|
|
672
672
|
/// custom login UIs the heuristic preamble can't parse, which are the #1 reason a real app stays
|
|
673
|
-
/// invisible to
|
|
673
|
+
/// invisible to Tapp. Steps are a JSON array of {action: type|tap|wait, target: <id-or-label>,
|
|
674
674
|
/// value?: <text; "$TEST_EMAIL"/"$TEST_PASSWORD" substituted from stored creds>, timeoutMs?}.
|
|
675
675
|
/// Returns true if any step ran (so the caller skips the heuristic preamble). Tolerant: a failed
|
|
676
676
|
/// step is logged but doesn't abort — exploration still proceeds, and the coverage eval reveals
|
|
@@ -1320,7 +1320,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1320
1320
|
// so it surfaces here at the next tree read rather than at the early crash check.
|
|
1321
1321
|
// Report it against the action that led here instead of breaking silently. (Real:
|
|
1322
1322
|
// Wikipedia's WMFCaptchaViewController.refreshImage assertionFailure crashes the
|
|
1323
|
-
// app during login — a genuine production crash
|
|
1323
|
+
// app during login — a genuine production crash Tapp must surface.)
|
|
1324
1324
|
if app.state != .runningForeground {
|
|
1325
1325
|
let where_ = pendingTransitionFrom?.title ?? "the previous screen"
|
|
1326
1326
|
let crashKey = "crash-async:\(where_)"
|
|
@@ -182,7 +182,7 @@ pbxproj = <<~PBX
|
|
|
182
182
|
CODE_SIGN_STYLE = Automatic;
|
|
183
183
|
DEVELOPMENT_TEAM = "";
|
|
184
184
|
INFOPLIST_FILE = OCQAHarness/Info.plist;
|
|
185
|
-
PRODUCT_BUNDLE_IDENTIFIER =
|
|
185
|
+
PRODUCT_BUNDLE_IDENTIFIER = io.github.aarwitz.tapp.harness;
|
|
186
186
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
187
187
|
SWIFT_VERSION = 5.0;
|
|
188
188
|
TARGETED_DEVICE_FAMILY = "1,2";
|
|
@@ -202,7 +202,7 @@ pbxproj = <<~PBX
|
|
|
202
202
|
CODE_SIGN_STYLE = Automatic;
|
|
203
203
|
DEVELOPMENT_TEAM = "";
|
|
204
204
|
INFOPLIST_FILE = OCQAHarnessUITests/Info.plist;
|
|
205
|
-
PRODUCT_BUNDLE_IDENTIFIER =
|
|
205
|
+
PRODUCT_BUNDLE_IDENTIFIER = io.github.aarwitz.tapp.harness.uitests;
|
|
206
206
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
207
207
|
SWIFT_VERSION = 5.0;
|
|
208
208
|
TARGETED_DEVICE_FAMILY = "1,2";
|
|
@@ -249,6 +249,6 @@ SCHEME
|
|
|
249
249
|
File.write("#{scheme_dir}/OCQAHarnessUITests.xcscheme", scheme)
|
|
250
250
|
|
|
251
251
|
puts "✅ Generated OCQAHarness.xcodeproj"
|
|
252
|
-
puts " Host app:
|
|
252
|
+
puts " Host app: io.github.aarwitz.tapp.harness"
|
|
253
253
|
puts " Test target: OCQAHarnessUITests"
|
|
254
254
|
puts " Platform: iOS 16.0+"
|
package/README.md
CHANGED
|
@@ -65,8 +65,7 @@ npx -y @aarwitz/tapp qa # finds your Xcode project → builds → installs on
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
The product, executable, and package leaf are all Tapp: npm distributes it as
|
|
68
|
-
`@aarwitz/tapp`, while the installed command remains `tapp`.
|
|
69
|
-
and `npx tapp-mcp ...` configurations remain supported as deprecated compatibility paths.
|
|
68
|
+
`@aarwitz/tapp`, while the installed command remains `tapp`.
|
|
70
69
|
|
|
71
70
|
To bootstrap maintained release infrastructure, preview the repository model and grounded plan
|
|
72
71
|
before Tapp writes anything:
|
|
@@ -292,7 +291,7 @@ npx -y @aarwitz/tapp ci --app path/to/MyApp.app \
|
|
|
292
291
|
```
|
|
293
292
|
|
|
294
293
|
See the self-test at
|
|
295
|
-
[.github/workflows/
|
|
294
|
+
[.github/workflows/tapp-gate-example.yml](.github/workflows/tapp-gate-example.yml) for
|
|
296
295
|
Flows, auth inputs, and other controls. GitHub-hosted iOS runs require a macOS runner; the first
|
|
297
296
|
run also builds the XCUITest harness, so budget roughly 5–10 minutes depending on app size.
|
|
298
297
|
|
|
@@ -317,8 +316,8 @@ environment. Add
|
|
|
317
316
|
[`docs/scenarios.md`](docs/scenarios.md). Automatic
|
|
318
317
|
baselines are isolated by platform and target, so two same-platform apps are never compared.
|
|
319
318
|
|
|
320
|
-
**The hosted service
|
|
321
|
-
|
|
319
|
+
**The hosted service is under development and is not currently offered for customer repositories.**
|
|
320
|
+
Do not upload private code or credentials to an old preview. The retained
|
|
322
321
|
cloud prototype is not the production SaaS boundary. Use the local Release Studio and the portable
|
|
323
322
|
GitHub Action in infrastructure you control until the new account, tenant authorization, private
|
|
324
323
|
evidence, and isolated-worker boundary passes security review.
|
|
@@ -352,8 +351,8 @@ verdict calculation run entirely locally — no telemetry, nothing phones home.
|
|
|
352
351
|
features are explicit: finding enrichment requires `TAPP_ENABLE_REMOTE_AI=1` (an ambient
|
|
353
352
|
API key alone never changes data handling), and AI flow generation / `assert_ai` only run
|
|
354
353
|
when you invoke them; these send selected metadata (screen names, finding titles) to your
|
|
355
|
-
configured model provider.
|
|
356
|
-
aliases.
|
|
354
|
+
configured model provider. Runtime configuration uses `TAPP_*` environment variables; deprecated
|
|
355
|
+
aliases remain readable for compatibility.
|
|
357
356
|
|
|
358
357
|
Committed Flow replay, recording a driven session, autonomous exploration, exact assertions,
|
|
359
358
|
regression comparison, and CI gating require **no API key and no coding agent at runtime**. AI is
|
package/bin/tapp.js
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
//
|
|
17
17
|
// All writable output (captures, harness build cache) goes to ~/.tapp (override
|
|
18
18
|
// with TAPP_HOME). The package directory itself is never written to.
|
|
19
|
-
// (Internally exported as AUTOTAP_HOME — the env name the bundled scripts read.)
|
|
20
19
|
|
|
21
20
|
import { spawnSync, spawn } from "node:child_process";
|
|
22
21
|
import fs from "node:fs";
|
|
@@ -29,10 +28,11 @@ const packageRoot = path.resolve(__dirname, "..");
|
|
|
29
28
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
30
29
|
|
|
31
30
|
// Redirect all writable output away from the (possibly read-only) package dir.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// The old environment alias remains populated only for older helper integrations.
|
|
32
|
+
const tappHome = (process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp")).trim();
|
|
33
|
+
process.env.TAPP_HOME = tappHome;
|
|
34
|
+
if (!process.env.AUTOTAP_HOME) process.env.AUTOTAP_HOME = tappHome;
|
|
35
|
+
fs.mkdirSync(tappHome, { recursive: true });
|
|
36
36
|
|
|
37
37
|
const [, , command = "help", ...rest] = process.argv;
|
|
38
38
|
|
|
@@ -88,7 +88,7 @@ function bootBestSimulator(preferredName = "iPhone 16 Pro") {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function harnessXctestrun() {
|
|
91
|
-
const dir = path.join(
|
|
91
|
+
const dir = path.join(tappHome, "harness-derived", "Build", "Products");
|
|
92
92
|
try {
|
|
93
93
|
const found = fs.readdirSync(dir).find((f) => f.endsWith(".xctestrun"));
|
|
94
94
|
return found ? path.join(dir, found) : null;
|
|
@@ -171,7 +171,7 @@ function androidTarget(flags, target = "") {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
function saveShot(img, outFlag, name) {
|
|
174
|
-
const out = outFlag || path.join(
|
|
174
|
+
const out = outFlag || path.join(tappHome, "shots", name);
|
|
175
175
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
176
176
|
fs.writeFileSync(out, Buffer.from(img.data, "base64"));
|
|
177
177
|
return out;
|
|
@@ -472,7 +472,7 @@ switch (command) {
|
|
|
472
472
|
await driver.forceStop();
|
|
473
473
|
console.log(`🚀 Launched \`${target.appId}\` (Android)\n`);
|
|
474
474
|
console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
475
|
-
const out = typeof flags.out === "string" ? flags.out : path.join(
|
|
475
|
+
const out = typeof flags.out === "string" ? flags.out : path.join(tappHome, "shots", `${target.appId}-${Date.now()}.png`);
|
|
476
476
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
477
477
|
fs.writeFileSync(out, data);
|
|
478
478
|
console.log(`\n📸 Screenshot: ${out}`);
|
|
@@ -663,7 +663,7 @@ switch (command) {
|
|
|
663
663
|
url: typeof flags.url === "string" ? flags.url : task.url || "",
|
|
664
664
|
reset: task.reset || "launch", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan,
|
|
665
665
|
};
|
|
666
|
-
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(
|
|
666
|
+
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(tappHome, "tasks", `${task.name}-${process.pid}-${Date.now()}.json`));
|
|
667
667
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
668
668
|
fs.writeFileSync(out, JSON.stringify(flow, null, 2) + "\n");
|
|
669
669
|
if (verb === "compile") {
|
|
@@ -712,8 +712,8 @@ switch (command) {
|
|
|
712
712
|
break;
|
|
713
713
|
}
|
|
714
714
|
const token = `${process.pid}-${Date.now()}`;
|
|
715
|
-
const flowLog = path.join(
|
|
716
|
-
const evidenceDir = path.join(
|
|
715
|
+
const flowLog = path.join(tappHome, "flows", `${token}.log`);
|
|
716
|
+
const evidenceDir = path.join(tappHome, "captures", `flow-${platform}-${token}`);
|
|
717
717
|
fs.mkdirSync(path.dirname(flowLog), { recursive: true });
|
|
718
718
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
719
719
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
@@ -779,7 +779,7 @@ switch (command) {
|
|
|
779
779
|
let compiled;
|
|
780
780
|
try { compiled = compileReleaseContract(contract, { platform, sourcePath: contractPath }); }
|
|
781
781
|
catch (error) { console.error(`❌ Could not compile Release Contract: ${error.message}`); process.exit(2); }
|
|
782
|
-
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(
|
|
782
|
+
const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(tappHome, "contracts", `${contract.name}-${process.pid}-${Date.now()}.json`));
|
|
783
783
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
784
784
|
fs.writeFileSync(out, JSON.stringify(compiled, null, 2) + "\n");
|
|
785
785
|
if (verb === "compile") {
|
|
@@ -893,8 +893,8 @@ switch (command) {
|
|
|
893
893
|
break;
|
|
894
894
|
}
|
|
895
895
|
const token = `${process.pid}-${Date.now()}`;
|
|
896
|
-
const flowLog = path.join(
|
|
897
|
-
const evidenceDir = path.join(
|
|
896
|
+
const flowLog = path.join(tappHome, "scenarios", `${token}.log`);
|
|
897
|
+
const evidenceDir = path.join(tappHome, "captures", `scenario-web-${token}`);
|
|
898
898
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
899
899
|
const url = typeof flags.url === "string" ? flags.url : scenario.url || scenario.app || "";
|
|
900
900
|
const result = spawnSync(process.execPath, [path.join(packageRoot, "scripts", "run-web-scenario.js"), absolute, url], { stdio: "inherit", env });
|
|
@@ -1007,7 +1007,7 @@ switch (command) {
|
|
|
1007
1007
|
console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
1010
|
-
console.log(`\n Home: ${
|
|
1010
|
+
console.log(`\n Home: ${tappHome}`);
|
|
1011
1011
|
console.log(healthy ? "\nReady. Add to your agent: claude mcp add tapp -- npx -y @aarwitz/tapp mcp" : "\nFix the ❌ items above, then re-run: tapp doctor");
|
|
1012
1012
|
process.exit(healthy ? 0 : 1);
|
|
1013
1013
|
}
|
|
@@ -1284,7 +1284,7 @@ switch (command) {
|
|
|
1284
1284
|
|
|
1285
1285
|
case "report": {
|
|
1286
1286
|
// Regenerate + open the HTML evidence page for a capture (default: the latest).
|
|
1287
|
-
const capturesDir = path.join(
|
|
1287
|
+
const capturesDir = path.join(tappHome, "captures");
|
|
1288
1288
|
const repoCaptures = path.join(packageRoot, "captures");
|
|
1289
1289
|
const roots = [capturesDir, repoCaptures].filter((d) => fs.existsSync(d));
|
|
1290
1290
|
const runs = roots
|
package/docs/BROWSER-PRODUCT.md
CHANGED
|
@@ -62,7 +62,7 @@ intent.
|
|
|
62
62
|
|
|
63
63
|
## Hosted relationship
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
The future hosted application will present the same product journey through a different adapter: application
|
|
66
66
|
accounts/organizations, GitHub App repository authorization, private storage, a durable queue, and
|
|
67
67
|
isolated managed workers. It cannot reuse the loopback session, local `gh` authority, filesystem
|
|
68
68
|
boundary, or in-memory ownership assumptions.
|
|
@@ -184,12 +184,7 @@ plan while preserving fields from newer engine versions; committed contract inte
|
|
|
184
184
|
through these proposal controls. Flow Map merges the repository `.autotap/ui-map.json` with current
|
|
185
185
|
run evidence instead of building a separate desktop-only graph.
|
|
186
186
|
|
|
187
|
-
Schema compatibility
|
|
188
|
-
|
|
189
|
-
```bash
|
|
190
|
-
AutoTap.app/Contents/MacOS/AutoTap --verify-artifacts /path/to/repository \
|
|
191
|
-
--out /tmp/tapp-desktop-artifacts.json
|
|
192
|
-
```
|
|
187
|
+
Schema compatibility is exercised by the repository's protocol tests and retained desktop reader.
|
|
193
188
|
|
|
194
189
|
`plan generate` handles only explicitly approved proposals. Grounded cross-actor proposals preserve
|
|
195
190
|
actor-attributed Task calls, captured output variables, bounded eventual assertions, and the
|
|
@@ -109,7 +109,7 @@ function assetPath(pathname) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
function captureRoot() {
|
|
112
|
-
return path.join(process.env.
|
|
112
|
+
return path.join(process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp"), "captures");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function capturePath(captureId, relative = "report.html") {
|
package/mcp-server/src/enrich.js
CHANGED
|
@@ -40,7 +40,7 @@ export async function enrichFindings(findings, { backend, callModel, screens = [
|
|
|
40
40
|
`Title: ${f.title}`;
|
|
41
41
|
try {
|
|
42
42
|
const res = await Promise.race([
|
|
43
|
-
callModel(backend, { system, userText, model: process.env.AUTOTAP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
|
|
43
|
+
callModel(backend, { system, userText, model: process.env.TAPP_FINDING_MODEL || process.env.AUTOTAP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
|
|
44
44
|
new Promise((r) => setTimeout(() => r({ error: "timeout" }), TIMEOUT_MS)),
|
|
45
45
|
]);
|
|
46
46
|
const parsed = res && !res.error ? parseEnrichment(res.text) : null;
|
package/mcp-server/src/index.js
CHANGED
|
@@ -18,10 +18,10 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
18
18
|
const __dirname = path.dirname(__filename);
|
|
19
19
|
const repoRoot = path.resolve(__dirname, "../..");
|
|
20
20
|
const scriptsDir = path.join(repoRoot, "scripts");
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
const
|
|
24
|
-
const capturesDir =
|
|
21
|
+
// TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
|
|
22
|
+
// The old alias remains a read-only fallback; unset repository development stays local.
|
|
23
|
+
const tappHome = (process.env.TAPP_HOME || process.env.AUTOTAP_HOME || "").trim();
|
|
24
|
+
const capturesDir = tappHome ? path.join(tappHome, "captures") : path.join(repoRoot, "captures");
|
|
25
25
|
const MAX_OUTPUT_CHARS = 60_000;
|
|
26
26
|
const requiredAuthToken = (process.env.TAPP_MCP_TOKEN || process.env.AUTOTAP_MCP_TOKEN || "").trim();
|
|
27
27
|
|
|
@@ -83,7 +83,7 @@ function ensureAuthorized(args = {}) {
|
|
|
83
83
|
const provided = typeof args.authToken === "string" ? args.authToken.trim() : "";
|
|
84
84
|
if (provided !== requiredAuthToken) {
|
|
85
85
|
return errorResult("Unauthorized", {
|
|
86
|
-
reason: "Provide valid authToken when
|
|
86
|
+
reason: "Provide valid authToken when TAPP_MCP_TOKEN is set",
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -339,7 +339,7 @@ export async function buildAppForSim({ dir, container, scheme, configuration = "
|
|
|
339
339
|
};
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
|
-
const derived = path.join(
|
|
342
|
+
const derived = path.join(tappHome || os.tmpdir(), "app-builds", schemeName.replace(/[^a-zA-Z0-9]/g, "_"));
|
|
343
343
|
const build = await runCommand(
|
|
344
344
|
"xcodebuild",
|
|
345
345
|
[
|
|
@@ -1065,7 +1065,7 @@ export async function captureScreenshotImage(maxWidth) {
|
|
|
1065
1065
|
// change data-handling behavior. A subscription token is an explicit tapp choice, and
|
|
1066
1066
|
// explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
|
|
1067
1067
|
export function remoteAiOptedIn(env = process.env) {
|
|
1068
|
-
if ((env.
|
|
1068
|
+
if ((env.TAPP_SUBSCRIPTION_TOKEN || env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim()) return true;
|
|
1069
1069
|
return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
|
|
1070
1070
|
}
|
|
1071
1071
|
|
|
@@ -1077,9 +1077,9 @@ export function isInsideDir(root, p) {
|
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
1079
|
function resolveModelBackend() {
|
|
1080
|
-
const token = (process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1080
|
+
const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
|
|
1081
1081
|
if (token) {
|
|
1082
|
-
const base = (process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1082
|
+
const base = (process.env.TAPP_PROXY_URL || process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
|
|
1083
1083
|
const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
|
|
1084
1084
|
return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
|
|
1085
1085
|
}
|
|
@@ -1091,7 +1091,7 @@ function resolveModelBackend() {
|
|
|
1091
1091
|
}
|
|
1092
1092
|
|
|
1093
1093
|
async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
|
|
1094
|
-
const body = JSON.stringify({ model: model || process.env.AUTOTAP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
|
|
1094
|
+
const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL || process.env.AUTOTAP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
|
|
1095
1095
|
const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
|
|
1096
1096
|
if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
|
|
1097
1097
|
const data = await res.json();
|
|
@@ -1832,7 +1832,7 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
1832
1832
|
command = process.execPath;
|
|
1833
1833
|
startArgs = [path.join(__dirname, "static-server.js"), startDir, String(port)];
|
|
1834
1834
|
}
|
|
1835
|
-
const logDir = path.join(
|
|
1835
|
+
const logDir = path.join(tappHome || os.tmpdir(), "init-runtime");
|
|
1836
1836
|
fs.mkdirSync(logDir, { recursive: true });
|
|
1837
1837
|
const logPath = path.join(logDir, `web-${process.pid}-${Date.now()}.log`);
|
|
1838
1838
|
const child = spawn(command, startArgs, {
|
|
@@ -1938,7 +1938,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1938
1938
|
properties: {
|
|
1939
1939
|
authToken: {
|
|
1940
1940
|
type: "string",
|
|
1941
|
-
description: "Required when
|
|
1941
|
+
description: "Required when TAPP_MCP_TOKEN is set",
|
|
1942
1942
|
},
|
|
1943
1943
|
projectDir: { type: "string", description: "Repo/dir to search for the .xcworkspace/.xcodeproj (default: cwd)" },
|
|
1944
1944
|
scheme: { type: "string", description: "Scheme to build (default: auto-detected)" },
|
|
@@ -1956,7 +1956,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1956
1956
|
properties: {
|
|
1957
1957
|
authToken: {
|
|
1958
1958
|
type: "string",
|
|
1959
|
-
description: "Required when
|
|
1959
|
+
description: "Required when TAPP_MCP_TOKEN is set",
|
|
1960
1960
|
},
|
|
1961
1961
|
mode: {
|
|
1962
1962
|
type: "string",
|
|
@@ -2075,7 +2075,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2075
2075
|
inputSchema: {
|
|
2076
2076
|
type: "object",
|
|
2077
2077
|
properties: {
|
|
2078
|
-
authToken: { type: "string", description: "Required when
|
|
2078
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2079
2079
|
appBundleId: { type: "string", description: "iOS: bundle id of the installed app to test, e.g. com.acme.app. Provide exactly one of appBundleId | url." },
|
|
2080
2080
|
androidAppId: { type: "string", description: "Android: application id installed on a connected emulator/device, e.g. com.acme.app." },
|
|
2081
2081
|
apkPath: { type: "string", description: "Android: optional APK to install before testing." },
|
|
@@ -2137,7 +2137,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2137
2137
|
inputSchema: {
|
|
2138
2138
|
type: "object",
|
|
2139
2139
|
properties: {
|
|
2140
|
-
authToken: { type: "string", description: "Required when
|
|
2140
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2141
2141
|
operation: { type: "string", enum: ["inspect", "write", "refresh", "explore"], default: "inspect" },
|
|
2142
2142
|
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2143
2143
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional target filter" },
|
|
@@ -2164,7 +2164,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2164
2164
|
inputSchema: {
|
|
2165
2165
|
type: "object",
|
|
2166
2166
|
properties: {
|
|
2167
|
-
authToken: { type: "string", description: "Required when
|
|
2167
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2168
2168
|
operation: { type: "string", enum: ["read", "set"], default: "read" },
|
|
2169
2169
|
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2170
2170
|
name: { type: "string", description: "Set: stable actor name" },
|
|
@@ -2184,7 +2184,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2184
2184
|
inputSchema: {
|
|
2185
2185
|
type: "object",
|
|
2186
2186
|
properties: {
|
|
2187
|
-
authToken: { type: "string", description: "Required when
|
|
2187
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2188
2188
|
operation: { type: "string", enum: ["read", "review", "generate", "validate", "promote"], default: "read" },
|
|
2189
2189
|
planPath: { type: "string", description: "Repo-relative plan path; default .autotap/release-plan.json" },
|
|
2190
2190
|
projectDir: { type: "string", description: "Generate: repo-relative project root containing the scoped .autotap Task directories" },
|
|
@@ -2211,7 +2211,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2211
2211
|
inputSchema: {
|
|
2212
2212
|
type: "object",
|
|
2213
2213
|
properties: {
|
|
2214
|
-
authToken: { type: "string", description: "Required when
|
|
2214
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2215
2215
|
operation: { type: "string", enum: ["inspect", "install", "baseline"], default: "inspect" },
|
|
2216
2216
|
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2217
2217
|
modelPath: { type: "string", description: "Repo-relative application model path; defaults to <projectDir>/.autotap/application-model.json" },
|
|
@@ -2237,7 +2237,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2237
2237
|
inputSchema: {
|
|
2238
2238
|
type: "object",
|
|
2239
2239
|
properties: {
|
|
2240
|
-
authToken: { type: "string", description: "Required when
|
|
2240
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2241
2241
|
operation: { type: "string", enum: ["read", "build", "diff"], default: "read" },
|
|
2242
2242
|
captureId: { type: "string", description: "Read/build from this Tapp capture's ocqa-markers.txt/ui-map.json" },
|
|
2243
2243
|
mapPath: { type: "string", description: "Repo-relative UI Map path for read, or build output (default .autotap/ui-map.json)" },
|
|
@@ -2261,7 +2261,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2261
2261
|
type: "object",
|
|
2262
2262
|
required: ["taskPath"],
|
|
2263
2263
|
properties: {
|
|
2264
|
-
authToken: { type: "string", description: "Required when
|
|
2264
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2265
2265
|
operation: { type: "string", enum: ["read", "validate", "compile"], default: "validate" },
|
|
2266
2266
|
taskPath: { type: "string", description: "Repo-relative .autotap/tasks/*.yml|json file" },
|
|
2267
2267
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Implementation to validate/compile" },
|
|
@@ -2282,7 +2282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2282
2282
|
type: "object",
|
|
2283
2283
|
required: ["contractPath"],
|
|
2284
2284
|
properties: {
|
|
2285
|
-
authToken: { type: "string", description: "Required when
|
|
2285
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2286
2286
|
operation: { type: "string", enum: ["read", "validate", "compile", "run"], default: "validate" },
|
|
2287
2287
|
contractPath: { type: "string", description: "Repo-relative .autotap/contracts/*.contract.ts file" },
|
|
2288
2288
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Target platform; optional when the contract declares exactly one" },
|
|
@@ -2305,7 +2305,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2305
2305
|
inputSchema: {
|
|
2306
2306
|
type: "object",
|
|
2307
2307
|
properties: {
|
|
2308
|
-
authToken: { type: "string", description: "Required when
|
|
2308
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2309
2309
|
operation: { type: "string", enum: ["plan", "adopt"], default: "plan" },
|
|
2310
2310
|
changedFiles: {
|
|
2311
2311
|
type: "array", minItems: 1,
|
|
@@ -2350,7 +2350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2350
2350
|
inputSchema: {
|
|
2351
2351
|
type: "object",
|
|
2352
2352
|
properties: {
|
|
2353
|
-
authToken: { type: "string", description: "Required when
|
|
2353
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2354
2354
|
flow: {
|
|
2355
2355
|
type: "object",
|
|
2356
2356
|
description:
|
|
@@ -2379,7 +2379,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2379
2379
|
inputSchema: {
|
|
2380
2380
|
type: "object",
|
|
2381
2381
|
properties: {
|
|
2382
|
-
authToken: { type: "string", description: "Required when
|
|
2382
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2383
2383
|
scenario: { type: "object", description: "Inline Scenario with {kind:'scenario', platform:'web', actors, steps, setup?, teardown?}" },
|
|
2384
2384
|
scenarioPath: { type: "string", description: "Repo-relative path to a .yml/.json Scenario" },
|
|
2385
2385
|
url: { type: "string", description: "Override the Scenario's web URL" },
|
|
@@ -2400,7 +2400,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2400
2400
|
inputSchema: {
|
|
2401
2401
|
type: "object",
|
|
2402
2402
|
properties: {
|
|
2403
|
-
authToken: { type: "string", description: "Required when
|
|
2403
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2404
2404
|
goal: { type: "string", description: "What the test should do, in plain English (e.g. 'sign in with test creds and reach the dashboard')" },
|
|
2405
2405
|
appBundleId: { type: "string", description: "Bundle id of the installed app to author against" },
|
|
2406
2406
|
captureId: { type: "string", description: "Reuse this capture's grounding instead of exploring (from a prior run_qa, faster)" },
|
|
@@ -2424,7 +2424,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2424
2424
|
inputSchema: {
|
|
2425
2425
|
type: "object",
|
|
2426
2426
|
properties: {
|
|
2427
|
-
authToken: { type: "string", description: "Required when
|
|
2427
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2428
2428
|
name: { type: "string", description: "Human name for the flow, e.g. 'Sign in and reach Home'" },
|
|
2429
2429
|
addFinalAssertion: { type: "boolean", default: true, description: "Append assert_screen for the final screen as a checkpoint" },
|
|
2430
2430
|
replace: { type: "boolean", default: false, description: "Explicitly replace a Flow with the same generated filename. Existing Flows are preserved by default." },
|
|
@@ -2442,7 +2442,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2442
2442
|
inputSchema: {
|
|
2443
2443
|
type: "object",
|
|
2444
2444
|
properties: {
|
|
2445
|
-
authToken: { type: "string", description: "Required when
|
|
2445
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2446
2446
|
appBundleId: { type: "string", description: "iOS bundle id of the installed app" },
|
|
2447
2447
|
androidAppId: { type: "string", description: "Android application id of the installed app" },
|
|
2448
2448
|
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
@@ -2459,7 +2459,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2459
2459
|
inputSchema: {
|
|
2460
2460
|
type: "object",
|
|
2461
2461
|
properties: {
|
|
2462
|
-
authToken: { type: "string", description: "Required when
|
|
2462
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2463
2463
|
maxWidth: { type: "integer", minimum: 200, maximum: 1400, default: 700, description: "Max image width in px (downscaled to keep payload small)" },
|
|
2464
2464
|
},
|
|
2465
2465
|
},
|
|
@@ -2477,7 +2477,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2477
2477
|
inputSchema: {
|
|
2478
2478
|
type: "object",
|
|
2479
2479
|
properties: {
|
|
2480
|
-
authToken: { type: "string", description: "Required when
|
|
2480
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2481
2481
|
appBundleId: { type: "string", description: "iOS bundle id of the installed app" },
|
|
2482
2482
|
androidAppId: { type: "string", description: "Android application id of the installed app" },
|
|
2483
2483
|
apkPath: { type: "string", description: "Android APK to install before launch" },
|
|
@@ -2502,7 +2502,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2502
2502
|
inputSchema: {
|
|
2503
2503
|
type: "object",
|
|
2504
2504
|
properties: {
|
|
2505
|
-
authToken: { type: "string", description: "Required when
|
|
2505
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2506
2506
|
udid: { type: "string", description: "Simulator UDID (from tapp_list_simulators)" },
|
|
2507
2507
|
name: { type: "string", description: "Simulator name, e.g. 'iPhone 16 Pro' (used if udid omitted)" },
|
|
2508
2508
|
},
|
|
@@ -2518,7 +2518,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2518
2518
|
inputSchema: {
|
|
2519
2519
|
type: "object",
|
|
2520
2520
|
properties: {
|
|
2521
|
-
authToken: { type: "string", description: "Required when
|
|
2521
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2522
2522
|
project: { type: "string", description: "Absolute path to .xcodeproj (use this OR workspace)" },
|
|
2523
2523
|
workspace: { type: "string", description: "Absolute path to .xcworkspace (use this OR project)" },
|
|
2524
2524
|
scheme: { type: "string", description: "Scheme to build" },
|
|
@@ -2542,7 +2542,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2542
2542
|
inputSchema: {
|
|
2543
2543
|
type: "object",
|
|
2544
2544
|
properties: {
|
|
2545
|
-
authToken: { type: "string", description: "Required when
|
|
2545
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2546
2546
|
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
2547
2547
|
androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
|
|
2548
2548
|
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
@@ -2571,7 +2571,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2571
2571
|
inputSchema: {
|
|
2572
2572
|
type: "object",
|
|
2573
2573
|
properties: {
|
|
2574
|
-
authToken: { type: "string", description: "Required when
|
|
2574
|
+
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2575
2575
|
action: { type: "string", enum: ["login", "tap", "type", "swipe", "back", "wait", "tree", "screenshot"] },
|
|
2576
2576
|
email: { type: "string", description: "login: email/username to sign in with" },
|
|
2577
2577
|
password: { type: "string", description: "login: password to sign in with" },
|
|
@@ -2592,7 +2592,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2592
2592
|
description: "End the active interactive session (quits the app + harness). Always call this when done.",
|
|
2593
2593
|
inputSchema: {
|
|
2594
2594
|
type: "object",
|
|
2595
|
-
properties: { authToken: { type: "string", description: "Required when
|
|
2595
|
+
properties: { authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" } },
|
|
2596
2596
|
},
|
|
2597
2597
|
},
|
|
2598
2598
|
],
|
|
@@ -3450,7 +3450,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3450
3450
|
if (!isNonEmptyString(args.goal)) return errorResult("goal is required");
|
|
3451
3451
|
if (!isNonEmptyString(args.appBundleId)) return errorResult("appBundleId is required");
|
|
3452
3452
|
const backend = resolveModelBackend();
|
|
3453
|
-
if (!backend) return errorResult("AI-generate needs a model backend — set
|
|
3453
|
+
if (!backend) return errorResult("AI-generate needs a model backend — set TAPP_SUBSCRIPTION_TOKEN or ANTHROPIC_API_KEY.");
|
|
3454
3454
|
const bundleId = args.appBundleId.trim();
|
|
3455
3455
|
|
|
3456
3456
|
// 1) Grounding: reuse a capture's markers, else explore the app to build a screen/control map.
|
|
@@ -13,7 +13,7 @@ import { compileReleaseContract, loadReleaseContractFile } from "./release-contr
|
|
|
13
13
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
14
14
|
|
|
15
15
|
function executionHome() {
|
|
16
|
-
return process.env.
|
|
16
|
+
return process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function atomicJson(destination, value) {
|
|
@@ -63,7 +63,7 @@ function artifactPaths(root, outDir = ".autotap") {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function productRunRoot(root) {
|
|
66
|
-
const home = process.env.
|
|
66
|
+
const home = process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
|
|
67
67
|
const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
68
68
|
return path.join(home, "product-runs", identity);
|
|
69
69
|
}
|
package/mcp-server/src/report.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Pure report/gate logic shared by the MCP server (index.js) and the CI gate CLI
|
|
2
|
-
// (ci-report.js). Turns a capture's OCQA markers into the same ship/no-ship report the
|
|
2
|
+
// (ci-report.js). Turns a capture's OCQA markers into the same ship/no-ship report the Tapp
|
|
3
3
|
// app produces, and diffs two runs' findings into the CI regression gate. No shell, no server —
|
|
4
4
|
// keep it dependency-free so the CI path stays importable and testable.
|
|
5
5
|
import fs from "fs";
|
|
@@ -78,7 +78,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Map harness OCQA_ISSUE `type` ->
|
|
81
|
+
// Map harness OCQA_ISSUE `type` -> Tapp FindingCategory. Crashes are always critical.
|
|
82
82
|
export const ISSUE_CATEGORY = {
|
|
83
83
|
crash: "crash",
|
|
84
84
|
app_hang: "app_hang",
|
|
@@ -100,7 +100,7 @@ export function severityRank(s) {
|
|
|
100
100
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
// Turn a capture's OCQA markers into the same ship/no-ship report
|
|
103
|
+
// Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
|
|
104
104
|
// deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
|
|
105
105
|
export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
106
106
|
const base = parseOcqaMarkers(markersFilePath);
|