@aarwitz/tapp 0.17.0-rc.11 → 0.17.0-rc.12
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 +32 -0
- package/AGENTS.md +4 -1
- package/README.md +51 -4
- package/bin/tapp.js +15 -3
- package/mcp-server/src/index.js +165 -77
- package/mcp-server/src/product-operations.js +2 -1
- package/mcp-server/src/web-explorer.js +120 -8
- package/package.json +4 -2
- package/skills/tapp/SKILL.md +74 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +102 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tapp",
|
|
3
|
+
"description": "The official Tapp plugin: one agent skill and MCP tools for testing iOS, Android, and web apps on real surfaces.",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Aaron Horowitz",
|
|
6
|
+
"url": "https://github.com/aarwitz"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "tapp",
|
|
11
|
+
"source": {
|
|
12
|
+
"source": "npm",
|
|
13
|
+
"package": "@aarwitz/tapp"
|
|
14
|
+
},
|
|
15
|
+
"description": "Test real iOS, Android, and web apps with Tapp. Includes the Tapp agent skill plus MCP hands, eyes, exploration, replayable Flows, and deterministic release evidence.",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Aaron Horowitz",
|
|
18
|
+
"url": "https://github.com/aarwitz"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/aarwitz/tapp",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ios",
|
|
24
|
+
"simulator",
|
|
25
|
+
"testing",
|
|
26
|
+
"qa",
|
|
27
|
+
"xcuitest",
|
|
28
|
+
"mcp",
|
|
29
|
+
"agent-skill",
|
|
30
|
+
"copilot"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tapp",
|
|
3
|
+
"description": "Test real iOS, Android, and web apps with Tapp. Includes the Tapp agent skill plus MCP hands, eyes, exploration, replayable Flows, and deterministic release evidence.",
|
|
4
|
+
"version": "0.17.0-rc.12",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Aaron Horowitz",
|
|
7
|
+
"url": "https://github.com/aarwitz"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/aarwitz/tapp",
|
|
10
|
+
"repository": "https://github.com/aarwitz/tapp",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ios",
|
|
14
|
+
"simulator",
|
|
15
|
+
"testing",
|
|
16
|
+
"qa",
|
|
17
|
+
"xcuitest",
|
|
18
|
+
"mcp",
|
|
19
|
+
"agent-skill",
|
|
20
|
+
"copilot"
|
|
21
|
+
],
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"tapp": {
|
|
24
|
+
"command": "node",
|
|
25
|
+
"args": [
|
|
26
|
+
"${CLAUDE_PLUGIN_ROOT}/bin/tapp.js",
|
|
27
|
+
"mcp"
|
|
28
|
+
],
|
|
29
|
+
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/AGENTS.md
CHANGED
|
@@ -12,6 +12,7 @@ bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npx -y @aarwitz/tapp explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
|
|
15
|
+
npx -y @aarwitz/tapp explore https://your-app.example --watch # web: visibly follow the same exploration
|
|
15
16
|
npx -y @aarwitz/tapp open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
|
|
16
17
|
npx -y @aarwitz/tapp tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
|
|
17
18
|
npx -y @aarwitz/tapp shot # screenshot the booted sim → file path (≈ tapp_screenshot)
|
|
@@ -43,7 +44,9 @@ that IS the screen. If you cannot (Cursor, VS Code Copilot), connect the MCP ser
|
|
|
43
44
|
instead: its tool results carry the screenshot inline. Screen *recordings* are for the
|
|
44
45
|
human: on **iOS**, `tapp explore` records the full exploration and embeds it in the report.html
|
|
45
46
|
evidence page (Android does not currently record video) — tell the user the report path so they can
|
|
46
|
-
watch it.
|
|
47
|
+
watch it. On **web**, explicit `--watch` opens the isolated Playwright Chromium window and overlays
|
|
48
|
+
Tapp's current action and pointer; the overlay is omitted from evidence screenshots. It does not
|
|
49
|
+
drive the person's existing/default browser profile.
|
|
47
50
|
|
|
48
51
|
The interactive session/record loop is MCP-only (it needs a long-lived process). Flow replay is
|
|
49
52
|
also available in the CLI. The rest of this playbook assumes the `tapp_*` MCP tools are connected. With
|
package/README.md
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@aarwitz/tapp)
|
|
5
5
|
[](https://www.npmjs.com/package/@aarwitz/tapp)
|
|
6
6
|
[](./LICENSE)
|
|
7
|
+
[](https://skills.sh/aarwitz/tapp)
|
|
8
|
+
[](https://marketplace.visualstudio.com/items?itemName=lidi-solutions.tapp)
|
|
7
9
|
[](cursor://anysphere.cursor-deeplink/mcp/install?name=tapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhYXJ3aXR6L3RhcHAiLCJtY3AiXX0=)
|
|
8
|
-
[](https://insiders.vscode.dev/redirect/mcp/install?name=tapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40aarwitz%2Ftapp%22%2C%22mcp%22%5D%7D)
|
|
9
11
|
|
|
10
12
|
**Tapp is the release-contract and evidence layer for teams shipping agent-authored applications.**
|
|
11
13
|
It turns a repository and real product into an observed UI Map, a compact reviewed deterministic
|
|
@@ -31,6 +33,47 @@ Three platforms, one observe-and-gate engine:
|
|
|
31
33
|
autonomous exploration, the deterministic detectors (uncaught exceptions, failed requests,
|
|
32
34
|
dead buttons, broken links, placeholder `href="#"` links, error pages), and the same gate.
|
|
33
35
|
|
|
36
|
+
## Give Tapp to your coding agent
|
|
37
|
+
|
|
38
|
+
After setup, the whole user prompt is:
|
|
39
|
+
|
|
40
|
+
> Use Tapp to test this app.
|
|
41
|
+
|
|
42
|
+
The official skill teaches the agent to choose the smallest useful operation, handle repositories
|
|
43
|
+
with multiple app targets, inspect visual evidence, and keep exploration observations separate from
|
|
44
|
+
release judgment.
|
|
45
|
+
|
|
46
|
+
**Claude Code — skill and MCP tools together:**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
claude plugin marketplace add aarwitz/tapp
|
|
50
|
+
claude plugin install tapp@tapp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Restart Claude Code after installation, open the application repository, and use the short prompt
|
|
54
|
+
above. The plugin bundles both the `tapp` Agent Skill and the local stdio MCP server; no additional
|
|
55
|
+
prompt block or bundle id is required.
|
|
56
|
+
|
|
57
|
+
**Claude, Codex, Cursor, Copilot, and other Agent Skills clients — skill only:**
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx -y skills add aarwitz/tapp --skill tapp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This installs the open Agent Skills version of the same instructions into the current project and
|
|
64
|
+
lets the agent fall back to the npm CLI when MCP is not connected. Add `-g` for a user-wide install,
|
|
65
|
+
or `--agent claude-code`, `--agent codex`, and similar selectors to constrain the clients.
|
|
66
|
+
|
|
67
|
+
**No agent integration:** run the npm package directly from an app repository:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx -y @aarwitz/tapp init . --explore
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**VS Code:** install [Tapp from the Marketplace](https://marketplace.visualstudio.com/items?itemName=lidi-solutions.tapp).
|
|
74
|
+
It contributes the same cross-platform Agent Skill to Copilot plus focused iOS simulator tools and a
|
|
75
|
+
live simulator panel. Android and web remain available through the skill's CLI/MCP workflow.
|
|
76
|
+
|
|
34
77
|
```
|
|
35
78
|
you: "Add a logout button to the settings screen"
|
|
36
79
|
agent: *writes the Swift*
|
|
@@ -38,7 +81,7 @@ agent: *tapp: builds, opens the app, navigates to Settings, screenshots it*
|
|
|
38
81
|
agent: "Done — and here it is working on the simulator: [screenshot]"
|
|
39
82
|
```
|
|
40
83
|
|
|
41
|
-
##
|
|
84
|
+
## npm CLI quickstart
|
|
42
85
|
|
|
43
86
|
Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
|
|
44
87
|
emulator/device; web needs Playwright + Chromium.
|
|
@@ -109,7 +152,9 @@ npx -y @aarwitz/tapp build [dir] # just build + install (scheme auto-detecte
|
|
|
109
152
|
```
|
|
110
153
|
|
|
111
154
|
Web (beta): `npx -y @aarwitz/tapp explore http://localhost:3000` *(one-time setup:
|
|
112
|
-
`npm i -g playwright && npx playwright install chromium`)
|
|
155
|
+
`npm i -g playwright && npx playwright install chromium`)*. Add `--watch` to open Tapp's controlled,
|
|
156
|
+
isolated Chromium window and follow its clicks with an on-page pointer/action label. Tapp hides that
|
|
157
|
+
watch UI from saved evidence screenshots and does not automate your personal/default browser profile.
|
|
113
158
|
|
|
114
159
|
Focused web inspection waits briefly for loading states to settle. If a consent or location modal
|
|
115
160
|
blocks the screen, dismiss it and wait for the content you care about in the same package-only call:
|
|
@@ -194,7 +239,9 @@ Then ask your agent:
|
|
|
194
239
|
| 📱 | `tapp_list_simulators` / `boot_simulator` / `install_app` | Simulator + app management. |
|
|
195
240
|
| 🩺 | `tapp_health`, `tapp_capture*`, `tapp_parse_markers` | Diagnostics and capture history. |
|
|
196
241
|
|
|
197
|
-
|
|
242
|
+
Canonical installable skill: [`skills/tapp/SKILL.md`](skills/tapp/SKILL.md). Full low-level tool
|
|
243
|
+
reference: [AGENTS.md](./AGENTS.md). Both ship inside the npm package; installing the Claude plugin
|
|
244
|
+
or Agent Skill is what makes the workflow discoverable without pasting instructions.
|
|
198
245
|
Application-model and import contract: [`docs/application-model.md`](docs/application-model.md).
|
|
199
246
|
The desktop Coverage view reads the same `.tapp/application-model.json`,
|
|
200
247
|
`.tapp/release-plan.json`, and `.tapp/ui-map.json`, including explicit proposal review; it
|
package/bin/tapp.js
CHANGED
|
@@ -240,8 +240,8 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
240
240
|
|
|
241
241
|
function safeCommandUsage(verb) {
|
|
242
242
|
const usage = {
|
|
243
|
-
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
244
|
-
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
|
|
243
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
244
|
+
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
245
245
|
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
246
246
|
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
247
247
|
shot: "tapp shot [--out FILE]",
|
|
@@ -346,6 +346,7 @@ switch (command) {
|
|
|
346
346
|
timeout,
|
|
347
347
|
testEmail: typeof flags.email === "string" ? flags.email : undefined,
|
|
348
348
|
testPassword: typeof flags.password === "string" ? flags.password : undefined,
|
|
349
|
+
watch: flags.watch === true,
|
|
349
350
|
runExploration: engine?.runInitExploration,
|
|
350
351
|
onProgress: (progress) => {
|
|
351
352
|
const activePlatform = progress.platform || platform;
|
|
@@ -557,6 +558,7 @@ switch (command) {
|
|
|
557
558
|
testPassword: flags.password,
|
|
558
559
|
...launchOptions,
|
|
559
560
|
baselineFindings,
|
|
561
|
+
watch: flags.watch === true,
|
|
560
562
|
surface: "cli",
|
|
561
563
|
onProgress,
|
|
562
564
|
onStatus: (t) => console.error(`ℹ️ ${t}`),
|
|
@@ -577,6 +579,10 @@ switch (command) {
|
|
|
577
579
|
process.exit(2);
|
|
578
580
|
}
|
|
579
581
|
if (platform === "ios") requireMacFor("iOS testing");
|
|
582
|
+
if (flags.watch === true && platform !== "web") {
|
|
583
|
+
console.error("❌ --watch is currently available for web exploration only");
|
|
584
|
+
process.exit(2);
|
|
585
|
+
}
|
|
580
586
|
if (platform !== "ios" && Object.keys(launchOptions).length) {
|
|
581
587
|
console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
|
|
582
588
|
process.exit(2);
|
|
@@ -598,6 +604,7 @@ switch (command) {
|
|
|
598
604
|
testEmail: flags.email,
|
|
599
605
|
testPassword: flags.password,
|
|
600
606
|
baselineFindings,
|
|
607
|
+
watch: flags.watch === true,
|
|
601
608
|
surface: "cli",
|
|
602
609
|
onProgress,
|
|
603
610
|
})
|
|
@@ -1569,7 +1576,7 @@ switch (command) {
|
|
|
1569
1576
|
Core — explore, prove, gate (agents and humans can just run these — no server, no setup):
|
|
1570
1577
|
tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
|
|
1571
1578
|
release decision — run 'tapp ci' to gate a merge)
|
|
1572
|
-
(--platform ios|android|web · --
|
|
1579
|
+
(web: --watch · all: --platform ios|android|web · --actions N)
|
|
1573
1580
|
tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
|
|
1574
1581
|
tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
|
|
1575
1582
|
(see: tapp ci --help)
|
|
@@ -1629,6 +1636,11 @@ Setup:
|
|
|
1629
1636
|
tapp doctor Check Xcode / simulators / toolchain
|
|
1630
1637
|
tapp mcp Start the MCP server on stdio (adds inline screenshots + interactive sessions)
|
|
1631
1638
|
|
|
1639
|
+
Agent Skill (optional — so a short “Use Tapp to test this app” prompt is enough):
|
|
1640
|
+
Any supported agent: npx -y skills add aarwitz/tapp --skill tapp
|
|
1641
|
+
Claude skill + MCP: claude plugin marketplace add aarwitz/tapp
|
|
1642
|
+
claude plugin install tapp@tapp
|
|
1643
|
+
|
|
1632
1644
|
MCP hookup (optional — for inline screenshots and the tap/type/inspect session loop):
|
|
1633
1645
|
Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp mcp
|
|
1634
1646
|
Cursor/VS Code (mcp.json):
|
package/mcp-server/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
import {
|
|
11
11
|
CallToolRequestSchema,
|
|
12
|
+
GetPromptRequestSchema,
|
|
13
|
+
ListPromptsRequestSchema,
|
|
12
14
|
ListToolsRequestSchema,
|
|
13
15
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
16
|
|
|
@@ -17,7 +19,15 @@ import { existingProjectArtifactPath, projectArtifactDirectory } from "./project
|
|
|
17
19
|
|
|
18
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
21
|
const __dirname = path.dirname(__filename);
|
|
22
|
+
// `repoRoot` is the installed Tapp package root: scripts and bundled harness assets live here.
|
|
23
|
+
// Repository-facing MCP operations must use `workspaceRoot` instead. In an npm/Claude-plugin
|
|
24
|
+
// installation those are different directories, even though source-repo tests historically made
|
|
25
|
+
// them look identical.
|
|
20
26
|
const repoRoot = path.resolve(__dirname, "../..");
|
|
27
|
+
const workspaceRoot = (() => {
|
|
28
|
+
try { return fs.realpathSync(process.cwd()); }
|
|
29
|
+
catch { return path.resolve(process.cwd()); }
|
|
30
|
+
})();
|
|
21
31
|
const scriptsDir = path.join(repoRoot, "scripts");
|
|
22
32
|
// TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
|
|
23
33
|
// The old alias remains a read-only fallback; unset repository development stays local.
|
|
@@ -1463,7 +1473,7 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1463
1473
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1464
1474
|
// the surfaces can't drift.)
|
|
1465
1475
|
|
|
1466
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1476
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1467
1477
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1468
1478
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1469
1479
|
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1480,6 +1490,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1480
1490
|
testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
|
|
1481
1491
|
seedRoutes,
|
|
1482
1492
|
seedTargets,
|
|
1493
|
+
watch: watch === true,
|
|
1483
1494
|
onProgress,
|
|
1484
1495
|
});
|
|
1485
1496
|
} catch (err) {
|
|
@@ -1640,6 +1651,7 @@ export async function runInitExploration({
|
|
|
1640
1651
|
timeout,
|
|
1641
1652
|
testEmail,
|
|
1642
1653
|
testPassword,
|
|
1654
|
+
watch = false,
|
|
1643
1655
|
onProgress = () => {},
|
|
1644
1656
|
onStatus = () => {},
|
|
1645
1657
|
} = {}) {
|
|
@@ -1655,17 +1667,18 @@ export async function runInitExploration({
|
|
|
1655
1667
|
let targetResolution = null;
|
|
1656
1668
|
let qa;
|
|
1657
1669
|
let managedRuntime = null;
|
|
1670
|
+
if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1658
1671
|
if (selected === "web") {
|
|
1659
1672
|
if (/^https?:\/\//i.test(String(url))) {
|
|
1660
1673
|
resolvedTarget = String(url).trim();
|
|
1661
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1674
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1662
1675
|
} else {
|
|
1663
1676
|
const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
|
|
1664
1677
|
if (started.error) return started;
|
|
1665
1678
|
managedRuntime = started;
|
|
1666
1679
|
resolvedTarget = started.url;
|
|
1667
1680
|
try {
|
|
1668
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1681
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1669
1682
|
} finally {
|
|
1670
1683
|
await stopManagedWebTarget(started);
|
|
1671
1684
|
}
|
|
@@ -1777,6 +1790,7 @@ export async function runExploreTarget({
|
|
|
1777
1790
|
appLaunchArgs,
|
|
1778
1791
|
appLaunchEnv,
|
|
1779
1792
|
baselineFindings,
|
|
1793
|
+
watch = false,
|
|
1780
1794
|
surface = "cli",
|
|
1781
1795
|
onProgress = () => {},
|
|
1782
1796
|
onStatus = () => {},
|
|
@@ -1799,6 +1813,8 @@ export async function runExploreTarget({
|
|
|
1799
1813
|
catch (error) { return { error: error.message || String(error) }; }
|
|
1800
1814
|
const selectedPlatform = selected.platform;
|
|
1801
1815
|
|
|
1816
|
+
if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1817
|
+
|
|
1802
1818
|
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1803
1819
|
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1804
1820
|
}
|
|
@@ -1807,14 +1823,14 @@ export async function runExploreTarget({
|
|
|
1807
1823
|
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1808
1824
|
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1809
1825
|
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1810
|
-
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1826
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1811
1827
|
}
|
|
1812
1828
|
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1813
1829
|
onStatus(`Preparing the managed web runtime for ${selected.name}…`);
|
|
1814
1830
|
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1815
1831
|
if (started.error) return started;
|
|
1816
1832
|
try {
|
|
1817
|
-
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1833
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1818
1834
|
} finally {
|
|
1819
1835
|
await stopManagedWebTarget(started);
|
|
1820
1836
|
onStatus("Stopped the managed web runtime.");
|
|
@@ -2037,11 +2053,61 @@ const server = new Server(
|
|
|
2037
2053
|
},
|
|
2038
2054
|
{
|
|
2039
2055
|
capabilities: {
|
|
2056
|
+
prompts: {},
|
|
2040
2057
|
tools: {},
|
|
2041
2058
|
},
|
|
2042
2059
|
}
|
|
2043
2060
|
);
|
|
2044
2061
|
|
|
2062
|
+
const testAppPrompt = {
|
|
2063
|
+
name: "test-app",
|
|
2064
|
+
title: "Test this app with Tapp",
|
|
2065
|
+
description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
|
|
2066
|
+
arguments: [
|
|
2067
|
+
{
|
|
2068
|
+
name: "goal",
|
|
2069
|
+
description: "What to verify, such as finding bugs or exercising checkout",
|
|
2070
|
+
required: false,
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
name: "target",
|
|
2074
|
+
description: "Optional repo target, bundle/app id, APK path, or owned URL",
|
|
2075
|
+
required: false,
|
|
2076
|
+
},
|
|
2077
|
+
],
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
|
|
2081
|
+
|
|
2082
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
2083
|
+
if (request.params.name !== testAppPrompt.name) {
|
|
2084
|
+
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
2085
|
+
}
|
|
2086
|
+
const goal = isNonEmptyString(request.params.arguments?.goal)
|
|
2087
|
+
? request.params.arguments.goal.trim()
|
|
2088
|
+
: "Test the app and find important bugs";
|
|
2089
|
+
const target = isNonEmptyString(request.params.arguments?.target)
|
|
2090
|
+
? ` Use this target: ${request.params.arguments.target.trim()}.`
|
|
2091
|
+
: "";
|
|
2092
|
+
return {
|
|
2093
|
+
description: testAppPrompt.description,
|
|
2094
|
+
messages: [
|
|
2095
|
+
{
|
|
2096
|
+
role: "user",
|
|
2097
|
+
content: {
|
|
2098
|
+
type: "text",
|
|
2099
|
+
text:
|
|
2100
|
+
`${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
|
|
2101
|
+
"Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
|
|
2102
|
+
"If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
|
|
2103
|
+
"Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
|
|
2104
|
+
"never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
|
|
2105
|
+
},
|
|
2106
|
+
},
|
|
2107
|
+
],
|
|
2108
|
+
};
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2045
2111
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2046
2112
|
tools: [
|
|
2047
2113
|
{
|
|
@@ -2213,6 +2279,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2213
2279
|
androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
|
|
2214
2280
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2215
2281
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2282
|
+
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2216
2283
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2217
2284
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2218
2285
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2283,6 +2350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2283
2350
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2284
2351
|
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2285
2352
|
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2353
|
+
watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
|
|
2286
2354
|
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2287
2355
|
outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
|
|
2288
2356
|
},
|
|
@@ -2736,45 +2804,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2736
2804
|
const { name, arguments: args = {} } = request.params;
|
|
2737
2805
|
|
|
2738
2806
|
if (name === "tapp_health") {
|
|
2739
|
-
const
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2807
|
+
const coreChecks = [];
|
|
2808
|
+
coreChecks.push({
|
|
2809
|
+
check: "workspace",
|
|
2810
|
+
ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
|
|
2811
|
+
value: workspaceRoot,
|
|
2812
|
+
required: true,
|
|
2745
2813
|
});
|
|
2746
2814
|
|
|
2747
2815
|
const nodeVersion = await runCommand("node", ["-v"]);
|
|
2748
|
-
|
|
2816
|
+
coreChecks.push({
|
|
2749
2817
|
check: "node",
|
|
2750
2818
|
ok: nodeVersion.code === 0,
|
|
2751
2819
|
value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
|
|
2820
|
+
required: true,
|
|
2752
2821
|
});
|
|
2753
2822
|
|
|
2754
2823
|
const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
|
|
2759
|
-
});
|
|
2760
|
-
|
|
2761
|
-
const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
|
|
2762
|
-
checks.push({
|
|
2763
|
-
check: "bootedSimulator",
|
|
2764
|
-
ok: simctl.code === 0,
|
|
2765
|
-
value: (simctl.stdout || simctl.stderr).trim(),
|
|
2766
|
-
});
|
|
2767
|
-
|
|
2768
|
-
const allOk = checks.every((c) => c.ok);
|
|
2824
|
+
const simctl = xcodebuildVersion.code === 0
|
|
2825
|
+
? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
|
|
2826
|
+
: { code: 1, stdout: "", stderr: "Xcode not found" };
|
|
2769
2827
|
const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
|
|
2770
2828
|
const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
|
|
2771
|
-
const
|
|
2829
|
+
const adb = await runCommand("adb", ["devices"]);
|
|
2830
|
+
const androidDevice = adb.code === 0
|
|
2831
|
+
? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
|
|
2832
|
+
: null;
|
|
2833
|
+
let web = { ok: false, value: "Playwright or Chromium not installed" };
|
|
2834
|
+
try {
|
|
2835
|
+
const { chromium } = await import("playwright");
|
|
2836
|
+
const executable = chromium.executablePath();
|
|
2837
|
+
web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
|
|
2838
|
+
} catch { /* optional dependency may be intentionally absent */ }
|
|
2839
|
+
const platformChecks = [
|
|
2840
|
+
{
|
|
2841
|
+
check: "iOS",
|
|
2842
|
+
ok: xcodebuildVersion.code === 0 && !!bootedName,
|
|
2843
|
+
value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
|
|
2844
|
+
required: false,
|
|
2845
|
+
},
|
|
2846
|
+
{
|
|
2847
|
+
check: "Android",
|
|
2848
|
+
ok: !!androidDevice,
|
|
2849
|
+
value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
|
|
2850
|
+
required: false,
|
|
2851
|
+
},
|
|
2852
|
+
{ check: "web", ok: web.ok, value: web.value, required: false },
|
|
2853
|
+
];
|
|
2854
|
+
const checks = [...coreChecks, ...platformChecks];
|
|
2855
|
+
const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
|
|
2856
|
+
const L = [`### ${ready ? "🩺 Tapp ready" : "⚠️ Tapp needs a platform runtime"}`, ""];
|
|
2772
2857
|
for (const c of checks) {
|
|
2773
|
-
|
|
2858
|
+
const icon = c.ok ? "✅" : c.required ? "❌" : "⚪️";
|
|
2859
|
+
L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
|
|
2774
2860
|
}
|
|
2775
|
-
L.push("");
|
|
2776
|
-
L.
|
|
2777
|
-
return richResult(L.join("\n"), { ok: allOk, checks });
|
|
2861
|
+
if (!ready) L.push("", "Run `tapp doctor` in the application repository for exact remediation.");
|
|
2862
|
+
return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
|
|
2778
2863
|
}
|
|
2779
2864
|
|
|
2780
2865
|
if (name === "tapp_build") {
|
|
@@ -2971,6 +3056,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2971
3056
|
if (targets !== 1) {
|
|
2972
3057
|
return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
|
|
2973
3058
|
}
|
|
3059
|
+
if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
|
|
2974
3060
|
|
|
2975
3061
|
// Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
|
|
2976
3062
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
@@ -2992,6 +3078,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2992
3078
|
testEmail: args.testEmail,
|
|
2993
3079
|
testPassword: args.testPassword,
|
|
2994
3080
|
baselineFindings: args.baselineFindings,
|
|
3081
|
+
watch: args.watch === true,
|
|
2995
3082
|
onProgress: notifyProgress("pages reached"),
|
|
2996
3083
|
});
|
|
2997
3084
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3036,8 +3123,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3036
3123
|
if (unauthorized) return unauthorized;
|
|
3037
3124
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3038
3125
|
if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
|
|
3039
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3040
|
-
if (!isInsideDir(
|
|
3126
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3127
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3041
3128
|
const maxContracts = asInteger(args.maxContracts, 15);
|
|
3042
3129
|
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
3043
3130
|
const { initializeProductProject } = await import("./product-operations.js");
|
|
@@ -3061,6 +3148,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3061
3148
|
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3062
3149
|
maxActions: args.maxActions, timeout: args.timeout, maxContracts,
|
|
3063
3150
|
testEmail: args.testEmail, testPassword: args.testPassword,
|
|
3151
|
+
watch: args.watch === true,
|
|
3064
3152
|
runExploration: runInitExploration,
|
|
3065
3153
|
onProgress: (progress) => {
|
|
3066
3154
|
if (progressToken === undefined) return;
|
|
@@ -3092,8 +3180,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3092
3180
|
const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
|
|
3093
3181
|
const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
|
|
3094
3182
|
if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
|
|
3095
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3096
|
-
if (!isInsideDir(
|
|
3183
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3184
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3097
3185
|
const { configureActor, readProjectConfig } = await import("./project-config.js");
|
|
3098
3186
|
if (operation === "read") {
|
|
3099
3187
|
const loaded = readProjectConfig(projectDir);
|
|
@@ -3122,8 +3210,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3122
3210
|
if (unauthorized) return unauthorized;
|
|
3123
3211
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3124
3212
|
if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
|
|
3125
|
-
const planPath = isNonEmptyString(args.planPath) ? path.resolve(
|
|
3126
|
-
if (!isInsideDir(
|
|
3213
|
+
const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
|
|
3214
|
+
if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
|
|
3127
3215
|
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
3128
3216
|
let plan;
|
|
3129
3217
|
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
@@ -3133,11 +3221,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3133
3221
|
if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
|
|
3134
3222
|
const { reviewProductPlan } = await import("./product-operations.js");
|
|
3135
3223
|
try {
|
|
3136
|
-
plan = reviewProductPlan({ projectDir:
|
|
3224
|
+
plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
|
|
3137
3225
|
} catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
|
|
3138
3226
|
} else if (operation === "generate") {
|
|
3139
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3140
|
-
if (!isInsideDir(
|
|
3227
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3228
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3141
3229
|
const { generateProductPlan } = await import("./product-operations.js");
|
|
3142
3230
|
try {
|
|
3143
3231
|
const result = await generateProductPlan({ projectDir, planPath });
|
|
@@ -3145,8 +3233,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3145
3233
|
return richResult(`🧩 Proposal drafts — ${result.generatedTasks.length} UI-Map-grounded Task(s) · ${result.generated.length} compile-checked/untrusted contract(s) · ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
|
|
3146
3234
|
} catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
|
|
3147
3235
|
} else if (operation === "validate") {
|
|
3148
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3149
|
-
if (!isInsideDir(
|
|
3236
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3237
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3150
3238
|
let apkPath = "";
|
|
3151
3239
|
if (isNonEmptyString(args.apkPath)) {
|
|
3152
3240
|
apkPath = path.resolve(projectDir, args.apkPath.trim());
|
|
@@ -3178,8 +3266,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3178
3266
|
return richResult(`🔎 Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
|
|
3179
3267
|
} catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
|
|
3180
3268
|
} else if (operation === "promote") {
|
|
3181
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3182
|
-
if (!isInsideDir(
|
|
3269
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3270
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3183
3271
|
const { promoteProductPlan } = await import("./product-operations.js");
|
|
3184
3272
|
try {
|
|
3185
3273
|
const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
|
|
@@ -3195,9 +3283,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3195
3283
|
if (unauthorized) return unauthorized;
|
|
3196
3284
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3197
3285
|
if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
|
|
3198
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3199
|
-
if (!isInsideDir(
|
|
3200
|
-
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(
|
|
3286
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3287
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3288
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
3201
3289
|
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3202
3290
|
let model;
|
|
3203
3291
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -3205,8 +3293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3205
3293
|
const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
|
|
3206
3294
|
if (operation === "baseline") {
|
|
3207
3295
|
if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
|
|
3208
|
-
const reportPath = path.resolve(
|
|
3209
|
-
if (!isInsideDir(
|
|
3296
|
+
const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
|
|
3297
|
+
if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
|
|
3210
3298
|
let report;
|
|
3211
3299
|
try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
|
|
3212
3300
|
catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
|
|
@@ -3231,8 +3319,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3231
3319
|
if (unauthorized) return unauthorized;
|
|
3232
3320
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3233
3321
|
const resolveRepoFile = (value, fallback = "") => {
|
|
3234
|
-
const resolved = path.resolve(
|
|
3235
|
-
return isInsideDir(
|
|
3322
|
+
const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
|
|
3323
|
+
return isInsideDir(workspaceRoot, resolved) ? resolved : null;
|
|
3236
3324
|
};
|
|
3237
3325
|
const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
|
|
3238
3326
|
if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
|
|
@@ -3252,18 +3340,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3252
3340
|
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3253
3341
|
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3254
3342
|
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3255
|
-
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3343
|
+
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3256
3344
|
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3257
3345
|
try {
|
|
3258
3346
|
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
3259
3347
|
const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
|
|
3260
3348
|
writeUiMap(outPath, map);
|
|
3261
3349
|
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
3262
|
-
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(
|
|
3350
|
+
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
|
|
3263
3351
|
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3264
3352
|
}
|
|
3265
3353
|
if (operation !== "read") return errorResult("operation must be read|build|diff");
|
|
3266
|
-
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3354
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3267
3355
|
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3268
3356
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3269
3357
|
try {
|
|
@@ -3280,8 +3368,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3280
3368
|
if (unauthorized) return unauthorized;
|
|
3281
3369
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3282
3370
|
if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
|
|
3283
|
-
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(
|
|
3284
|
-
if (!taskPath || !isInsideDir(
|
|
3371
|
+
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
|
|
3372
|
+
if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
|
|
3285
3373
|
if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
|
|
3286
3374
|
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
|
|
3287
3375
|
let task;
|
|
@@ -3292,8 +3380,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3292
3380
|
let groundingMap = null;
|
|
3293
3381
|
let groundingMapPath = null;
|
|
3294
3382
|
if (isNonEmptyString(args.mapPath)) {
|
|
3295
|
-
const mapPath = path.resolve(
|
|
3296
|
-
if (!isInsideDir(
|
|
3383
|
+
const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3384
|
+
if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3297
3385
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3298
3386
|
groundingMapPath = mapPath;
|
|
3299
3387
|
try {
|
|
@@ -3323,12 +3411,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3323
3411
|
const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
|
|
3324
3412
|
let outPath = null;
|
|
3325
3413
|
if (isNonEmptyString(args.outPath)) {
|
|
3326
|
-
outPath = path.resolve(
|
|
3327
|
-
if (!isInsideDir(
|
|
3414
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3415
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3328
3416
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3329
3417
|
fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
|
|
3330
3418
|
}
|
|
3331
|
-
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(
|
|
3419
|
+
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
|
|
3332
3420
|
}
|
|
3333
3421
|
|
|
3334
3422
|
if (name === "tapp_release_contract") {
|
|
@@ -3336,8 +3424,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3336
3424
|
if (unauthorized) return unauthorized;
|
|
3337
3425
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3338
3426
|
if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
|
|
3339
|
-
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(
|
|
3340
|
-
if (!contractPath || !isInsideDir(
|
|
3427
|
+
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
|
|
3428
|
+
if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
|
|
3341
3429
|
const {
|
|
3342
3430
|
applyReleaseContractCoverage,
|
|
3343
3431
|
compileReleaseContract,
|
|
@@ -3352,8 +3440,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3352
3440
|
let groundingMap = null;
|
|
3353
3441
|
let groundingMapPath = null;
|
|
3354
3442
|
if (isNonEmptyString(args.mapPath)) {
|
|
3355
|
-
groundingMapPath = path.resolve(
|
|
3356
|
-
if (!isInsideDir(
|
|
3443
|
+
groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3444
|
+
if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3357
3445
|
if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3358
3446
|
try {
|
|
3359
3447
|
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
@@ -3374,8 +3462,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3374
3462
|
catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
|
|
3375
3463
|
let outPath = null;
|
|
3376
3464
|
if (isNonEmptyString(args.outPath)) {
|
|
3377
|
-
outPath = path.resolve(
|
|
3378
|
-
if (!isInsideDir(
|
|
3465
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3466
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3379
3467
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3380
3468
|
fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
|
|
3381
3469
|
}
|
|
@@ -3422,12 +3510,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3422
3510
|
if (unauthorized) return unauthorized;
|
|
3423
3511
|
const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
|
|
3424
3512
|
if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
|
|
3425
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3426
|
-
if (!isInsideDir(
|
|
3513
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3514
|
+
if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
|
|
3427
3515
|
if (operation === "adopt") {
|
|
3428
3516
|
if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
|
|
3429
3517
|
const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
|
|
3430
|
-
if (!isInsideDir(
|
|
3518
|
+
if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
|
|
3431
3519
|
const { adoptPrCoverageProposal } = await import("./pr-selection.js");
|
|
3432
3520
|
try {
|
|
3433
3521
|
const adopted = adoptPrCoverageProposal({
|
|
@@ -3471,8 +3559,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3471
3559
|
fs.writeFileSync(flowFile, JSON.stringify(args.flow));
|
|
3472
3560
|
parsedFlow = args.flow;
|
|
3473
3561
|
} else if (isNonEmptyString(args.flowPath)) {
|
|
3474
|
-
const p = path.resolve(
|
|
3475
|
-
if (!isInsideDir(
|
|
3562
|
+
const p = path.resolve(workspaceRoot, args.flowPath.trim());
|
|
3563
|
+
if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
|
|
3476
3564
|
if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
|
|
3477
3565
|
flowFile = p;
|
|
3478
3566
|
} else {
|
|
@@ -3554,8 +3642,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3554
3642
|
if (args.scenario && typeof args.scenario === "object") {
|
|
3555
3643
|
scenario = args.scenario;
|
|
3556
3644
|
} else if (isNonEmptyString(args.scenarioPath)) {
|
|
3557
|
-
const scenarioFile = path.resolve(
|
|
3558
|
-
if (!isInsideDir(
|
|
3645
|
+
const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
|
|
3646
|
+
if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
|
|
3559
3647
|
if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
|
|
3560
3648
|
try {
|
|
3561
3649
|
const { loadScenarioFile } = await import("./scenario-runtime.js");
|
|
@@ -3625,7 +3713,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3625
3713
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3626
3714
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3627
3715
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3628
|
-
const dir = path.join(
|
|
3716
|
+
const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
|
|
3629
3717
|
fs.mkdirSync(dir, { recursive: true });
|
|
3630
3718
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3631
3719
|
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
@@ -3633,7 +3721,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3633
3721
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3634
3722
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3635
3723
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3636
|
-
const rel = path.relative(
|
|
3724
|
+
const rel = path.relative(workspaceRoot, outPath);
|
|
3637
3725
|
|
|
3638
3726
|
const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
|
|
3639
3727
|
L.push(`⚠️ This is an **untrusted draft**, not a committed test. Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
|
|
@@ -3888,7 +3976,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3888
3976
|
const unauthorized = ensureAuthorized(args);
|
|
3889
3977
|
if (unauthorized) return unauthorized;
|
|
3890
3978
|
try {
|
|
3891
|
-
const saved = await saveInteractiveSessionFlow({ projectDir:
|
|
3979
|
+
const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
|
|
3892
3980
|
const text = `💾 Saved flow **${saved.flow.name}** → \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
|
|
3893
3981
|
return richResult(text, { path:saved.path, flow:saved.flow });
|
|
3894
3982
|
} catch (error) {
|
|
@@ -363,6 +363,7 @@ export async function initializeProductProject({
|
|
|
363
363
|
maxContracts = 15,
|
|
364
364
|
testEmail,
|
|
365
365
|
testPassword,
|
|
366
|
+
watch = false,
|
|
366
367
|
runExploration,
|
|
367
368
|
onProgress = () => {},
|
|
368
369
|
onStatus = () => {},
|
|
@@ -394,7 +395,7 @@ export async function initializeProductProject({
|
|
|
394
395
|
exploration = await runExploration({
|
|
395
396
|
projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
|
|
396
397
|
bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
|
|
397
|
-
testEmail, testPassword, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
|
|
398
|
+
testEmail, testPassword, watch, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
|
|
398
399
|
});
|
|
399
400
|
if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
|
|
400
401
|
}
|
|
@@ -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
|
|
|
@@ -604,7 +704,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
604
704
|
if (existingKey && existingKey !== evidenceKey) screenshotFor.delete(existingKey);
|
|
605
705
|
screenshotFor.set(evidenceKey, { path: screenshotPath, busy: info.busy, route: key });
|
|
606
706
|
screenCount = screenshotFor.size;
|
|
607
|
-
await page
|
|
707
|
+
await screenshotWithoutWebWatchUi(page, { path: screenshotPath }, watch).catch(() => {});
|
|
608
708
|
// Deterministic per-page detectors run once per distinct screen.
|
|
609
709
|
if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
|
|
610
710
|
else {
|
|
@@ -628,12 +728,19 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
628
728
|
if (!(await pw.isVisible().catch(() => false))) return null;
|
|
629
729
|
loginTried = true;
|
|
630
730
|
const emailSel = "input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]";
|
|
631
|
-
if (testEmail)
|
|
731
|
+
if (testEmail) {
|
|
732
|
+
const email = page.locator(emailSel).first();
|
|
733
|
+
if (watch) await showWebWatchAction(page, { locator: email, action: "Type", target: "Email" });
|
|
734
|
+
await email.fill(testEmail).catch(() => {});
|
|
735
|
+
}
|
|
736
|
+
if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
|
|
632
737
|
await pw.fill(testPassword).catch(() => {});
|
|
633
738
|
lastActionTarget = "Sign in";
|
|
634
739
|
emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
635
740
|
actions += 1;
|
|
636
|
-
await submitWebLogin(page
|
|
741
|
+
await submitWebLogin(page, watch
|
|
742
|
+
? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
|
|
743
|
+
: null).catch(() => false);
|
|
637
744
|
await waitForWebStability(page, { timeoutMs: Math.min(5_000, CLICK_SETTLE_MS * 6) });
|
|
638
745
|
// Still on the login form after a submit = the sign-in failed — full stop. (A quiet
|
|
639
746
|
// credential rejection often shows NO other symptom, so this must not be coupled to
|
|
@@ -688,6 +795,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
688
795
|
progress();
|
|
689
796
|
continue;
|
|
690
797
|
}
|
|
798
|
+
if (watch) await showWebWatchAction(page, { action: "Open", target });
|
|
691
799
|
await waitForWebStability(page);
|
|
692
800
|
if (nav && typeof nav.status === "function" && nav.status() === 404) {
|
|
693
801
|
issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
|
|
@@ -708,6 +816,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
708
816
|
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
817
|
let acted = false;
|
|
710
818
|
if (action.type === "back") {
|
|
819
|
+
if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
|
|
711
820
|
await page.goBack({ waitUntil: "domcontentloaded", timeout: step.wait?.timeoutMs || NAV_TIMEOUT_MS }).catch(() => {});
|
|
712
821
|
acted = true;
|
|
713
822
|
} else {
|
|
@@ -721,6 +830,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
721
830
|
else continue;
|
|
722
831
|
if (await locator.isVisible().catch(() => false)) {
|
|
723
832
|
try {
|
|
833
|
+
if (watch) await showWebWatchAction(page, { locator, action: "Click", target: action.target });
|
|
724
834
|
await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) });
|
|
725
835
|
acted = true;
|
|
726
836
|
break;
|
|
@@ -785,6 +895,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
785
895
|
emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
786
896
|
let clickSucceeded = false;
|
|
787
897
|
try {
|
|
898
|
+
if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
|
|
788
899
|
await b.click({ timeout: 3000 });
|
|
789
900
|
clickSucceeded = true;
|
|
790
901
|
} catch {}
|
|
@@ -795,6 +906,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
795
906
|
await waitForWebStability(page);
|
|
796
907
|
if (page.url() !== beforeState.url) {
|
|
797
908
|
await observe();
|
|
909
|
+
if (watch) await showWebWatchAction(page, { action: "Back", target: ob.screen });
|
|
798
910
|
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
799
911
|
await waitForWebStability(page);
|
|
800
912
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.0-rc.
|
|
3
|
+
"version": "0.17.0-rc.12",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
5
|
"description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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": {
|
|
@@ -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/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",
|
|
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/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
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tapp
|
|
3
|
+
description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence, or run the deterministic Tapp gate. Also use when the user mentions Tapp, @aarwitz/tapp, tapp_* tools, .tapp artifacts, or asks whether agent-authored UI actually works.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tapp
|
|
7
|
+
|
|
8
|
+
Use Tapp as the app's hands and eyes. Work on the real UI surface and show evidence; do not claim a
|
|
9
|
+
screen or journey works from source inspection alone.
|
|
10
|
+
|
|
11
|
+
## Choose the smallest operation
|
|
12
|
+
|
|
13
|
+
| Intent | Operation |
|
|
14
|
+
|---|---|
|
|
15
|
+
| See or screenshot one screen | `open` / `tapp_open_app` |
|
|
16
|
+
| Inspect controls on the current screen | `tree` / `tapp_ui_tree` |
|
|
17
|
+
| Drive a specific journey | MCP session start → act → end |
|
|
18
|
+
| Find bugs autonomously | `explore` / `tapp_explore` |
|
|
19
|
+
| Preserve a journey | record and save a Flow; replay it deterministically |
|
|
20
|
+
| Decide whether a merge passes policy | `ci`; exploration never decides this |
|
|
21
|
+
|
|
22
|
+
Prefer connected `tapp_*` MCP tools when available: they keep interactive sessions alive and return
|
|
23
|
+
screenshots inline. Otherwise run `npx -y @aarwitz/tapp` from the app repository. Do not require MCP,
|
|
24
|
+
an account, an API key, or a global install for the core workflow.
|
|
25
|
+
|
|
26
|
+
## Start source-connected
|
|
27
|
+
|
|
28
|
+
When the user asks for a general first test of a repository:
|
|
29
|
+
|
|
30
|
+
1. If `.tapp/application-model.json` exists, run `npx -y @aarwitz/tapp explore`.
|
|
31
|
+
2. Otherwise run `npx -y @aarwitz/tapp init . --explore` or call `tapp_init` with
|
|
32
|
+
`{operation:"explore", projectDir:"."}`.
|
|
33
|
+
3. If Tapp returns `target-selection-required`, present its actual choices and ask the user to pick.
|
|
34
|
+
Never guess among multiple targets. Re-run with the selected platform/target exactly as Tapp
|
|
35
|
+
instructs.
|
|
36
|
+
4. If a prerequisite is missing, run `tapp doctor`, apply only the stated remediation that is in
|
|
37
|
+
scope, and retry once.
|
|
38
|
+
|
|
39
|
+
For a focused request, use the requested target directly rather than forcing repository onboarding.
|
|
40
|
+
Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
|
|
41
|
+
or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
|
|
42
|
+
clicks and types.
|
|
43
|
+
|
|
44
|
+
## Observe honestly
|
|
45
|
+
|
|
46
|
+
Exploration returns findings, coverage, evidence, and `inconclusive`; it does not return a score or
|
|
47
|
+
ship verdict. Report:
|
|
48
|
+
|
|
49
|
+
- target and platform;
|
|
50
|
+
- screens/actions and whether coverage was conclusive;
|
|
51
|
+
- deterministic versus advisory finding counts;
|
|
52
|
+
- each important finding and its evidence/report path;
|
|
53
|
+
- what Tapp explicitly did not check.
|
|
54
|
+
|
|
55
|
+
If `inconclusive: true`, explain the blocker. A login wall or missing test data is not a pass. Ask for
|
|
56
|
+
credentials or launch configuration instead of rerunning blindly. Do not infer content accuracy,
|
|
57
|
+
privacy, brand consistency, or business guarantees from a generic crawl; those require a reviewed
|
|
58
|
+
Flow, Scenario, contract, verifier, or human review.
|
|
59
|
+
|
|
60
|
+
When a screenshot path is printed, open it with the client's image-reading tool before describing
|
|
61
|
+
the screen. For web, use `--watch` when the human wants to follow Tapp's controlled browser. For iOS,
|
|
62
|
+
point the human to the report's exploration recording when available.
|
|
63
|
+
|
|
64
|
+
## Drive safely
|
|
65
|
+
|
|
66
|
+
For an interactive MCP session, read returned `elements[]` before every action, target accessibility
|
|
67
|
+
ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
|
|
68
|
+
content. Use coordinates only as a last resort. End the session when finished.
|
|
69
|
+
|
|
70
|
+
Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
|
|
71
|
+
what the evidence proves and what remains untested.
|
|
72
|
+
|
|
73
|
+
Read [references/commands.md](references/commands.md) only when exact CLI/MCP syntax, Flow replay,
|
|
74
|
+
credentials, or platform prerequisites are needed.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Tapp command reference
|
|
2
|
+
|
|
3
|
+
## Plain CLI
|
|
4
|
+
|
|
5
|
+
Run from the application repository. `[target]` is optional when Tapp can read the repository model
|
|
6
|
+
or detect one unambiguous target.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx -y @aarwitz/tapp init . --explore
|
|
10
|
+
npx -y @aarwitz/tapp explore [target]
|
|
11
|
+
npx -y @aarwitz/tapp open [target]
|
|
12
|
+
npx -y @aarwitz/tapp tree [target] --json
|
|
13
|
+
npx -y @aarwitz/tapp shot
|
|
14
|
+
npx -y @aarwitz/tapp report latest
|
|
15
|
+
npx -y @aarwitz/tapp doctor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Platform examples:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Web: Tapp may build/start/stop a source target; --watch is human-visible.
|
|
22
|
+
npx -y @aarwitz/tapp explore --platform web --target website --watch
|
|
23
|
+
npx -y @aarwitz/tapp explore https://staging.example.com
|
|
24
|
+
|
|
25
|
+
# iOS: source repo, .app, or bundle id.
|
|
26
|
+
npx -y @aarwitz/tapp explore MyApp.xcodeproj --platform ios
|
|
27
|
+
npx -y @aarwitz/tapp open com.example.MyApp --platform ios
|
|
28
|
+
|
|
29
|
+
# Android: app id is required; APK is optional if already installed.
|
|
30
|
+
npx -y @aarwitz/tapp explore app-debug.apk --platform android --app-id com.example.app
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Focused web evidence can perform one semantic interaction and wait for async content:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx -y @aarwitz/tapp open https://example.com --tap "Not now" --wait-for "Dashboard"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## MCP mapping
|
|
40
|
+
|
|
41
|
+
- `tapp_init`: inspect or initialize a source repository; `operation:"explore"` prepares and explores.
|
|
42
|
+
- `tapp_build`: build and install an iOS app without needing its bundle id first.
|
|
43
|
+
- `tapp_open_app`: launch and return a screen summary plus inline screenshot.
|
|
44
|
+
- `tapp_ui_tree` / `tapp_screenshot`: inspect the current real surface.
|
|
45
|
+
- `tapp_session_start` → `tapp_session_act` → `tapp_session_end`: drive one persistent journey.
|
|
46
|
+
- `tapp_explore`: autonomous iOS, Android, or web exploration; observation only.
|
|
47
|
+
- `tapp_flow_save` / `tapp_flow_run`: save a driven journey and replay it deterministically.
|
|
48
|
+
- `tapp_release_contract`: validate, compile, or run a reviewed business guarantee.
|
|
49
|
+
- `tapp_ci_setup`: create a target-scoped baseline or reviewable CI installation.
|
|
50
|
+
|
|
51
|
+
An iOS no-bundle-id MCP path is `tapp_build {projectDir:"."}` followed by `tapp_explore` with the
|
|
52
|
+
returned `bundleId`. Android uses `androidAppId` and optional `apkPath`; web uses `url` or
|
|
53
|
+
source-connected `tapp_init`.
|
|
54
|
+
|
|
55
|
+
## Interactive session loop
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
tapp_session_start {appBundleId:"com.example.app"}
|
|
59
|
+
tapp_session_act {action:"tap", id:"Email"}
|
|
60
|
+
tapp_session_act {action:"type", text:"qa@example.com"}
|
|
61
|
+
tapp_session_act {action:"tap", id:"Password"}
|
|
62
|
+
tapp_session_act {action:"type", text:"..."}
|
|
63
|
+
tapp_session_act {action:"tap", id:"Sign In"}
|
|
64
|
+
tapp_session_act {action:"wait", text:"Home", timeoutMs:10000}
|
|
65
|
+
tapp_screenshot
|
|
66
|
+
tapp_session_end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Android sessions use `androidAppId`, optional `apkPath`, and the same action loop.
|
|
70
|
+
|
|
71
|
+
## Credentials and test configuration
|
|
72
|
+
|
|
73
|
+
For autonomous exploration, pass test-only values when authorized:
|
|
74
|
+
|
|
75
|
+
- CLI: `--email`, `--password`, repeated `--launch-arg`, and JSON `--launch-env`.
|
|
76
|
+
- MCP: `testEmail`, `testPassword`, `inputOverrides`, `appLaunchArgs`, `appLaunchEnv`, or explicit
|
|
77
|
+
`loginSteps`.
|
|
78
|
+
|
|
79
|
+
Do not persist secrets in `.tapp/`. If the result reports input fields and no values were supplied,
|
|
80
|
+
ask the user rather than pretending the explored surface was complete.
|
|
81
|
+
|
|
82
|
+
## Replay and gating
|
|
83
|
+
|
|
84
|
+
Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml
|
|
88
|
+
npx -y @aarwitz/tapp ci
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
|
|
92
|
+
Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
|
|
93
|
+
or `inconclusive`; both `fail` and `inconclusive` block a merge.
|
|
94
|
+
|
|
95
|
+
## Platform prerequisites
|
|
96
|
+
|
|
97
|
+
- iOS: macOS, Xcode, and a booted simulator. First use builds a cached harness under `~/.tapp`.
|
|
98
|
+
- Android: `adb` and a connected authorized emulator/device.
|
|
99
|
+
- Web: Playwright and Chromium. If Tapp reports the browser missing, run
|
|
100
|
+
`npx playwright install chromium` and retry.
|
|
101
|
+
|
|
102
|
+
Captures and screenshots are stored under `~/.tapp/captures/` and `~/.tapp/shots/`.
|