playwright-e2e-mcp 0.8.0 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +39 -9
  2. package/dist/apps/index.d.ts +29 -0
  3. package/dist/apps/index.js +52 -0
  4. package/dist/apps/index.js.map +1 -0
  5. package/dist/apps/test-report-html.d.ts +15 -0
  6. package/dist/apps/test-report-html.js +430 -0
  7. package/dist/apps/test-report-html.js.map +1 -0
  8. package/dist/cli/upload.d.ts +6 -5
  9. package/dist/cli/upload.js +20 -17
  10. package/dist/cli/upload.js.map +1 -1
  11. package/dist/index.js +4 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/oauth.d.ts +12 -2
  14. package/dist/oauth.js +44 -6
  15. package/dist/oauth.js.map +1 -1
  16. package/dist/server.js +17 -0
  17. package/dist/server.js.map +1 -1
  18. package/dist/tools/analyze-history.js +9 -9
  19. package/dist/tools/analyze-history.js.map +1 -1
  20. package/dist/tools/audit-tests.js +24 -6
  21. package/dist/tools/audit-tests.js.map +1 -1
  22. package/dist/tools/compare-visual-state.js +7 -1
  23. package/dist/tools/compare-visual-state.js.map +1 -1
  24. package/dist/tools/generate-e2e-test.js +27 -12
  25. package/dist/tools/generate-e2e-test.js.map +1 -1
  26. package/dist/tools/quarantine.js +7 -1
  27. package/dist/tools/quarantine.js.map +1 -1
  28. package/dist/tools/shared.d.ts +10 -0
  29. package/dist/tools/shared.js +15 -0
  30. package/dist/tools/shared.js.map +1 -1
  31. package/dist/tools/structured.d.ts +2 -0
  32. package/dist/tools/structured.js +4 -2
  33. package/dist/tools/structured.js.map +1 -1
  34. package/dist/tools/suggest-fix.js +6 -1
  35. package/dist/tools/suggest-fix.js.map +1 -1
  36. package/dist/types/index.d.ts +9 -0
  37. package/dist/utils/client-metadata.d.ts +46 -0
  38. package/dist/utils/client-metadata.js +174 -0
  39. package/dist/utils/client-metadata.js.map +1 -0
  40. package/dist/utils/confirm.d.ts +68 -0
  41. package/dist/utils/confirm.js +120 -0
  42. package/dist/utils/confirm.js.map +1 -0
  43. package/dist/utils/history-client.d.ts +32 -0
  44. package/dist/utils/{pro-client.js → history-client.js} +27 -20
  45. package/dist/utils/history-client.js.map +1 -0
  46. package/examples/github-actions/playwright-e2e.yml +5 -4
  47. package/package.json +1 -1
  48. package/skill/playwright-e2e-mcp/SKILL.md +8 -3
  49. package/skill/playwright-e2e-mcp/references/setup.md +2 -2
  50. package/dist/utils/pro-client.d.ts +0 -29
  51. package/dist/utils/pro-client.js.map +0 -1
