@aarwitz/tapp 0.17.0-rc.8 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +34 -14
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +11 -0
- package/README.md +126 -77
- package/bin/tapp.js +187 -63
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- package/mcp-server/src/android-explorer.js +3 -1
- package/mcp-server/src/android-flow.js +18 -1
- package/mcp-server/src/application-model.js +83 -13
- package/mcp-server/src/ci-report.js +2 -2
- package/mcp-server/src/ci-setup.js +4 -4
- package/mcp-server/src/environment-preflight.js +43 -0
- package/mcp-server/src/html-report.js +3 -2
- package/mcp-server/src/index.js +326 -110
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +111 -6
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/web-explorer.js +123 -10
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +6 -4
- package/scripts/ci-gate.sh +42 -0
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +2 -2
- package/skills/tapp/SKILL.md +75 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +102 -0
|
@@ -410,7 +410,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
|
|
|
410
410
|
if (!prPlanPath || !fs.existsSync(source)) throw new Error(`PR plan not found: ${source || "(missing path)"}`);
|
|
411
411
|
const targetPath = releasePlanPath === ".tapp/release-plan.json" ? existingProjectArtifactPath(root, "release-plan.json") : path.resolve(root, releasePlanPath);
|
|
412
412
|
if (!inside(root, targetPath)) throw new Error("Release plan path must stay inside the project directory");
|
|
413
|
-
if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run tapp init first`);
|
|
413
|
+
if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run npx -y @aarwitz/tapp@latest init first`);
|
|
414
414
|
const prPlan = JSON.parse(fs.readFileSync(source, "utf8"));
|
|
415
415
|
if (prPlan?.schemaVersion !== 1 || !Array.isArray(prPlan.explorationTargets)) throw new Error("PR plan must be an executed Tapp PR plan v1");
|
|
416
416
|
const matches = prPlan.explorationTargets.filter((target) => target.id === item);
|
|
@@ -428,7 +428,7 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
|
|
|
428
428
|
if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
|
|
429
429
|
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
430
430
|
const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
|
|
431
|
-
const refreshAdvice = "refresh the persistent UI Map with `tapp init --explore --refresh`, rerun `tapp pr gate`, then retry `tapp pr adopt`";
|
|
431
|
+
const refreshAdvice = "refresh the persistent UI Map with `npx -y @aarwitz/tapp@latest init --explore --refresh`, rerun `npx -y @aarwitz/tapp@latest pr gate`, then retry `npx -y @aarwitz/tapp@latest pr adopt`";
|
|
432
432
|
if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}; ${refreshAdvice}`);
|
|
433
433
|
if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
|
|
434
434
|
throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}; ${refreshAdvice}`);
|
|
@@ -64,6 +64,90 @@ function artifactPaths(root, outDir = ".tapp") {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function normalizedInitTarget(root, target) {
|
|
68
|
+
const requested = String(target || "").trim();
|
|
69
|
+
if (!requested) return "";
|
|
70
|
+
let absolute;
|
|
71
|
+
try { absolute = fs.realpathSync(path.resolve(root, requested)); }
|
|
72
|
+
catch { absolute = path.resolve(root, requested); }
|
|
73
|
+
if (absolute === root) return "";
|
|
74
|
+
if (inside(root, absolute)) return path.relative(root, absolute).replaceAll(path.sep, "/");
|
|
75
|
+
return requested;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function initTargetChoices(model, platform = "") {
|
|
79
|
+
const candidates = (model.targets || []).filter((target) => !platform || target.platform === platform);
|
|
80
|
+
return candidates.map((target) => ({
|
|
81
|
+
target,
|
|
82
|
+
command: `npx -y @aarwitz/tapp@latest init . --explore --platform ${target.platform} --target ${target.sourcePath === "." ? JSON.stringify(target.name) : JSON.stringify(target.sourcePath)}`,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function selectInitExplorationTarget(model, {
|
|
87
|
+
root,
|
|
88
|
+
platform = "",
|
|
89
|
+
target = "",
|
|
90
|
+
appId = "",
|
|
91
|
+
} = {}) {
|
|
92
|
+
const selectedPlatform = String(platform || "").trim().toLowerCase();
|
|
93
|
+
if (selectedPlatform && !["ios", "android", "web"].includes(selectedPlatform)) throw new Error("platform must be ios|android|web");
|
|
94
|
+
let requested = normalizedInitTarget(root, target);
|
|
95
|
+
if (!requested && appId) {
|
|
96
|
+
const androidMatch = (model.targets || []).find((candidate) => candidate.platform === "android" && candidate.runtime?.applicationId === appId);
|
|
97
|
+
if (androidMatch) requested = androidMatch.id;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return selectApplicationTarget(model, {
|
|
101
|
+
platform: selectedPlatform,
|
|
102
|
+
target: requested,
|
|
103
|
+
// `init --explore` is the explicit onboarding/refresh operation: it asks whenever several
|
|
104
|
+
// targets are plausible. Only a later bare `tapp explore` consumes the recorded default.
|
|
105
|
+
useDefault: false,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const choices = initTargetChoices(model, selectedPlatform);
|
|
109
|
+
if (choices.length > 1 && !requested) {
|
|
110
|
+
const selection = new Error(
|
|
111
|
+
`Multiple application targets were detected; Tapp will not guess which one you mean:\n` +
|
|
112
|
+
choices.map(({ target: choice }) => ` - ${choice.platform}:${choice.name} (${choice.sourcePath})`).join("\n") +
|
|
113
|
+
`\nRerun with one of:\n` + choices.map(({ command }) => ` ${command}`).join("\n")
|
|
114
|
+
);
|
|
115
|
+
selection.code = "TAPP_TARGET_SELECTION_REQUIRED";
|
|
116
|
+
selection.details = {
|
|
117
|
+
reason: "target-selection-required",
|
|
118
|
+
choices: choices.map(({ target: choice, command }) => ({
|
|
119
|
+
id: choice.id,
|
|
120
|
+
platform: choice.platform,
|
|
121
|
+
name: choice.name,
|
|
122
|
+
sourcePath: choice.sourcePath,
|
|
123
|
+
selector: choice.sourcePath === "." ? choice.name : choice.sourcePath,
|
|
124
|
+
command,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
throw selection;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function scopeProductRequirements(model, { selectedTargetId = "" } = {}) {
|
|
134
|
+
const targets = Array.isArray(model?.targets) ? model.targets : [];
|
|
135
|
+
const requirements = Array.isArray(model?.requirements) ? model.requirements : [];
|
|
136
|
+
const selected = String(selectedTargetId || "").trim();
|
|
137
|
+
const enriched = requirements.map((requirement) => {
|
|
138
|
+
const target = targets.find((candidate) => requirement.targetId === candidate.id || String(requirement.id || "").startsWith(`${candidate.id}:`));
|
|
139
|
+
return target
|
|
140
|
+
? { ...requirement, targetId: target.id, targetPlatform: target.platform, targetName: target.name }
|
|
141
|
+
: { ...requirement };
|
|
142
|
+
});
|
|
143
|
+
if (!selected) return { selectedTargetId: "", active: enriched, deferred: [] };
|
|
144
|
+
return {
|
|
145
|
+
selectedTargetId: selected,
|
|
146
|
+
active: enriched.filter((requirement) => !requirement.targetId || requirement.targetId === selected),
|
|
147
|
+
deferred: enriched.filter((requirement) => requirement.targetId && requirement.targetId !== selected),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
67
151
|
function productRunRoot(root) {
|
|
68
152
|
const home = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
|
|
69
153
|
const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
@@ -279,6 +363,7 @@ export async function initializeProductProject({
|
|
|
279
363
|
maxContracts = 15,
|
|
280
364
|
testEmail,
|
|
281
365
|
testPassword,
|
|
366
|
+
watch = false,
|
|
282
367
|
runExploration,
|
|
283
368
|
onProgress = () => {},
|
|
284
369
|
onStatus = () => {},
|
|
@@ -287,22 +372,41 @@ export async function initializeProductProject({
|
|
|
287
372
|
if (!["inspect", "write", "refresh", "explore"].includes(mode)) throw new Error("mode must be inspect|write|refresh|explore");
|
|
288
373
|
if (!Number.isInteger(Number(maxContracts)) || Number(maxContracts) < 1 || Number(maxContracts) > 50) throw new Error("maxContracts must be between 1 and 50");
|
|
289
374
|
const paths = artifactPaths(root, outDir);
|
|
375
|
+
const priorModel = readJson(paths.model);
|
|
290
376
|
let exploration = null;
|
|
377
|
+
let selectedTarget = null;
|
|
291
378
|
if (mode === "explore") {
|
|
292
379
|
if (typeof runExploration !== "function") throw new Error("The selected adapter did not provide a platform exploration capability");
|
|
293
|
-
const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "
|
|
380
|
+
const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "")).toLowerCase();
|
|
381
|
+
const inspected = await buildInitArtifacts({
|
|
382
|
+
projectDir: root,
|
|
383
|
+
ownedUrl,
|
|
384
|
+
outDir,
|
|
385
|
+
maxContracts: Number(maxContracts),
|
|
386
|
+
defaultTargetId: priorModel?.application?.defaultTargetId || "",
|
|
387
|
+
});
|
|
388
|
+
selectedTarget = selectInitExplorationTarget(inspected.model, {
|
|
389
|
+
root,
|
|
390
|
+
platform: selectedPlatform,
|
|
391
|
+
target,
|
|
392
|
+
appId,
|
|
393
|
+
});
|
|
394
|
+
const sourceTarget = selectedTarget.sourcePath === "." ? root : path.resolve(root, selectedTarget.sourcePath);
|
|
294
395
|
exploration = await runExploration({
|
|
295
|
-
projectDir: root, platform:
|
|
296
|
-
bundleId, appId, apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
|
|
297
|
-
testEmail, testPassword, onProgress, onStatus,
|
|
396
|
+
projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
|
|
397
|
+
bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
|
|
398
|
+
testEmail, testPassword, watch, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
|
|
298
399
|
});
|
|
299
400
|
if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
|
|
300
401
|
}
|
|
301
402
|
const built = await buildInitArtifacts({
|
|
302
403
|
projectDir: root,
|
|
303
404
|
ownedUrl: ownedUrl || (exploration?.platform === "web" && !exploration.managedRuntime ? exploration.target : ""),
|
|
304
|
-
|
|
405
|
+
// Exploration chooses one runnable surface, but the repository model must retain every detected
|
|
406
|
+
// application target. Otherwise selecting web would silently erase the native app (and vice versa).
|
|
407
|
+
platform: mode === "explore" ? "" : String(platform || "").toLowerCase(),
|
|
305
408
|
targetValidation: exploration?.targetValidation || null,
|
|
409
|
+
defaultTargetId: selectedTarget?.id || priorModel?.application?.defaultTargetId || "",
|
|
306
410
|
outDir,
|
|
307
411
|
maxContracts: Number(maxContracts),
|
|
308
412
|
});
|
|
@@ -317,7 +421,8 @@ export async function initializeProductProject({
|
|
|
317
421
|
invalidateValidation: mode === "explore",
|
|
318
422
|
});
|
|
319
423
|
}
|
|
320
|
-
|
|
424
|
+
const requirementScope = scopeProductRequirements(built.model, { selectedTargetId: selectedTarget?.id || "" });
|
|
425
|
+
return { operation: "initialize", mode, model: built.model, plan: written?.plan || built.plan, exploration, selectedTarget, requirementScope, written, project: readProductProject({ projectDir: root, outDir }) };
|
|
321
426
|
}
|
|
322
427
|
|
|
323
428
|
function resolvePlan(root, outDir, planPath = "") {
|
package/mcp-server/src/report.js
CHANGED
|
@@ -146,6 +146,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
146
146
|
const screenElementCounts = {}; // screen -> max elements observed (content-collapse detection)
|
|
147
147
|
let anySecure = false;
|
|
148
148
|
let loginAttempted = false;
|
|
149
|
+
let credentialsProvided = base.complete?.credentialsProvided === true;
|
|
149
150
|
let actions = 0;
|
|
150
151
|
|
|
151
152
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -158,7 +159,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
158
159
|
// `target` gives a finding its identity beyond type|screen — two dead buttons on the
|
|
159
160
|
// same screen are two findings, and fixing one while breaking another is a regression.
|
|
160
161
|
const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
|
|
161
|
-
|
|
162
|
+
const url = typeof o.url === "string" && o.url.trim() ? o.url.trim() : null;
|
|
163
|
+
rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null });
|
|
162
164
|
} catch {
|
|
163
165
|
/* ignore malformed */
|
|
164
166
|
}
|
|
@@ -175,6 +177,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
175
177
|
t === "OCQA_STATE:login_preamble_two_step_submitted"
|
|
176
178
|
) {
|
|
177
179
|
loginAttempted = true;
|
|
180
|
+
} else if (t === "OCQA_STATE:credentials_supplied") {
|
|
181
|
+
credentialsProvided = true;
|
|
178
182
|
} else if (t.startsWith("OCQA_STATE:{")) {
|
|
179
183
|
try {
|
|
180
184
|
const s = JSON.parse(t.slice("OCQA_STATE:".length));
|
|
@@ -273,9 +277,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
273
277
|
const coverageFloorMet = platform === "web"
|
|
274
278
|
? screensExplored >= 1 && actionsPerformed >= 1
|
|
275
279
|
: screensExplored >= 2 && actionsPerformed >= 3;
|
|
276
|
-
const
|
|
277
|
-
const inconclusive = !coverageFloorMet ||
|
|
278
|
-
const stopReason =
|
|
280
|
+
const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
|
|
281
|
+
const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
|
|
282
|
+
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
279
283
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
280
284
|
: coverageFloorMet ? "completed" : "coverage-floor-not-met";
|
|
281
285
|
|
|
@@ -338,7 +342,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
338
342
|
// "Failed sign-ins" is only a claim when credentials were actually submitted. Merely seeing a
|
|
339
343
|
// password field proves that a login surface was reached, not that authentication was exercised.
|
|
340
344
|
if (loginAttempted) checkedFor.splice(2, 0, "failed sign-ins");
|
|
345
|
+
else if (anySecure && credentialsProvided) notChecked.push("sign-in behavior (test credentials were supplied, but no sign-in attempt was observed)");
|
|
341
346
|
else if (anySecure) notChecked.push("sign-in behavior (login form reached, no test credentials supplied)");
|
|
347
|
+
else if (credentialsProvided) conditionsNotReached.push("sign-in (test credentials were supplied but no login form was encountered, so they were not used)");
|
|
342
348
|
else conditionsNotReached.push("sign-in (no login form encountered this run)");
|
|
343
349
|
if (timeBudgetExhausted) notChecked.push("the full requested action budget (run reached its wall-clock timeout)");
|
|
344
350
|
|
|
@@ -385,6 +391,13 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
385
391
|
screenElementCounts,
|
|
386
392
|
inputFieldsEncountered,
|
|
387
393
|
loginEncountered: anySecure,
|
|
394
|
+
credentialsProvided,
|
|
395
|
+
credentialsUsed: loginAttempted,
|
|
396
|
+
credentialWarning: credentialsProvided && !loginAttempted
|
|
397
|
+
? anySecure
|
|
398
|
+
? "Test credentials were supplied, but this run did not submit the login form. Authentication was not exercised."
|
|
399
|
+
: "Test credentials were supplied, but this run did not encounter a login form. They were not used."
|
|
400
|
+
: null,
|
|
388
401
|
complete: base.complete,
|
|
389
402
|
relativeMarkersFilePath: base.relativeMarkersFilePath,
|
|
390
403
|
};
|
|
@@ -23,6 +23,7 @@ import { execFileSync } from "child_process";
|
|
|
23
23
|
const CLICK_SETTLE_MS = 700;
|
|
24
24
|
const NAV_TIMEOUT_MS = 15_000;
|
|
25
25
|
const BUTTONS_PER_PAGE = 4;
|
|
26
|
+
const WATCH_ACTION_DELAY_MS = 350;
|
|
26
27
|
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
27
28
|
const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
|
|
28
29
|
|
|
@@ -249,13 +250,14 @@ export async function loadPlaywright() {
|
|
|
249
250
|
);
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
export async function submitWebLogin(page) {
|
|
253
|
+
export async function submitWebLogin(page, beforeClick = null) {
|
|
253
254
|
const candidates = [
|
|
254
255
|
page.locator("button[type=submit], input[type=submit], form button").first(),
|
|
255
256
|
page.getByRole("button", { name: /sign ?in|log ?in|continue/i }).first(),
|
|
256
257
|
];
|
|
257
258
|
for (const candidate of candidates) {
|
|
258
259
|
if (await candidate.isVisible().catch(() => false)) {
|
|
260
|
+
if (beforeClick) await beforeClick(candidate);
|
|
259
261
|
await candidate.click({ timeout: 3000 });
|
|
260
262
|
return true;
|
|
261
263
|
}
|
|
@@ -279,13 +281,14 @@ export function webTransitionOrigin(pendingNavigation, currentScreen) {
|
|
|
279
281
|
return pendingNavigation?.fromScreen || currentScreen || null;
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
export function webBrowserLaunchOptions(environment = process.env) {
|
|
284
|
+
export function webBrowserLaunchOptions(environment = process.env, { watch = false } = {}) {
|
|
283
285
|
const browserProxy = String(environment.TAPP_BROWSER_PROXY_SERVER || "").trim();
|
|
284
286
|
if (environment.TAPP_ENFORCE_PUBLIC_EGRESS === "1" && !/^http:\/\/127\.0\.0\.1:\d+$/.test(browserProxy)) {
|
|
285
287
|
throw new Error("public egress policy proxy is required");
|
|
286
288
|
}
|
|
287
289
|
return {
|
|
288
|
-
headless:
|
|
290
|
+
headless: !watch,
|
|
291
|
+
...(watch ? { slowMo: 200 } : {}),
|
|
289
292
|
...(browserProxy ? { proxy: { server: browserProxy, bypass: "<-loopback>" } } : {}),
|
|
290
293
|
args: browserProxy ? [
|
|
291
294
|
"--disable-quic",
|
|
@@ -296,6 +299,102 @@ export function webBrowserLaunchOptions(environment = process.env) {
|
|
|
296
299
|
};
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
// A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
|
|
303
|
+
// explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
|
|
304
|
+
// real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
|
|
305
|
+
// evidence screenshots; it therefore cannot become an app control or alter detector input.
|
|
306
|
+
async function installWebWatchUi(context) {
|
|
307
|
+
await context.addInitScript(() => {
|
|
308
|
+
const stateKey = Symbol.for("tapp.watchUi");
|
|
309
|
+
const ensure = () => {
|
|
310
|
+
if (window[stateKey]?.host?.isConnected) return window[stateKey];
|
|
311
|
+
const host = document.createElement("div");
|
|
312
|
+
host.setAttribute("data-tapp-watch-ui", "");
|
|
313
|
+
host.setAttribute("aria-hidden", "true");
|
|
314
|
+
Object.assign(host.style, {
|
|
315
|
+
position: "fixed",
|
|
316
|
+
inset: "0",
|
|
317
|
+
zIndex: "2147483647",
|
|
318
|
+
pointerEvents: "none",
|
|
319
|
+
});
|
|
320
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
321
|
+
const style = document.createElement("style");
|
|
322
|
+
style.textContent = `
|
|
323
|
+
.cursor { position: fixed; left: 24px; top: 72px; width: 22px; height: 28px;
|
|
324
|
+
filter: drop-shadow(0 2px 2px rgba(0,0,0,.45)); transition: left 260ms ease, top 260ms ease;
|
|
325
|
+
transform: rotate(-8deg); }
|
|
326
|
+
.cursor::before { content: ""; display: block; width: 100%; height: 100%; background: #111827;
|
|
327
|
+
clip-path: polygon(0 0, 0 88%, 25% 67%, 39% 100%, 53% 93%, 39% 61%, 70% 61%); }
|
|
328
|
+
.cursor::after { content: ""; position: absolute; inset: 2px 3px 4px 2px; background: white;
|
|
329
|
+
clip-path: polygon(0 0, 0 79%, 25% 59%, 40% 91%, 46% 88%, 32% 55%, 60% 55%); }
|
|
330
|
+
.hud { position: fixed; top: 14px; right: 14px; max-width: min(420px, calc(100vw - 28px));
|
|
331
|
+
box-sizing: border-box; padding: 9px 12px; border-radius: 10px; color: white;
|
|
332
|
+
background: rgba(17,24,39,.92); box-shadow: 0 5px 18px rgba(0,0,0,.24);
|
|
333
|
+
font: 600 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
334
|
+
.brand { color: #93c5fd; margin-right: 6px; }
|
|
335
|
+
`;
|
|
336
|
+
const cursor = document.createElement("div");
|
|
337
|
+
cursor.className = "cursor";
|
|
338
|
+
const hud = document.createElement("div");
|
|
339
|
+
hud.className = "hud";
|
|
340
|
+
shadow.append(style, cursor, hud);
|
|
341
|
+
(document.documentElement || document).appendChild(host);
|
|
342
|
+
const state = { host, cursor, hud };
|
|
343
|
+
Object.defineProperty(window, stateKey, { value: state, configurable: true });
|
|
344
|
+
return state;
|
|
345
|
+
};
|
|
346
|
+
Object.defineProperty(window, "__tappShowWatchAction", {
|
|
347
|
+
configurable: true,
|
|
348
|
+
value: ({ x, y, action, target }) => {
|
|
349
|
+
const state = ensure();
|
|
350
|
+
state.host.style.display = "block";
|
|
351
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
352
|
+
state.cursor.style.left = `${Math.max(4, Math.min(window.innerWidth - 26, x))}px`;
|
|
353
|
+
state.cursor.style.top = `${Math.max(4, Math.min(window.innerHeight - 32, y))}px`;
|
|
354
|
+
}
|
|
355
|
+
state.hud.replaceChildren();
|
|
356
|
+
const brand = document.createElement("span");
|
|
357
|
+
brand.className = "brand";
|
|
358
|
+
brand.textContent = "Tapp";
|
|
359
|
+
state.hud.append(brand, document.createTextNode(`${action}${target ? ` · ${target}` : ""}`));
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
Object.defineProperty(window, "__tappSetWatchUiVisible", {
|
|
363
|
+
configurable: true,
|
|
364
|
+
value: (visible) => {
|
|
365
|
+
if (window[stateKey]?.host) window[stateKey].host.style.display = visible ? "block" : "none";
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function showWebWatchAction(page, { locator = null, action = "Exploring", target = "" } = {}) {
|
|
372
|
+
let x = 28;
|
|
373
|
+
let y = 76;
|
|
374
|
+
if (locator) {
|
|
375
|
+
await locator.scrollIntoViewIfNeeded().catch(() => {});
|
|
376
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
377
|
+
if (box) {
|
|
378
|
+
x = box.x + box.width / 2;
|
|
379
|
+
y = box.y + box.height / 2;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
await page.evaluate(({ x, y, action, target }) => {
|
|
383
|
+
window.__tappShowWatchAction?.({ x, y, action, target });
|
|
384
|
+
}, { x, y, action, target }).catch(() => {});
|
|
385
|
+
await page.waitForTimeout(WATCH_ACTION_DELAY_MS).catch(() => {});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function screenshotWithoutWebWatchUi(page, options, watch) {
|
|
389
|
+
if (!watch) return page.screenshot(options);
|
|
390
|
+
await page.evaluate(() => window.__tappSetWatchUiVisible?.(false)).catch(() => {});
|
|
391
|
+
try {
|
|
392
|
+
return await page.screenshot(options);
|
|
393
|
+
} finally {
|
|
394
|
+
await page.evaluate(() => window.__tappSetWatchUiVisible?.(true)).catch(() => {});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
299
398
|
// Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
|
|
300
399
|
// commands. This deliberately does no exploration or judgment; it opens exactly one page,
|
|
301
400
|
// captures the visible semantic controls, and optionally takes one screenshot.
|
|
@@ -425,7 +524,7 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
|
|
|
425
524
|
return result;
|
|
426
525
|
}
|
|
427
526
|
|
|
428
|
-
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], onProgress }) {
|
|
527
|
+
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
|
|
429
528
|
const start = new URL(url);
|
|
430
529
|
if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
|
|
431
530
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -434,9 +533,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
434
533
|
const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
435
534
|
|
|
436
535
|
const { chromium } = await loadPlaywright();
|
|
437
|
-
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
536
|
+
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
438
537
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
439
538
|
await installWebListenerTracking(context);
|
|
539
|
+
if (watch) await installWebWatchUi(context);
|
|
440
540
|
const page = await context.newPage();
|
|
441
541
|
page.setDefaultTimeout(NAV_TIMEOUT_MS);
|
|
442
542
|
|
|
@@ -444,7 +544,8 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
444
544
|
const issues = []; // emitted immediately; kept for counting only
|
|
445
545
|
const issue = (type, severity, title, screen, target) => {
|
|
446
546
|
issues.push(type);
|
|
447
|
-
|
|
547
|
+
const pageUrl = page.url();
|
|
548
|
+
emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
|
|
448
549
|
};
|
|
449
550
|
|
|
450
551
|
// Async defect listeners: attribute to whatever screen is current when they fire.
|
|
@@ -604,7 +705,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
604
705
|
if (existingKey && existingKey !== evidenceKey) screenshotFor.delete(existingKey);
|
|
605
706
|
screenshotFor.set(evidenceKey, { path: screenshotPath, busy: info.busy, route: key });
|
|
606
707
|
screenCount = screenshotFor.size;
|
|
607
|
-
await page
|
|
708
|
+
await screenshotWithoutWebWatchUi(page, { path: screenshotPath }, watch).catch(() => {});
|
|
608
709
|
// Deterministic per-page detectors run once per distinct screen.
|
|
609
710
|
if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
|
|
610
711
|
else {
|
|
@@ -628,12 +729,19 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
628
729
|
if (!(await pw.isVisible().catch(() => false))) return null;
|
|
629
730
|
loginTried = true;
|
|
630
731
|
const emailSel = "input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]";
|
|
631
|
-
if (testEmail)
|
|
732
|
+
if (testEmail) {
|
|
733
|
+
const email = page.locator(emailSel).first();
|
|
734
|
+
if (watch) await showWebWatchAction(page, { locator: email, action: "Type", target: "Email" });
|
|
735
|
+
await email.fill(testEmail).catch(() => {});
|
|
736
|
+
}
|
|
737
|
+
if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
|
|
632
738
|
await pw.fill(testPassword).catch(() => {});
|
|
633
739
|
lastActionTarget = "Sign in";
|
|
634
740
|
emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
635
741
|
actions += 1;
|
|
636
|
-
await submitWebLogin(page
|
|
742
|
+
await submitWebLogin(page, watch
|
|
743
|
+
? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
|
|
744
|
+
: null).catch(() => false);
|
|
637
745
|
await waitForWebStability(page, { timeoutMs: Math.min(5_000, CLICK_SETTLE_MS * 6) });
|
|
638
746
|
// Still on the login form after a submit = the sign-in failed — full stop. (A quiet
|
|
639
747
|
// credential rejection often shows NO other symptom, so this must not be coupled to
|
|
@@ -688,6 +796,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
688
796
|
progress();
|
|
689
797
|
continue;
|
|
690
798
|
}
|
|
799
|
+
if (watch) await showWebWatchAction(page, { action: "Open", target });
|
|
691
800
|
await waitForWebStability(page);
|
|
692
801
|
if (nav && typeof nav.status === "function" && nav.status() === 404) {
|
|
693
802
|
issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
|
|
@@ -708,6 +817,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
708
817
|
emit("ACTION", { type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
|
|
709
818
|
let acted = false;
|
|
710
819
|
if (action.type === "back") {
|
|
820
|
+
if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
|
|
711
821
|
await page.goBack({ waitUntil: "domcontentloaded", timeout: step.wait?.timeoutMs || NAV_TIMEOUT_MS }).catch(() => {});
|
|
712
822
|
acted = true;
|
|
713
823
|
} else {
|
|
@@ -721,6 +831,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
721
831
|
else continue;
|
|
722
832
|
if (await locator.isVisible().catch(() => false)) {
|
|
723
833
|
try {
|
|
834
|
+
if (watch) await showWebWatchAction(page, { locator, action: "Click", target: action.target });
|
|
724
835
|
await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) });
|
|
725
836
|
acted = true;
|
|
726
837
|
break;
|
|
@@ -785,6 +896,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
785
896
|
emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
786
897
|
let clickSucceeded = false;
|
|
787
898
|
try {
|
|
899
|
+
if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
|
|
788
900
|
await b.click({ timeout: 3000 });
|
|
789
901
|
clickSucceeded = true;
|
|
790
902
|
} catch {}
|
|
@@ -795,6 +907,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
795
907
|
await waitForWebStability(page);
|
|
796
908
|
if (page.url() !== beforeState.url) {
|
|
797
909
|
await observe();
|
|
910
|
+
if (watch) await showWebWatchAction(page, { action: "Back", target: ob.screen });
|
|
798
911
|
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
799
912
|
await waitForWebStability(page);
|
|
800
913
|
} else {
|
|
@@ -813,7 +926,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
813
926
|
progress();
|
|
814
927
|
}
|
|
815
928
|
} finally {
|
|
816
|
-
emit("COMPLETE", { actions, screens: screenCount });
|
|
929
|
+
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
|
|
817
930
|
fs.closeSync(markersFd);
|
|
818
931
|
await browser.close().catch(() => {});
|
|
819
932
|
}
|
|
@@ -132,6 +132,22 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
132
132
|
const el = await locateWebElement(page, target);
|
|
133
133
|
if (!el) throw new Error(`no field ‘${target}’ to type into`);
|
|
134
134
|
await el.fill(value);
|
|
135
|
+
} else if (action === "login") {
|
|
136
|
+
const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
|
|
137
|
+
const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
|
|
138
|
+
const email = await firstVisible([
|
|
139
|
+
page.locator("input[type=email]"), page.locator("input[name*=mail i]"),
|
|
140
|
+
page.locator("input[name*=user i]"), page.getByLabel(/email|user/i),
|
|
141
|
+
]);
|
|
142
|
+
const password = await firstVisible([page.locator("input[type=password]"), page.getByLabel(/password|passcode/i)]);
|
|
143
|
+
if (!email || !password) throw new Error("could not identify email and password fields");
|
|
144
|
+
await email.fill(emailValue);
|
|
145
|
+
await password.fill(passwordValue);
|
|
146
|
+
const submit = await firstVisible([page.getByRole("button", { name:/sign in|log in|login|continue|submit/i })]);
|
|
147
|
+
if (!submit) throw new Error("could not identify a sign-in control");
|
|
148
|
+
await submit.click();
|
|
149
|
+
await page.waitForTimeout(400);
|
|
150
|
+
if (await password.isVisible().catch(() => false)) throw new Error("submit left the app on the login screen");
|
|
135
151
|
} else if (action === "swipe") {
|
|
136
152
|
const dy = target.toLowerCase() === "down" ? -600 : 600;
|
|
137
153
|
await page.evaluate((y) => window.scrollBy({ top: y, behavior: "instant" }), dy);
|
|
@@ -173,7 +189,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
173
189
|
status = "fail";
|
|
174
190
|
detail = error.message || String(error);
|
|
175
191
|
}
|
|
176
|
-
return { action, target: target || value, status, detail, task: raw.task };
|
|
192
|
+
return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.0
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Agent-driven app testing for iOS, Android, and web—real screens, replayable flows, and deterministic CI gates.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"Harness/OCQAHarnessUITests/",
|
|
42
42
|
"Harness/OCQAHarness.xcodeproj/",
|
|
43
43
|
"Harness/generate-harness-xcodeproj.rb",
|
|
44
|
+
"skills/",
|
|
45
|
+
".claude-plugin/",
|
|
44
46
|
"AGENTS.md"
|
|
45
47
|
],
|
|
46
48
|
"dependencies": {
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
"type": "git",
|
|
58
60
|
"url": "git+https://github.com/aarwitz/tapp.git"
|
|
59
61
|
},
|
|
60
|
-
"homepage": "https://
|
|
62
|
+
"homepage": "https://runtapp.com/",
|
|
61
63
|
"bugs": {
|
|
62
64
|
"url": "https://github.com/aarwitz/tapp/issues"
|
|
63
65
|
},
|
|
@@ -87,7 +89,7 @@
|
|
|
87
89
|
"mobile"
|
|
88
90
|
],
|
|
89
91
|
"scripts": {
|
|
90
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/action.test.js tests/package-surface.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
91
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
92
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
93
95
|
}
|
package/scripts/ci-gate.sh
CHANGED
|
@@ -86,6 +86,48 @@ TAPP_PROJECT_ARTIFACTS=""
|
|
|
86
86
|
if [[ -n "$PROJECT_DIR" ]]; then
|
|
87
87
|
[[ -d "$PROJECT_DIR/.tapp" ]] && TAPP_PROJECT_ARTIFACTS="$PROJECT_DIR/.tapp"
|
|
88
88
|
fi
|
|
89
|
+
|
|
90
|
+
# A repository-connected gate must retain the stable application-model target identity in its
|
|
91
|
+
# report. Otherwise its first passing report cannot become a target-scoped baseline, even though
|
|
92
|
+
# Tapp already knows exactly which application it built and exercised. Explicit --target-key still
|
|
93
|
+
# wins (the composite Action supplies one); the CLI derives it only when the repository model makes
|
|
94
|
+
# the selection conclusive. Multi-target repositories remain strict and print the available ids.
|
|
95
|
+
if [[ -z "$TARGET_KEY" && -n "$TAPP_PROJECT_ARTIFACTS" && -f "$TAPP_PROJECT_ARTIFACTS/application-model.json" ]]; then
|
|
96
|
+
if ! TARGET_KEY="$(node - "$TAPP_PROJECT_ARTIFACTS/application-model.json" "$PLATFORM" "$WEB_TARGET" "$APP_ID" "$BUNDLE_ID" <<'NODE'
|
|
97
|
+
const fs = require("fs");
|
|
98
|
+
const [modelPath, platform, webTarget, appId, bundleId] = process.argv.slice(2);
|
|
99
|
+
let model;
|
|
100
|
+
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
101
|
+
catch (error) { console.error(`❌ Could not read application model ${modelPath}: ${error.message}`); process.exit(2); }
|
|
102
|
+
if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
|
|
103
|
+
console.error("❌ Expected .tapp/application-model.json to contain a Tapp application model");
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
let candidates = model.targets.filter((target) => target.platform === platform);
|
|
107
|
+
const requested = platform === "web" ? webTarget : platform === "android" ? appId : bundleId;
|
|
108
|
+
if (requested) {
|
|
109
|
+
const normalized = requested.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
110
|
+
candidates = candidates.filter((target) => {
|
|
111
|
+
const identities = [target.id, target.name, target.sourcePath];
|
|
112
|
+
if (platform === "android") identities.push(target.runtime?.applicationId);
|
|
113
|
+
if (platform === "ios") identities.push(target.runtime?.bundleId);
|
|
114
|
+
return identities.some((value) => String(value || "").replaceAll("\\", "/") === normalized);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (candidates.length !== 1) {
|
|
118
|
+
const available = model.targets.filter((target) => target.platform === platform);
|
|
119
|
+
console.error(candidates.length
|
|
120
|
+
? `❌ Multiple ${platform} targets match. Pass --target-key explicitly: ${candidates.map((target) => target.id).join(", ")}`
|
|
121
|
+
: `❌ Could not resolve one ${platform} target from the application model.${available.length ? ` Available target ids: ${available.map((target) => target.id).join(", ")}` : ""}`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write(String(candidates[0].id));
|
|
125
|
+
NODE
|
|
126
|
+
)"; then
|
|
127
|
+
exit 2
|
|
128
|
+
fi
|
|
129
|
+
echo "Target identity: $PLATFORM:$TARGET_KEY (from .tapp/application-model.json)"
|
|
130
|
+
fi
|
|
89
131
|
[[ -z "$FLOWS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/flows" ]] && FLOWS="$TAPP_PROJECT_ARTIFACTS/flows/*.yml"
|
|
90
132
|
[[ "$PLATFORM" == "web" && -z "$SCENARIOS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/scenarios" ]] && SCENARIOS="$TAPP_PROJECT_ARTIFACTS/scenarios/*.yml"
|
|
91
133
|
[[ -z "$CONTRACTS" && -n "$TAPP_PROJECT_ARTIFACTS" && -d "$TAPP_PROJECT_ARTIFACTS/contracts" ]] && CONTRACTS="$TAPP_PROJECT_ARTIFACTS/contracts/*.contract.ts"
|
package/scripts/flow_lib.py
CHANGED
|
@@ -102,7 +102,7 @@ def report(path, as_json=False):
|
|
|
102
102
|
return 0 if passed else 1
|
|
103
103
|
|
|
104
104
|
icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
|
|
105
|
-
verb = {"tap": "👆 tap", "type": "⌨️ type", "swipe": "↔️ swipe", "back": "◀️ back",
|
|
105
|
+
verb = {"tap": "👆 tap", "type": "⌨️ type", "login": "🔐 sign in", "swipe": "↔️ swipe", "back": "◀️ back",
|
|
106
106
|
"wait": "⏳ wait", "wait_for": "⏳ wait for", "assert_screen": "🔎 screen is",
|
|
107
107
|
"assert_exists": "🔎 exists", "assert_absent": "🔎 absent", "assert_text": "🔎 text",
|
|
108
108
|
"assert_ai": "🤖 ai"}
|
package/scripts/quick-capture.sh
CHANGED
|
@@ -23,9 +23,9 @@ TAPP_RUNTIME_HOME="${TAPP_HOME:-}"
|
|
|
23
23
|
if [[ -n "${TAPP_CAPTURE_DIR:-}" ]]; then
|
|
24
24
|
CAPTURE_DIR="$TAPP_CAPTURE_DIR"
|
|
25
25
|
elif [[ -n "$TAPP_RUNTIME_HOME" ]]; then
|
|
26
|
-
CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures
|
|
26
|
+
CAPTURE_DIR="$TAPP_RUNTIME_HOME/captures/ios-$(date +%Y%m%d-%H%M%S)"
|
|
27
27
|
else
|
|
28
|
-
CAPTURE_DIR="$PROJECT_ROOT/captures
|
|
28
|
+
CAPTURE_DIR="$PROJECT_ROOT/captures/ios-$(date +%Y%m%d-%H%M%S)"
|
|
29
29
|
fi
|
|
30
30
|
if [[ -n "$TAPP_RUNTIME_HOME" ]]; then
|
|
31
31
|
HARNESS_DERIVED="$TAPP_RUNTIME_HOME/harness-derived"
|