package/README.md CHANGED
@@ -67,7 +67,7 @@ red at HEAD ─▶ bisect-failure (runs it across git history, names the breakin
67
67
  - **Lean.** With the `core` tool set, finding and fixing a broken locator took about **5,500 tokens**
68
68
  versus about **7,900** with Playwright MCP plus a shell. All 19 tools add about 16k tokens of
69
69
  definitions to every request, so load only the [profiles](#configuration) you use ([benchmark](#benchmark)).
70
- - **Free and open source.** MIT, no account, no API key, no telemetry. Every tool is free; an optional paid Pro only adds hosted CI history.
70
+ - **Free and open source.** MIT, no account, no API key, no telemetry. Every tool and feature is free, including the shared team history server you can host yourself.
71
71
 
72
72
  ## 🚀 What only this MCP does
73
73
 
@@ -273,8 +273,8 @@ Running and fixing your own tests needs your project, so use the local install f
273
273
  | --- | --- |
274
274
  | Skill only, any agent | `npx skills add xsignon-secure/E2E` |
275
275
  | MCP Registry | `io.github.xsignon-secure/E2E` ([listing](https://registry.modelcontextprotocol.io/)); registry-aware clients find it there |
276
- | Straight from GitHub, no npm | `npx -y github:xsignon-secure/E2E#v0.8.0` (pin a release tag) |
277
- | Release tarball | `npm install -D https://github.com/xsignon-secure/E2E/releases/download/v0.8.0/playwright-e2e-mcp-0.8.0.tgz` |
276
+ | Straight from GitHub, no npm | `npx -y github:xsignon-secure/E2E#v0.10.0` (pin a release tag) |
277
+ | Release tarball | `npm install -D https://github.com/xsignon-secure/E2E/releases/download/v0.10.0/playwright-e2e-mcp-0.10.0.tgz` |
278
278
  | Docker | `docker run -i --rm -v "$PWD:/work" ghcr.io/xsignon-secure/playwright-e2e-mcp` (published on each release; see [Development](docs/development.md#docker)) |
279
279
  | Local checkout | `"command": "node", "args": ["/path/to/E2E/dist/index.js"]` |
280
280
 
@@ -338,6 +338,27 @@ in the project, else `python3` / `python`.
338
338
  pip install pytest-playwright && playwright install chromium
339
339
  ```
340
340
 
341
+ ## What's new in 0.10
342
+
343
+ - **Asks before changing your files.** Applying a fix, writing a new test, quarantining a test or
344
+ replacing a visual baseline now shows you the exact change and waits for your yes, in clients
345
+ that support MCP questions (elicitation). No writes nothing. `PW_MCP_CONFIRM_WRITES` sets the
346
+ policy (`auto`, `always`, `off`).
347
+ - **Interactive test report** in Claude, ChatGPT, Cursor and other MCP Apps clients: counts,
348
+ a filterable list of failures, expected vs. received, and one-click re-run or "Ask to fix".
349
+ Other clients show the text result as before. `PW_MCP_APPS=0` turns it off.
350
+ - **Newer sign-in for the hosted endpoint:** OAuth clients can identify with a Client ID
351
+ Metadata Document (the MCP default since 2025-11-25); dynamic registration keeps working.
352
+
353
+ ## What's new in 0.9
354
+
355
+ - **Everything is free, including team history.** The CI history service that used to be a
356
+ paid add-on is now in this repo as a [self-hosted history server](history-server/README.md)
357
+ (MIT): one `docker run`, plain Node, or a free Vercel or Azure account. You hand out the
358
+ tokens; there is no account or billing anywhere.
359
+ - **New names:** `PW_MCP_HISTORY_URL` / `PW_MCP_HISTORY_TOKEN` and the Action's `history-url` /
360
+ `history-token`. The old `PW_MCP_PRO_*`, `pro-url` and `pro-key` names keep working.
361
+
341
362
  ## What's new in 0.8
342
363
 
343
364
  - **Python suites:** projects that use `pytest-playwright` work with `run-test`, `get-failure`
@@ -472,19 +493,25 @@ steps:
472
493
  - uses: actions/setup-node@v6
473
494
  with: { node-version: 24 }
474
495
  - run: npm ci && npx playwright install --with-deps chromium
475
- - uses: xsignon-secure/E2E@v0.8.0
496
+ - uses: xsignon-secure/E2E@v0.10.0
476
497
  with:
477
498
  flaky-runs: 3 # 0 skips the flaky verdict
478
499
  suggest: 2 # suggest-fix diffs for the first 2 failures
479
500
  # args: --browser chromium tests/checkout
480
501
  ```
481
502
 
482
- A complete, ready-to-copy workflow is available in [`examples/github-actions/playwright-e2e.yml`](examples/github-actions/playwright-e2e.yml), including artifact upload and commented options for `guard: true` and Pro Cloud.
503
+ A complete, ready-to-copy workflow is available in [`examples/github-actions/playwright-e2e.yml`](examples/github-actions/playwright-e2e.yml), including artifact upload and commented options for `guard: true` and a shared history server.
483
504
 
484
505
  **GitLab CI:** [`examples/gitlab-ci/playwright-e2e.yml`](examples/gitlab-ci/playwright-e2e.yml) runs the heal guard on merge requests, the same `ci` diagnosis, and posts the report as a merge request comment when you give it a project access token. Any other CI (Jenkins, CircleCI, AWS CodeBuild) works the same way: run `npx -y playwright-e2e-mcp ci --output e2e-report.md` and keep the file.
485
506
 
507
+ **Team history across machines (free, self-hosted):** CI runs normally stay on the CI machine.
508
+ Run the [history server](history-server/README.md) from this repo (one `docker run`, or a free
509
+ Vercel, Azure or any Node host), give the Action `history-url` and `history-token`, and every
510
+ CI run lands in one history that `analyze-history` on your laptop and the server's dashboard
511
+ both read. No account, no billing; you hand out the tokens.
512
+
486
513
  Inputs: `project-root`, `args`, `flaky-runs`, `suggest`, `comment`, `review`,
487
- `verify-fixes`, `guard`, `pro-key`, `pro-url`, `fail-on-failure`, `version`, `github-token`. Outputs: `exit-code`,
514
+ `verify-fixes`, `guard`, `history-token`, `history-url`, `fail-on-failure`, `version`, `github-token`. Outputs: `exit-code`,
488
515
  `report`, `guard`.
489
516
 
490
517
  Quarantined tests (`playwright-quarantine.json`, see [`quarantine`](docs/tools.md#quarantine))
@@ -510,19 +537,22 @@ npx -y playwright-e2e-mcp guard --base origin/main # exit 1 on BLOCK; --strict
510
537
  | `PW_MCP_OUTPUT_SCHEMAS` | on | `0` leaves the output schemas out of the tool list to save context. Results still carry `structuredContent` (see [Structured results](docs/tools.md#structured-results)) |
511
538
  | `PW_MCP_PROTOCOL` | both | `legacy` stops offering the stateless 2026-07-28 MCP revision, for a client that trips over it. By default 2026 clients get it and 2025 clients get the old handshake |
512
539
  | `PW_MCP_NO_UPDATE_CHECK` | off | `1` stops `doctor` from asking npm whether a newer version is out (same as `doctor --offline`) |
540
+ | `PW_MCP_CONFIRM_WRITES` | `auto` | Ask before a tool changes your files (`suggest-fix` apply, `audit-tests` fix, a new test from `generate-e2e-test`, quarantine add/remove, replacing a visual baseline). `auto` asks when the client can show a question (MCP elicitation) and writes as before when it can't; `always` refuses to write when the client can't ask; `off` never asks. The question shows the exact diff, and a yes only covers that diff |
541
+ | `PW_MCP_APPS` | `1` | Show an interactive test report next to `run-test`, `get-run-status` and `get-failure` results in clients that support MCP Apps (Claude, ChatGPT, Cursor). The report filters failures, shows expected vs. received, and re-runs or asks to fix a test with one click. `0` turns it off; other clients show the text result as before |
513
542
  | `PW_MCP_SKILLS` | on | `0` stops serving the bundled agent skill over the MCP Skills extension (`skill://playwright-e2e-mcp/SKILL.md`) |
514
543
  | `PW_MCP_STORAGE_STATE` | — | Default `storageState` file (inside the project) for the page tools |
515
544
  | `PW_MCP_PYTHON` | project venv, then `python3` | The Python that runs a pytest-playwright suite |
516
545
  | `PW_MCP_BLOCK_WRITES` | off | `1` makes the page tools abort POST, PUT, PATCH and DELETE requests that no mock answers (same as `blockWrites: true`), so steps run against a real environment cannot change its data |
517
546
  | `PW_MCP_SHUFFLE_SEEDS` | random | Comma-separated seeds for `diagnose-flaky`'s `order` pass, to replay the orders a diagnosis reported |
518
547
  | `PW_MCP_LOGIN_ENV` | — | Extra environment variables `save-login` may type through `{{env:NAME}}`, comma separated. Without it only `E2E_*`, `TEST_*`, `PW_*`, `PLAYWRIGHT_*` and `LOGIN_*` are allowed |
519
- | `PW_MCP_PRO_KEY` / `PW_MCP_PRO_URL` | — | Optional, paid: Pro key and Pro Cloud address. CI runs are uploaded with `playwright-e2e-mcp upload` (or the Action's `pro-key` / `pro-url` inputs) and `analyze-history` merges them with local runs. Unset (the default), nothing is ever sent anywhere |
548
+ | `PW_MCP_HISTORY_URL` / `PW_MCP_HISTORY_TOKEN` | — | Optional: your team's [history server](history-server/README.md) (free, self-hosted) and a token from it. CI runs are uploaded with `playwright-e2e-mcp upload` (or the Action's `history-url` / `history-token` inputs) and `analyze-history` merges them with local runs. Unset (the default), nothing is ever sent anywhere. The 0.7/0.8 names `PW_MCP_PRO_KEY` / `PW_MCP_PRO_URL` still work |
520
549
  | `PW_MCP_HISTORY` | on | `0` stops recording runs to `.playwright-e2e-mcp/history.jsonl` |
521
550
  | `PW_MCP_ALLOWED_ROOTS` | — | Extra directories a caller may pass as `projectRoot` (`:`-separated, `;` on Windows). Anything outside these and the default root is rejected |
522
551
  | `PW_MCP_HTTP_TOKEN` | — | HTTP bridge only: bearer token that unlocks all tools: 16+ characters, 32+ recommended (see [Hosted endpoint](docs/hosted-endpoint.md)) |
523
552
  | `PW_MCP_ALLOWED_HOSTS` | localhost + Vercel hostnames when there is no token | HTTP bridge only: comma-separated `Host` allowlist; `*` accepts any |
524
553
  | `PW_MCP_MAX_FAILED_AUTH` | `20` | HTTP bridge only: wrong tokens or access keys one client address may send in 15 minutes before it gets `429` |
525
554
  | `PW_MCP_OAUTH_REDIRECT_HOSTS` | any https host | HTTP bridge only: comma-separated hosts OAuth apps may return to, e.g. `claude.ai,chatgpt.com`; `.example.com` covers subdomains; localhost always works |
555
+ | `PW_MCP_OAUTH_CIMD` | `1` | HTTP bridge only: accept OAuth clients that identify with a Client ID Metadata Document (an https `client_id`, the MCP default since 2025-11-25); `0` accepts only dynamically registered clients |
526
556
  | `PW_MCP_TRUST_PROXY` | off (on for Vercel) | HTTP bridge only: `1` trusts `X-Real-IP` / `X-Forwarded-For` from a reverse proxy in front, so the sign-in limit counts per client, not per proxy |
527
557
  | `PW_MCP_PASSTHROUGH_ENV` | — | HTTP bridge only: comma-separated extra variables passed to test runs (e.g. `BASE_URL`) |
528
558
  | `PW_MCP_MAX_CHILDREN` | `4` | HTTP bridge only: how many test runs and browser probes may run at once |
@@ -574,9 +604,9 @@ A real `run-test` call served over stdio by `npx -y playwright-e2e-mcp` against
574
604
 
575
605
  ## Free and open source
576
606
 
577
- - **MIT licensed.** Use it, change it, ship it in commercial work; no fees, no feature held back. The optional Pro plan adds a hosted service (CI run history across machines), not tools; nothing here needs it.
607
+ - **MIT licensed.** Use it, change it, ship it in commercial work; no fees, no paid tier, no feature held back. That includes the [team history server](history-server/README.md) that collects CI runs from every machine: it is in this repo, and you host it yourself for free.
578
608
  - **No account, no API key, no sign-up.** `npx -y playwright-e2e-mcp init` is the whole setup.
579
- - **No telemetry.** It never phones home; your tests, traces and run history stay on your machine unless you set a Pro key and upload them. The only other request is `doctor` asking npm for the latest version number when you run it (`--offline` or `PW_MCP_NO_UPDATE_CHECK=1` skips it).
609
+ - **No telemetry.** It never phones home; your tests, traces and run history stay on your machine unless you point it at your own history server and upload them. The only other request is `doctor` asking npm for the latest version number when you run it (`--offline` or `PW_MCP_NO_UPDATE_CHECK=1` skips it).
580
610
  - **Free dependencies only:** the MCP SDK (Apache-2.0), zod (MIT), axe-core (MPL-2.0) and the Playwright your project already has (Apache-2.0).
581
611
  - **Free to host yourself**, too: the HTTP bridge deploys to a free Vercel account (see [Hosted endpoint](docs/hosted-endpoint.md)).
582
612
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MCP Apps (SEP-1865, extension `io.modelcontextprotocol/ui`): an
3
+ * interactive test report that Claude, ChatGPT, Cursor and other MCP Apps
4
+ * hosts render next to the run-test, get-run-status and get-failure results.
5
+ *
6
+ * The tools say which view to show in their `_meta.ui.resourceUri`; the
7
+ * host reads the `ui://` resource, renders it in a sandboxed iframe and
8
+ * hands it the tool result. Hosts that do not support MCP Apps ignore the
9
+ * metadata and show the text result exactly as before, so nothing changes
10
+ * for them.
11
+ *
12
+ * The view makes no network requests (it declares no CSP domains) and only
13
+ * calls this server's own tools through the host.
14
+ *
15
+ * Set PW_MCP_APPS=0 to turn it off.
16
+ */
17
+ import type { McpServer } from '@modelcontextprotocol/server';
18
+ /** The MCP Apps resource MIME type (`text/html;profile=mcp-app`). */
19
+ export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
20
+ /** Older hosts read the view from this flat key (`ui/resourceUri`). */
21
+ export declare const LEGACY_RESOURCE_URI_KEY = "ui/resourceUri";
22
+ export declare const TEST_REPORT_URI = "ui://playwright-e2e-mcp/test-report.html";
23
+ /** Tools whose results the report view can show. */
24
+ export declare const REPORT_TOOLS: ReadonlySet<string>;
25
+ export declare function appsEnabled(): boolean;
26
+ /** The `_meta` a tool declares so MCP Apps hosts show the report, or undefined. */
27
+ export declare function appMetaFor(toolName: string): Record<string, unknown> | undefined;
28
+ /** Register the report view resource. A no-op when PW_MCP_APPS=0. */
29
+ export declare function registerApps(server: McpServer): void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * MCP Apps (SEP-1865, extension `io.modelcontextprotocol/ui`): an
3
+ * interactive test report that Claude, ChatGPT, Cursor and other MCP Apps
4
+ * hosts render next to the run-test, get-run-status and get-failure results.
5
+ *
6
+ * The tools say which view to show in their `_meta.ui.resourceUri`; the
7
+ * host reads the `ui://` resource, renders it in a sandboxed iframe and
8
+ * hands it the tool result. Hosts that do not support MCP Apps ignore the
9
+ * metadata and show the text result exactly as before, so nothing changes
10
+ * for them.
11
+ *
12
+ * The view makes no network requests (it declares no CSP domains) and only
13
+ * calls this server's own tools through the host.
14
+ *
15
+ * Set PW_MCP_APPS=0 to turn it off.
16
+ */
17
+ import { TEST_REPORT_HTML } from './test-report-html.js';
18
+ /** The MCP Apps resource MIME type (`text/html;profile=mcp-app`). */
19
+ export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
20
+ /** Older hosts read the view from this flat key (`ui/resourceUri`). */
21
+ export const LEGACY_RESOURCE_URI_KEY = 'ui/resourceUri';
22
+ export const TEST_REPORT_URI = 'ui://playwright-e2e-mcp/test-report.html';
23
+ /** Tools whose results the report view can show. */
24
+ export const REPORT_TOOLS = new Set(['run-test', 'get-run-status', 'get-failure']);
25
+ export function appsEnabled() {
26
+ return process.env.PW_MCP_APPS !== '0';
27
+ }
28
+ /** The `_meta` a tool declares so MCP Apps hosts show the report, or undefined. */
29
+ export function appMetaFor(toolName) {
30
+ if (!appsEnabled() || !REPORT_TOOLS.has(toolName))
31
+ return undefined;
32
+ return { ui: { resourceUri: TEST_REPORT_URI }, [LEGACY_RESOURCE_URI_KEY]: TEST_REPORT_URI };
33
+ }
34
+ /** Rendering hints for the host: a border, and no outside origins at all. */
35
+ const RESOURCE_UI_META = {
36
+ prefersBorder: true,
37
+ csp: { connectDomains: [], resourceDomains: [] },
38
+ };
39
+ /** Register the report view resource. A no-op when PW_MCP_APPS=0. */
40
+ export function registerApps(server) {
41
+ if (!appsEnabled())
42
+ return;
43
+ server.registerResource('test-report', TEST_REPORT_URI, {
44
+ title: 'Interactive test report',
45
+ description: 'The view MCP Apps hosts show for run-test, get-run-status and get-failure results.',
46
+ mimeType: MCP_APP_MIME_TYPE,
47
+ _meta: { ui: RESOURCE_UI_META },
48
+ }, async () => ({
49
+ contents: [{ uri: TEST_REPORT_URI, mimeType: MCP_APP_MIME_TYPE, text: TEST_REPORT_HTML, _meta: { ui: RESOURCE_UI_META } }],
50
+ }));
51
+ }
52
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/apps/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAEzD,qEAAqE;AACrE,MAAM,CAAC,MAAM,iBAAiB,GAAG,2BAA2B,CAAC;AAE7D,uEAAuE;AACvE,MAAM,CAAC,MAAM,uBAAuB,GAAG,gBAAgB,CAAC;AAExD,MAAM,CAAC,MAAM,eAAe,GAAG,0CAA0C,CAAC;AAE1E,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,UAAU,EAAE,gBAAgB,EAAE,aAAa,CAAC,CAAC,CAAC;AAExG,MAAM,UAAU,WAAW;IACzB,OAAO,OAAO,CAAC,GAAG,CAAC,WAAW,KAAK,GAAG,CAAC;AACzC,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAC;IACpE,OAAO,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,EAAE,CAAC,uBAAuB,CAAC,EAAE,eAAe,EAAE,CAAC;AAC9F,CAAC;AAED,6EAA6E;AAC7E,MAAM,gBAAgB,GAAG;IACvB,aAAa,EAAE,IAAI;IACnB,GAAG,EAAE,EAAE,cAAc,EAAE,EAAE,EAAE,eAAe,EAAE,EAAE,EAAE;CACjD,CAAC;AAEF,qEAAqE;AACrE,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC5C,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO;IAC3B,MAAM,CAAC,gBAAgB,CACrB,aAAa,EACb,eAAe,EACf;QACE,KAAK,EAAE,yBAAyB;QAChC,WAAW,EAAE,oFAAoF;QACjG,QAAQ,EAAE,iBAAiB;QAC3B,KAAK,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE;KAChC,EACD,KAAK,IAAI,EAAE,CAAC,CAAC;QACX,QAAQ,EAAE,CAAC,EAAE,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,iBAAiB,EAAE,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE,EAAE,CAAC;KAC3H,CAAC,CACH,CAAC;AACJ,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The interactive test report view (MCP Apps, SEP-1865).
3
+ *
4
+ * One self-contained HTML page with no external requests: the host loads it
5
+ * in a sandboxed iframe and talks to it with JSON-RPC over postMessage. The
6
+ * page speaks the small part of the MCP Apps protocol it needs directly
7
+ * (ui/initialize, tool-input, tool-result, tools/call, ui/message,
8
+ * size-changed), so it ships no framework and no bundle.
9
+ *
10
+ * Everything from the tool result is written with textContent, never as
11
+ * HTML: test titles and error messages come from the project under test.
12
+ *
13
+ * Kept as a TypeScript string so `tsc` ships it in dist/ with the server.
14
+ */
15
+ export declare const TEST_REPORT_HTML: string;
@@ -0,0 +1,430 @@
1
+ /**
2
+ * The interactive test report view (MCP Apps, SEP-1865).
3
+ *
4
+ * One self-contained HTML page with no external requests: the host loads it
5
+ * in a sandboxed iframe and talks to it with JSON-RPC over postMessage. The
6
+ * page speaks the small part of the MCP Apps protocol it needs directly
7
+ * (ui/initialize, tool-input, tool-result, tools/call, ui/message,
8
+ * size-changed), so it ships no framework and no bundle.
9
+ *
10
+ * Everything from the tool result is written with textContent, never as
11
+ * HTML: test titles and error messages come from the project under test.
12
+ *
13
+ * Kept as a TypeScript string so `tsc` ships it in dist/ with the server.
14
+ */
15
+ export const TEST_REPORT_HTML = String.raw `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>Playwright test report</title>
21
+ <style>
22
+ :root {
23
+ --bg: var(--color-background-primary, #ffffff);
24
+ --bg2: var(--color-background-secondary, #f5f6f8);
25
+ --fg: var(--color-text-primary, #1d2125);
26
+ --fg2: var(--color-text-secondary, #5b636b);
27
+ --line: var(--color-border-primary, #dfe2e6);
28
+ --ok: var(--color-text-success, #1f7a3d);
29
+ --bad: var(--color-text-danger, #b42318);
30
+ --warn: var(--color-text-warning, #9a6700);
31
+ --okbg: var(--color-background-success, #e7f6ec);
32
+ --badbg: var(--color-background-danger, #fdecea);
33
+ --warnbg: var(--color-background-warning, #fff4d6);
34
+ --radius: var(--border-radius-md, 8px);
35
+ --sans: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
36
+ --mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
37
+ }
38
+ :root[data-theme="dark"] {
39
+ --bg: var(--color-background-primary, #17191c);
40
+ --bg2: var(--color-background-secondary, #212429);
41
+ --fg: var(--color-text-primary, #e8eaed);
42
+ --fg2: var(--color-text-secondary, #a2a9b1);
43
+ --line: var(--color-border-primary, #33373d);
44
+ --ok: var(--color-text-success, #6fd08c);
45
+ --bad: var(--color-text-danger, #ff8a80);
46
+ --warn: var(--color-text-warning, #f2c94c);
47
+ --okbg: var(--color-background-success, #17301f);
48
+ --badbg: var(--color-background-danger, #3a1d1b);
49
+ --warnbg: var(--color-background-warning, #3a3117);
50
+ }
51
+ * { box-sizing: border-box; }
52
+ html, body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 var(--sans); }
53
+ main { padding: 14px 16px 16px; }
54
+ h1 { font-size: 15px; margin: 0 0 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
55
+ .badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
56
+ .badge.ok { background: var(--okbg); color: var(--ok); }
57
+ .badge.bad { background: var(--badbg); color: var(--bad); }
58
+ .badge.warn { background: var(--warnbg); color: var(--warn); }
59
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: 8px; margin-bottom: 12px; }
60
+ .stat { background: var(--bg2); border-radius: var(--radius); padding: 8px 10px; }
61
+ .stat b { display: block; white-space: nowrap; font-size: 18px; font-variant-numeric: tabular-nums; }
62
+ .stat span { color: var(--fg2); font-size: 12px; }
63
+ .stat.failed b { color: var(--bad); }
64
+ .stat.passed b { color: var(--ok); }
65
+ .stat.flaky b { color: var(--warn); }
66
+ .bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: var(--bg2); margin-bottom: 12px; }
67
+ .bar i { display: block; }
68
+ .toolbar { display: flex; gap: 8px; margin-bottom: 8px; }
69
+ input[type=search] { flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); color: var(--fg); font: inherit; }
70
+ ul { list-style: none; margin: 0; padding: 0; }
71
+ li.failure { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 8px; overflow: hidden; }
72
+ li.failure > button.head { all: unset; cursor: pointer; display: block; width: 100%; padding: 8px 10px; box-sizing: border-box; }
73
+ li.failure > button.head:focus-visible { outline: 2px solid var(--color-ring-primary, #3b82f6); outline-offset: -2px; }
74
+ .title { font-weight: 600; word-break: break-word; }
75
+ .where { color: var(--fg2); font: 12px var(--mono); word-break: break-all; }
76
+ .kind { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--bad); margin-left: 6px; }
77
+ .body { padding: 0 10px 10px; display: none; }
78
+ li.open .body { display: block; }
79
+ pre { margin: 6px 0; padding: 8px; background: var(--bg2); border-radius: 6px; font: 12px/1.4 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; }
80
+ .cmp { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
81
+ .cmp div > span { font-size: 12px; color: var(--fg2); }
82
+ @media (max-width: 480px) { .cmp { grid-template-columns: 1fr; } }
83
+ .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
84
+ button.act { font: inherit; font-size: 13px; padding: 5px 10px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
85
+ button.act.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
86
+ button.act:disabled { opacity: .55; cursor: default; }
87
+ .muted { color: var(--fg2); }
88
+ .note { font-size: 12px; color: var(--fg2); margin-top: 10px; }
89
+ .empty { padding: 18px; text-align: center; color: var(--fg2); background: var(--bg2); border-radius: var(--radius); }
90
+ .spin { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--fg2); border-right-color: transparent; border-radius: 50%; animation: s 0.8s linear infinite; }
91
+ @keyframes s { to { transform: rotate(360deg); } }
92
+ @media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <main id="root"><p class="muted"><span class="spin"></span> Waiting for the test results…</p></main>
97
+ <script>
98
+ (function () {
99
+ "use strict";
100
+ var root = document.getElementById("root");
101
+ var nextId = 1;
102
+ var pending = {};
103
+ var lastInput = {};
104
+ var hostCaps = {};
105
+ var openai = window.openai;
106
+
107
+ // ---- JSON-RPC over postMessage (MCP Apps) ----
108
+ function post(msg) { window.parent.postMessage(msg, "*"); }
109
+ function request(method, params) {
110
+ var id = nextId++;
111
+ return new Promise(function (resolve, reject) {
112
+ pending[id] = { resolve: resolve, reject: reject };
113
+ post({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
114
+ });
115
+ }
116
+ function notify(method, params) { post({ jsonrpc: "2.0", method: method, params: params || {} }); }
117
+
118
+ window.addEventListener("message", function (event) {
119
+ if (event.source !== window.parent) return;
120
+ var msg = event.data;
121
+ if (!msg || msg.jsonrpc !== "2.0") return;
122
+ if (msg.id !== undefined && !msg.method) {
123
+ var p = pending[msg.id];
124
+ if (!p) return;
125
+ delete pending[msg.id];
126
+ if (msg.error) p.reject(new Error(msg.error.message || "request failed"));
127
+ else p.resolve(msg.result);
128
+ return;
129
+ }
130
+ if (msg.method === "ui/notifications/tool-input") { lastInput = (msg.params && msg.params.arguments) || {}; return; }
131
+ if (msg.method === "ui/notifications/tool-result") { render(msg.params || {}); return; }
132
+ if (msg.method === "ui/notifications/tool-cancelled") { message("The tool call was cancelled."); return; }
133
+ if (msg.method === "ui/notifications/host-context-changed") { applyContext(msg.params || {}); return; }
134
+ if (msg.method === "ui/resource-teardown") { post({ jsonrpc: "2.0", id: msg.id, result: {} }); return; }
135
+ if (msg.method === "ping" && msg.id !== undefined) { post({ jsonrpc: "2.0", id: msg.id, result: {} }); }
136
+ });
137
+
138
+ function applyContext(ctx) {
139
+ if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.setAttribute("data-theme", ctx.theme);
140
+ var vars = ctx.styles && ctx.styles.variables;
141
+ if (vars) Object.keys(vars).forEach(function (k) { if (vars[k]) document.documentElement.style.setProperty(k, vars[k]); });
142
+ }
143
+
144
+ function callTool(name, args) {
145
+ if (openai && typeof openai.callTool === "function") return openai.callTool(name, args);
146
+ return request("tools/call", { name: name, arguments: args || {} });
147
+ }
148
+ function canCallTools() { return !!(openai && openai.callTool) || !!hostCaps.serverTools; }
149
+ function sendMessage(text) {
150
+ if (openai && typeof openai.sendFollowUpMessage === "function") return openai.sendFollowUpMessage({ prompt: text });
151
+ return request("ui/message", { role: "user", content: [{ type: "text", text: text }] });
152
+ }
153
+ function canMessage() { return !!(openai && openai.sendFollowUpMessage) || !!hostCaps.message; }
154
+
155
+ // ---- DOM helpers (textContent only) ----
156
+ function el(tag, cls, text) {
157
+ var node = document.createElement(tag);
158
+ if (cls) node.className = cls;
159
+ if (text !== undefined && text !== null) node.textContent = String(text);
160
+ return node;
161
+ }
162
+ function clear() { while (root.firstChild) root.removeChild(root.firstChild); }
163
+ function message(text) { clear(); root.appendChild(el("p", "muted", text)); }
164
+ function where(f) { return f.file + (f.line ? ":" + f.line : ""); }
165
+ function duration(ms) {
166
+ if (typeof ms !== "number") return "";
167
+ if (ms < 1000) return Math.round(ms) + " ms";
168
+ var s = ms / 1000;
169
+ return s < 60 ? s.toFixed(1) + " s" : Math.floor(s / 60) + "m " + Math.round(s % 60) + "s";
170
+ }
171
+ function button(label, cls, onClick) {
172
+ var b = el("button", "act" + (cls ? " " + cls : ""), label);
173
+ b.type = "button";
174
+ b.addEventListener("click", function () {
175
+ if (b.disabled) return;
176
+ b.disabled = true;
177
+ var before = b.textContent;
178
+ b.textContent = "…";
179
+ Promise.resolve().then(onClick).catch(function (err) {
180
+ b.title = String(err && err.message || err);
181
+ }).then(function () { b.disabled = false; b.textContent = before; });
182
+ });
183
+ return b;
184
+ }
185
+
186
+ // ---- views ----
187
+ function render(result) {
188
+ var data = result.structuredContent;
189
+ if (!data && result.content) {
190
+ // Hosts that drop structuredContent: show the Markdown text.
191
+ clear();
192
+ var text = result.content.filter(function (c) { return c.type === "text"; }).map(function (c) { return c.text; }).join("\n\n");
193
+ root.appendChild(el("pre", null, text || "No result."));
194
+ return;
195
+ }
196
+ data = data || {};
197
+ if (data.error) return renderError(data.error);
198
+ if (data.run) return renderRun(data);
199
+ if (data.failure) return renderSingleFailure(data);
200
+ if (data.status === "running" && data.runId) return renderRunning(data);
201
+ message(data.ok ? "Done." : "The tool reported a problem; see the text reply.");
202
+ }
203
+
204
+ function renderError(error) {
205
+ clear();
206
+ var h = el("h1", null, "Could not run the tests");
207
+ h.appendChild(el("span", "badge bad", error.kind || "ERROR"));
208
+ root.appendChild(h);
209
+ root.appendChild(el("pre", null, error.message || ""));
210
+ if (error.hint) root.appendChild(el("p", "muted", error.hint));
211
+ }
212
+
213
+ function renderRunning(data) {
214
+ clear();
215
+ var h = el("h1", null, "Tests are running");
216
+ h.appendChild(el("span", "badge warn", data.runId));
217
+ root.appendChild(h);
218
+ var p = el("p", "muted");
219
+ p.appendChild(el("span", "spin"));
220
+ p.appendChild(document.createTextNode(" This report updates when the run finishes."));
221
+ root.appendChild(p);
222
+ if (!canCallTools()) return;
223
+ var poll = function () {
224
+ callTool("get-run-status", { runId: data.runId, waitSeconds: 20 }).then(function (res) {
225
+ var sc = (res && res.structuredContent) || {};
226
+ if (sc.status === "running" && !sc.run) setTimeout(poll, 500);
227
+ else render(res);
228
+ }).catch(function () { setTimeout(poll, 5000); });
229
+ };
230
+ setTimeout(poll, 500);
231
+ }
232
+
233
+ function renderRun(data) {
234
+ var run = data.run;
235
+ clear();
236
+ var total = run.passed + run.failed + run.flaky + run.skipped;
237
+ var h = el("h1", null, "Playwright test report");
238
+ var verdict = run.failed > 0 ? ["bad", run.failed + " failed"] : run.flaky > 0 ? ["warn", "flaky"] : total === 0 ? ["warn", "no tests ran"] : ["ok", "all passed"];
239
+ h.appendChild(el("span", "badge " + verdict[0], verdict[1]));
240
+ if (run.partial) h.appendChild(el("span", "badge warn", "stopped early"));
241
+ if (data.status === "cancelled") h.appendChild(el("span", "badge warn", "cancelled"));
242
+ root.appendChild(h);
243
+
244
+ var stats = el("div", "stats");
245
+ [["passed", run.passed], ["failed", run.failed], ["flaky", run.flaky], ["skipped", run.skipped]].forEach(function (s) {
246
+ var box = el("div", "stat " + s[0]);
247
+ box.appendChild(el("b", null, s[1]));
248
+ box.appendChild(el("span", null, s[0]));
249
+ stats.appendChild(box);
250
+ });
251
+ var time = el("div", "stat");
252
+ time.appendChild(el("b", null, duration(run.durationMs)));
253
+ time.appendChild(el("span", null, "duration"));
254
+ stats.appendChild(time);
255
+ root.appendChild(stats);
256
+
257
+ if (total > 0) {
258
+ var bar = el("div", "bar");
259
+ bar.setAttribute("role", "img");
260
+ bar.setAttribute("aria-label", run.passed + " passed, " + run.failed + " failed, " + run.flaky + " flaky, " + run.skipped + " skipped");
261
+ [["passed", "var(--ok)"], ["failed", "var(--bad)"], ["flaky", "var(--warn)"], ["skipped", "var(--line)"]].forEach(function (s) {
262
+ if (!run[s[0]]) return;
263
+ var seg = el("i");
264
+ seg.style.width = (100 * run[s[0]] / total) + "%";
265
+ seg.style.background = s[1];
266
+ bar.appendChild(seg);
267
+ });
268
+ root.appendChild(bar);
269
+ }
270
+
271
+ if (!run.failures.length) {
272
+ root.appendChild(el("div", "empty", run.failed > 0 ? "Failures were not listed. Ask for get-failure." : "No failures."));
273
+ } else {
274
+ var toolbar = el("div", "toolbar");
275
+ var search = el("input");
276
+ search.type = "search";
277
+ search.placeholder = "Filter failures by title, file or error";
278
+ search.setAttribute("aria-label", "Filter failures");
279
+ toolbar.appendChild(search);
280
+ if (run.failures.length > 1 && canMessage()) {
281
+ toolbar.appendChild(button("Fix all", "primary", function () {
282
+ return sendMessage("Please fix the " + run.failures.length + " failing Playwright tests from the last run. Start with get-failure for each one, then suggest-fix.");
283
+ }));
284
+ }
285
+ root.appendChild(toolbar);
286
+ var list = el("ul");
287
+ run.failures.forEach(function (f, i) { list.appendChild(failureItem(f, i + 1, run.failures.length === 1)); });
288
+ root.appendChild(list);
289
+ search.addEventListener("input", function () {
290
+ var q = search.value.trim().toLowerCase();
291
+ Array.prototype.forEach.call(list.children, function (li) {
292
+ li.style.display = !q || li.getAttribute("data-text").indexOf(q) >= 0 ? "" : "none";
293
+ });
294
+ });
295
+ if (run.failuresTruncated) root.appendChild(el("p", "note", "Only the first failures are listed; the text reply has the rest."));
296
+ }
297
+ if (run.quarantined) root.appendChild(el("p", "note", run.quarantined + " failing test(s) are quarantined and do not fail the run."));
298
+ var rerun = rerunArgs();
299
+ if (rerun && canCallTools()) {
300
+ var actions = el("div", "actions");
301
+ actions.appendChild(button("Run again", null, function () {
302
+ message("Running the tests again…");
303
+ return callTool("run-test", rerun).then(render);
304
+ }));
305
+ root.appendChild(actions);
306
+ }
307
+ }
308
+
309
+ function rerunArgs() {
310
+ var keep = {};
311
+ ["testFiles", "grep", "browser", "config", "projectRoot", "retries", "workers", "lastFailed", "timeoutMs", "testTimeoutMs"].forEach(function (k) {
312
+ if (lastInput[k] !== undefined) keep[k] = lastInput[k];
313
+ });
314
+ return keep;
315
+ }
316
+
317
+ function failureItem(f, index, open) {
318
+ var li = el("li", "failure" + (open ? " open" : ""));
319
+ li.setAttribute("data-text", (f.title + " " + f.file + " " + f.message).toLowerCase());
320
+ var head = el("button", "head");
321
+ head.type = "button";
322
+ head.setAttribute("aria-expanded", open ? "true" : "false");
323
+ var t = el("div", "title", f.title);
324
+ t.appendChild(el("span", "kind", f.kind));
325
+ head.appendChild(t);
326
+ head.appendChild(el("div", "where", where(f) + (f.project ? " · " + f.project : "")));
327
+ li.appendChild(head);
328
+ var body = el("div", "body");
329
+ body.appendChild(el("pre", null, f.message));
330
+ var details = el("div");
331
+ body.appendChild(details);
332
+ var actions = el("div", "actions");
333
+ if (canCallTools()) {
334
+ actions.appendChild(button("Show details", null, function () {
335
+ return callTool("get-failure", { index: index }).then(function (res) {
336
+ var sc = (res && res.structuredContent) || {};
337
+ while (details.firstChild) details.removeChild(details.firstChild);
338
+ fillDetails(details, sc.failure, res);
339
+ });
340
+ }));
341
+ actions.appendChild(button("Re-run this test", null, function () {
342
+ message("Running " + where(f) + "…");
343
+ return callTool("run-test", { testFiles: [where(f)] }).then(render);
344
+ }));
345
+ }
346
+ if (canMessage()) {
347
+ actions.appendChild(button("Ask to fix", "primary", function () {
348
+ return sendMessage("Fix the failing Playwright test \"" + f.title + "\" at " + where(f) + ". Call get-failure with index " + index + " first, then suggest-fix.");
349
+ }));
350
+ }
351
+ if (actions.firstChild) body.appendChild(actions);
352
+ li.appendChild(body);
353
+ head.addEventListener("click", function () {
354
+ var isOpen = li.classList.toggle("open");
355
+ head.setAttribute("aria-expanded", isOpen ? "true" : "false");
356
+ });
357
+ return li;
358
+ }
359
+
360
+ function fillDetails(target, failure, res) {
361
+ if (failure && (failure.expected || failure.actual)) {
362
+ var cmp = el("div", "cmp");
363
+ var a = el("div"); a.appendChild(el("span", null, "Expected")); a.appendChild(el("pre", null, failure.expected || ""));
364
+ var b = el("div"); b.appendChild(el("span", null, "Received")); b.appendChild(el("pre", null, failure.actual || ""));
365
+ cmp.appendChild(a); cmp.appendChild(b);
366
+ target.appendChild(cmp);
367
+ }
368
+ var text = ((res && res.content) || []).filter(function (c) { return c.type === "text"; }).map(function (c) { return c.text; }).join("\n\n");
369
+ if (text) target.appendChild(el("pre", null, text));
370
+ }
371
+
372
+ function renderSingleFailure(data) {
373
+ clear();
374
+ var f = data.failure;
375
+ var h = el("h1", null, "Failure " + (data.index || 1) + (data.total ? " of " + data.total : ""));
376
+ h.appendChild(el("span", "badge bad", f.kind));
377
+ root.appendChild(h);
378
+ root.appendChild(el("div", "title", f.title));
379
+ root.appendChild(el("div", "where", where(f)));
380
+ var box = el("div");
381
+ box.appendChild(el("pre", null, f.message));
382
+ fillDetails(box, { expected: f.expected, actual: f.actual }, null);
383
+ root.appendChild(box);
384
+ var actions = el("div", "actions");
385
+ if (canCallTools() && data.total && data.total > 1) {
386
+ var i = data.index || 1;
387
+ if (i > 1) actions.appendChild(button("Previous", null, function () { return callTool("get-failure", { index: i - 1 }).then(render); }));
388
+ if (i < data.total) actions.appendChild(button("Next", null, function () { return callTool("get-failure", { index: i + 1 }).then(render); }));
389
+ }
390
+ if (canMessage()) {
391
+ actions.appendChild(button("Ask to fix", "primary", function () {
392
+ return sendMessage("Fix the failing Playwright test \"" + f.title + "\" at " + where(f) + " with suggest-fix.");
393
+ }));
394
+ }
395
+ if (actions.firstChild) root.appendChild(actions);
396
+ }
397
+
398
+ // ---- size reporting ----
399
+ function reportSize() {
400
+ var h = Math.ceil(document.documentElement.getBoundingClientRect().height);
401
+ notify("ui/notifications/size-changed", { width: Math.ceil(window.innerWidth), height: h });
402
+ }
403
+
404
+ // ---- start ----
405
+ if (openai) {
406
+ // ChatGPT's window.openai bridge: the result is already there.
407
+ if (openai.toolInput) lastInput = openai.toolInput;
408
+ if (openai.toolOutput) render({ structuredContent: openai.toolOutput });
409
+ window.addEventListener("openai:set_globals", function () {
410
+ if (window.openai.toolOutput) render({ structuredContent: window.openai.toolOutput });
411
+ });
412
+ return;
413
+ }
414
+ request("ui/initialize", {
415
+ appInfo: { name: "playwright-e2e-mcp-report", version: "1.0.0" },
416
+ appCapabilities: {},
417
+ protocolVersion: "2026-01-26"
418
+ }).then(function (init) {
419
+ hostCaps = (init && init.hostCapabilities) || {};
420
+ applyContext((init && init.hostContext) || {});
421
+ notify("ui/notifications/initialized", {});
422
+ if (typeof ResizeObserver === "function") new ResizeObserver(reportSize).observe(document.body);
423
+ reportSize();
424
+ }).catch(function () { /* not inside an MCP Apps host */ });
425
+ })();
426
+ </script>
427
+ </body>
428
+ </html>
429
+ `;
430
+ //# sourceMappingURL=test-report-html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-report-html.js","sourceRoot":"","sources":["../../src/apps/test-report-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8ZzC,CAAC"}