From 0e64be6b26926583e91a33e2efae6632e0cf9c28 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Apr 2026 00:28:09 +0000
Subject: [PATCH 1/3] Initial plan
From cb409e357d7436c63653066c2433fde48d246e76 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Apr 2026 00:52:03 +0000
Subject: [PATCH 2/3] feat: add petstore SPA UI, Playwright tests, and fix
numeric path params
Agent-Logs-Url: https://github.com/counterfact/example-petstore/sessions/15794b61-698a-4ef6-8833-bd28f7a06353
Co-authored-by: pmcelhaney <51504+pmcelhaney@users.noreply.github.com>
---
.gitignore | 2 +
README.md | 46 +
api/routes/pet/{petId}.ts | 6 +-
api/routes/pet/{petId}/uploadImage.ts | 2 +-
api/routes/store/order/{orderId}.ts | 4 +-
package-lock.json | 896 +++++++++++++++-
package.json | 6 +-
playwright.config.ts | 39 +
test/petstore.ui.test.ts | 378 +++++++
ui/index.html | 1358 +++++++++++++++++++++++++
10 files changed, 2728 insertions(+), 9 deletions(-)
create mode 100644 playwright.config.ts
create mode 100644 test/petstore.ui.test.ts
create mode 100644 ui/index.html
diff --git a/.gitignore b/.gitignore
index 3c3629e..169a2af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
node_modules
+playwright-report
+test-results
diff --git a/README.md b/README.md
index 62e4be7..48a5419 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,46 @@ Once running, Counterfact provides:
- An interactive REPL for inspecting and manipulating server state at runtime
- A Swagger UI for exploring and testing the API
+### Pet Store UI
+
+A single-page app is included in the `ui/` directory. After starting the mock server, open `ui/index.html` in a browser, or serve it with any static file server:
+
+```bash
+npx serve ui
+```
+
+Then visit `http://localhost:3000` (or whatever port `serve` uses). The UI communicates with the Counterfact backend at `http://localhost:3100` by default. To point it at a different API server, pass the `api` query parameter:
+
+```
+http://localhost:3000/?api=http://localhost:3100
+```
+
+The UI covers all petstore APIs:
+
+- **Pets tab** – browse pets by status, search by tags, add, edit, and delete pets
+- **Store tab** – view live inventory, place orders, look up and delete orders
+- **Users tab** – list, add, edit, look up, and delete users
+
+## Testing
+
+### Unit tests
+
+```bash
+npm test
+```
+
+Runs the context unit tests with Node's built-in test runner.
+
+### UI tests (Playwright)
+
+```bash
+npm run test:ui
+```
+
+Starts the Counterfact API server on port 3101 and a static file server on port 8080, then runs Playwright browser tests against the full UI + backend stack.
+
+Playwright reports are written to `playwright-report/`. Test failure artefacts (screenshots, videos) go to `test-results/`.
+
## Project Structure
```
@@ -41,6 +81,12 @@ Once running, Counterfact provides:
│ └── types/ # Generated TypeScript types from the OpenAPI spec
├── spec/
│ └── petstore.yaml # OpenAPI specification for the Petstore API
+├── test/
+│ ├── context.test.ts # Unit tests for the Context class
+│ └── petstore.ui.test.ts # Playwright end-to-end UI tests
+├── ui/
+│ └── index.html # Single-page application
+├── playwright.config.ts # Playwright configuration
└── package.json
```
diff --git a/api/routes/pet/{petId}.ts b/api/routes/pet/{petId}.ts
index 1fc4187..4a73277 100644
--- a/api/routes/pet/{petId}.ts
+++ b/api/routes/pet/{petId}.ts
@@ -3,7 +3,7 @@ import type { updatePetWithForm } from "../../types/paths/pet/{petId}.types.js";
import type { deletePet } from "../../types/paths/pet/{petId}.types.js";
export const GET: getPetById = ($) => {
- const pet = $.context.getPetById($.path.petId);
+ const pet = $.context.getPetById(Number($.path.petId));
if (!pet) {
return $.response[404];
}
@@ -11,7 +11,7 @@ export const GET: getPetById = ($) => {
};
export const POST: updatePetWithForm = ($) => {
- const pet = $.context.getPetById($.path.petId);
+ const pet = $.context.getPetById(Number($.path.petId));
if (!pet) {
return $.response[400];
}
@@ -29,7 +29,7 @@ export const POST: updatePetWithForm = ($) => {
};
export const DELETE: deletePet = ($) => {
- const deleted = $.context.deletePet($.path.petId);
+ const deleted = $.context.deletePet(Number($.path.petId));
if (!deleted) {
return $.response[400];
}
diff --git a/api/routes/pet/{petId}/uploadImage.ts b/api/routes/pet/{petId}/uploadImage.ts
index fd5de1c..13c7a05 100644
--- a/api/routes/pet/{petId}/uploadImage.ts
+++ b/api/routes/pet/{petId}/uploadImage.ts
@@ -1,7 +1,7 @@
import type { uploadFile } from "../../../types/paths/pet/{petId}/uploadImage.types.js";
export const POST: uploadFile = ($) => {
- const pet = $.context.getPetById($.path.petId);
+ const pet = $.context.getPetById(Number($.path.petId));
if (!pet) {
return $.response[404];
}
diff --git a/api/routes/store/order/{orderId}.ts b/api/routes/store/order/{orderId}.ts
index 660f051..5c4b1d1 100644
--- a/api/routes/store/order/{orderId}.ts
+++ b/api/routes/store/order/{orderId}.ts
@@ -2,7 +2,7 @@ import type { getOrderById } from "../../../types/paths/store/order/{orderId}.ty
import type { deleteOrder } from "../../../types/paths/store/order/{orderId}.types.js";
export const GET: getOrderById = ($) => {
- const order = $.context.getOrderById($.path.orderId);
+ const order = $.context.getOrderById(Number($.path.orderId));
if (!order) {
return $.response[404];
}
@@ -10,7 +10,7 @@ export const GET: getOrderById = ($) => {
};
export const DELETE: deleteOrder = ($) => {
- const deleted = $.context.deleteOrder($.path.orderId);
+ const deleted = $.context.deleteOrder(Number($.path.orderId));
if (!deleted) {
return $.response[404];
}
diff --git a/package-lock.json b/package-lock.json
index 2d1d443..36eda9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,9 +13,11 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.59.1",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
+ "serve": "^14.2.6",
"typescript-eslint": "^8.58.0"
}
},
@@ -741,6 +743,22 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@posthog/core": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.25.0.tgz",
@@ -1229,6 +1247,13 @@
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"license": "BSD-2-Clause"
},
+ "node_modules/@zeit/schemas": {
+ "version": "2.36.0",
+ "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
+ "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1302,6 +1327,74 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "node_modules/ansi-align/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-align/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ansi-align/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-align/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -1317,6 +1410,34 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/arch": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
+ "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1353,6 +1474,42 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/boxen": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz",
+ "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-align": "^3.0.1",
+ "camelcase": "^7.0.0",
+ "chalk": "^5.0.1",
+ "cli-boxes": "^3.0.0",
+ "string-width": "^5.1.2",
+ "type-fest": "^2.13.0",
+ "widest-line": "^4.0.1",
+ "wrap-ansi": "^8.0.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -1454,6 +1611,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/camelcase": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
+ "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1470,6 +1640,22 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chalk-template": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
+ "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk-template?sponsor=1"
+ }
+ },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -1500,6 +1686,66 @@
"node": ">=8"
}
},
+ "node_modules/cli-boxes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+ "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clipboardy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz",
+ "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "arch": "^2.2.0",
+ "execa": "^5.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clipboardy/node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clipboardy/node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/co-body": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz",
@@ -1586,6 +1832,72 @@
"node": ">=20"
}
},
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -1718,6 +2030,16 @@
"integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==",
"license": "MIT"
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1927,6 +2249,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -1942,6 +2271,13 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -2338,6 +2674,30 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2366,6 +2726,23 @@
"node": ">=10.0"
}
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -2585,6 +2962,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
@@ -2811,6 +3201,16 @@
"node": ">=14"
}
},
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -2858,8 +3258,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
- "node_modules/is-arrayish": {
- "version": "0.2.1",
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
@@ -2904,6 +3311,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2956,6 +3373,32 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-port-reachable": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
+ "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
@@ -3395,6 +3838,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -3433,6 +3883,16 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -3590,6 +4050,19 @@
"semver": "bin/semver"
}
},
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3623,6 +4096,32 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/open": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
@@ -3855,6 +4354,13 @@
"node": ">=8"
}
},
+ "node_modules/path-is-inside": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+ "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)"
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3942,6 +4448,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -4163,6 +4716,16 @@
"integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==",
"license": "MIT"
},
+ "node_modules/range-parser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
+ "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -4178,6 +4741,22 @@
"node": ">= 0.8"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -4257,6 +4836,40 @@
"node": ">= 4"
}
},
+ "node_modules/registry-auth-token": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
+ "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rc": "^1.1.6",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/registry-url": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz",
+ "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rc": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -4371,6 +4984,166 @@
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT"
},
+ "node_modules/serve": {
+ "version": "14.2.6",
+ "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz",
+ "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@zeit/schemas": "2.36.0",
+ "ajv": "8.18.0",
+ "arg": "5.0.2",
+ "boxen": "7.0.0",
+ "chalk": "5.0.1",
+ "chalk-template": "0.4.0",
+ "clipboardy": "3.0.0",
+ "compression": "1.8.1",
+ "is-port-reachable": "4.0.0",
+ "serve-handler": "6.1.7",
+ "update-check": "1.5.4"
+ },
+ "bin": {
+ "serve": "build/main.js"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/serve-handler": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz",
+ "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.0.0",
+ "content-disposition": "0.5.2",
+ "mime-types": "2.1.18",
+ "minimatch": "3.1.5",
+ "path-is-inside": "1.0.2",
+ "path-to-regexp": "3.3.0",
+ "range-parser": "1.2.0"
+ }
+ },
+ "node_modules/serve-handler/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/serve-handler/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/serve-handler/node_modules/bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/serve-handler/node_modules/content-disposition": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
+ "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-handler/node_modules/mime-db": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
+ "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-handler/node_modules/mime-types": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
+ "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "~1.33.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-handler/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/serve-handler/node_modules/path-to-regexp": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
+ "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/serve/node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/serve/node_modules/chalk": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz",
+ "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/serve/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -4487,6 +5260,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
@@ -4555,6 +5335,60 @@
"node": ">= 0.8"
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -4820,6 +5654,17 @@
"node": ">= 0.8"
}
},
+ "node_modules/update-check": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz",
+ "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "registry-auth-token": "3.3.2",
+ "registry-url": "3.1.0"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4881,6 +5726,22 @@
"node": ">= 8"
}
},
+ "node_modules/widest-line": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+ "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4897,6 +5758,37 @@
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
diff --git a/package.json b/package.json
index 3133b56..e0dfece 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,10 @@
"main": "index.js",
"type": "module",
"scripts": {
- "test": "node --test test/**/*.test.ts",
+ "test": "node --test --import tsx/esm test/context.test.ts",
+ "test:ui": "playwright test",
"start": "counterfact --spec ./spec/petstore.yaml api",
+ "start:test": "counterfact --spec ./spec/petstore.yaml api --port 3101",
"lint": "eslint .",
"format": "prettier --write ."
},
@@ -17,9 +19,11 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.59.1",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
+ "serve": "^14.2.6",
"typescript-eslint": "^8.58.0"
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..2514d4e
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./test",
+ testMatch: "**/*.ui.test.ts",
+ timeout: 30_000,
+ retries: 1,
+ reporter: [["html", { open: "never" }], ["list"]],
+ use: {
+ baseURL: "http://localhost:8080",
+ headless: true,
+ screenshot: "only-on-failure",
+ video: "retain-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+ webServer: [
+ {
+ command: "npm run start:test",
+ url: "http://localhost:3101/store/inventory",
+ reuseExistingServer: !process.env.CI,
+ timeout: 20_000,
+ stdout: "ignore",
+ stderr: "pipe",
+ },
+ {
+ command: "npx serve ui --listen 8080 --no-clipboard",
+ url: "http://localhost:8080",
+ reuseExistingServer: !process.env.CI,
+ timeout: 10_000,
+ stdout: "ignore",
+ stderr: "pipe",
+ },
+ ],
+});
diff --git a/test/petstore.ui.test.ts b/test/petstore.ui.test.ts
new file mode 100644
index 0000000..5f8446b
--- /dev/null
+++ b/test/petstore.ui.test.ts
@@ -0,0 +1,378 @@
+import { test, expect, type Page } from "@playwright/test";
+
+const API_URL = "http://localhost:3101";
+const UI_BASE = `/?api=${encodeURIComponent(API_URL)}`;
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+async function navigateTo(page: Page, tab: "pets" | "store" | "users") {
+ await page.getByRole("button", { name: new RegExp(tab, "i") }).click();
+ await page.waitForLoadState("networkidle");
+}
+
+// ─── Pets ─────────────────────────────────────────────────────────────────────
+
+test.describe("Pets tab", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(UI_BASE);
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("displays the page title", async ({ page }) => {
+ await expect(page).toHaveTitle(/pet store/i);
+ });
+
+ test("shows the Pets tab as active by default", async ({ page }) => {
+ const petsBtn = page.getByRole("button", { name: /pets/i });
+ await expect(petsBtn).toHaveClass(/active/);
+ });
+
+ test("loads available pets on page load", async ({ page }) => {
+ // The page auto-loads available pets on init
+ const petGrid = page.locator("#pets-list .pet-card");
+ await expect(petGrid.first()).toBeVisible({ timeout: 10_000 });
+ const count = await petGrid.count();
+ expect(count).toBeGreaterThan(0);
+ });
+
+ test("filters pets by status — pending", async ({ page }) => {
+ await page.selectOption("#status-filter", "pending");
+ await page.getByRole("button", { name: /^search$/i }).first().click();
+ await page.waitForLoadState("networkidle");
+
+ const cards = page.locator("#pets-list .pet-card");
+ await expect(cards.first()).toBeVisible({ timeout: 10_000 });
+ // All visible cards should show the pending badge
+ const count = await cards.count();
+ for (let i = 0; i < count; i++) {
+ await expect(cards.nth(i).locator(".status-badge")).toContainText("pending", {
+ ignoreCase: true,
+ });
+ }
+ });
+
+ test("filters pets by status — sold", async ({ page }) => {
+ await page.selectOption("#status-filter", "sold");
+ await page.getByRole("button", { name: /^search$/i }).first().click();
+ await page.waitForLoadState("networkidle");
+
+ const cards = page.locator("#pets-list .pet-card");
+ await expect(cards.first()).toBeVisible({ timeout: 10_000 });
+ const count = await cards.count();
+ for (let i = 0; i < count; i++) {
+ await expect(cards.nth(i).locator(".status-badge")).toContainText("sold", {
+ ignoreCase: true,
+ });
+ }
+ });
+
+ test("searches pets by tag", async ({ page }) => {
+ await page.fill("#tag-search", "friendly");
+ await page.getByRole("button", { name: /^search$/i }).nth(1).click();
+ await page.waitForLoadState("networkidle");
+
+ const cards = page.locator("#pets-by-tags-list .pet-card");
+ await expect(cards.first()).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("shows empty message when no pets match tag", async ({ page }) => {
+ await page.fill("#tag-search", "zzz-no-such-tag");
+ await page.getByRole("button", { name: /^search$/i }).nth(1).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#pets-by-tags-list")).toContainText(
+ "No pets found",
+ { timeout: 10_000 },
+ );
+ });
+
+ test("adds a new pet and shows it in the list", async ({ page }) => {
+ const uniqueName = `TestPet-${Date.now()}`;
+
+ await page.fill("#pet-name", uniqueName);
+ await page.fill("#pet-category", "TestCategory");
+ await page.selectOption("#pet-status-add", "available");
+ await page.fill("#pet-tags", "test-tag");
+ await page.fill("#pet-photo-url", "https://example.com/test.jpg");
+
+ await page.getByRole("button", { name: /^add pet$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // success alert should appear
+ await expect(page.locator("#pets-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#pets-alert .alert-success")).toContainText(uniqueName);
+
+ // pet should appear in the grid
+ const card = page.locator("#pets-list .pet-card").filter({ hasText: uniqueName });
+ await expect(card).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("edits an existing pet", async ({ page }) => {
+ // First load available pets
+ await page.waitForSelector("#pets-list .pet-card", { timeout: 10_000 });
+
+ // Click Edit on the first pet card
+ await page.locator("#pets-list .pet-card").first().getByRole("button", { name: /edit/i }).click();
+
+ // Wait for modal to open
+ await expect(page.locator("#edit-pet-modal")).toHaveClass(/open/, {
+ timeout: 5_000,
+ });
+
+ // Change the name
+ const updatedName = `Updated-${Date.now()}`;
+ await page.fill("#edit-pet-name", updatedName);
+ await page.getByRole("button", { name: /^save$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#pets-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#pets-alert .alert-success")).toContainText(
+ "updated",
+ );
+ });
+
+ test("deletes a pet", async ({ page }) => {
+ // Add a pet specifically to delete
+ const uniqueName = `DeleteMe-${Date.now()}`;
+ await page.fill("#pet-name", uniqueName);
+ await page.selectOption("#pet-status-add", "available");
+ await page.getByRole("button", { name: /^add pet$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // Find the card
+ const card = page.locator("#pets-list .pet-card").filter({ hasText: uniqueName });
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ // Accept the confirm dialog and click Delete
+ page.once("dialog", (d) => d.accept());
+ await card.getByRole("button", { name: /delete/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#pets-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#pets-alert .alert-success")).toContainText("deleted");
+
+ // Verify the card is gone
+ await expect(card).toHaveCount(0, { timeout: 10_000 });
+ });
+});
+
+// ─── Store ────────────────────────────────────────────────────────────────────
+
+test.describe("Store tab", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(UI_BASE);
+ await navigateTo(page, "store");
+ });
+
+ test("displays inventory counts", async ({ page }) => {
+ const available = page.locator("#inv-available");
+ const pending = page.locator("#inv-pending");
+ const sold = page.locator("#inv-sold");
+
+ await expect(available).not.toHaveText("—", { timeout: 10_000 });
+ await expect(pending).not.toHaveText("—");
+ await expect(sold).not.toHaveText("—");
+ });
+
+ test("places a new order", async ({ page }) => {
+ await page.fill("#order-pet-id", "1");
+ await page.fill("#order-quantity", "2");
+
+ await page.getByRole("button", { name: /^place order$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#store-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#store-alert .alert-success")).toContainText(
+ "placed",
+ );
+
+ // Order appears in the table
+ const rows = page.locator("#orders-tbody tr");
+ await expect(rows.first()).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("deletes an order", async ({ page }) => {
+ // Place an order first
+ await page.fill("#order-pet-id", "1");
+ await page.fill("#order-quantity", "1");
+ await page.getByRole("button", { name: /^place order$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // Delete it
+ page.once("dialog", (d) => d.accept());
+ await page.locator("#orders-tbody tr").first().getByRole("button", { name: /delete/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#store-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#store-alert .alert-success")).toContainText("deleted");
+ });
+
+ test("looks up an order by ID", async ({ page }) => {
+ // Place an order to get a known ID
+ await page.fill("#order-pet-id", "2");
+ await page.fill("#order-quantity", "1");
+ await page.getByRole("button", { name: /^place order$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // Extract the order ID from the success alert
+ const alertText = await page.locator("#store-alert .alert-success").textContent();
+ const match = alertText?.match(/#(\d+)/);
+ const orderId = match?.[1];
+ expect(orderId).toBeTruthy();
+
+ // Look up by ID
+ await page.fill("#order-lookup-id", orderId!);
+ await page.getByRole("button", { name: /look up/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#order-lookup-result .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#order-lookup-result .alert-success")).toContainText(
+ `Order #${orderId}`,
+ );
+ });
+
+ test("shows error when looking up a non-existent order", async ({ page }) => {
+ await page.fill("#order-lookup-id", "999999");
+ await page.getByRole("button", { name: /look up/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#order-lookup-result .alert-error")).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("refreshes inventory after placing an order", async ({ page }) => {
+ const beforeText = await page.locator("#inv-available").textContent();
+ const before = parseInt(beforeText ?? "0");
+
+ await page.fill("#order-pet-id", "1");
+ await page.fill("#order-quantity", "1");
+ await page.getByRole("button", { name: /^place order$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ // Inventory should have reloaded (available may change depending on server logic)
+ // At minimum verify it's still a number
+ const afterText = await page.locator("#inv-available").textContent();
+ expect(Number.isNaN(parseInt(afterText ?? ""))).toBe(false);
+ // Just check before was a valid number too
+ expect(Number.isNaN(before)).toBe(false);
+ });
+});
+
+// ─── Users ────────────────────────────────────────────────────────────────────
+
+test.describe("Users tab", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(UI_BASE);
+ await navigateTo(page, "users");
+ });
+
+ test("shows pre-seeded users", async ({ page }) => {
+ const rows = page.locator("#users-tbody tr");
+ await expect(rows.first()).toBeVisible({ timeout: 10_000 });
+ const count = await rows.count();
+ expect(count).toBeGreaterThan(0);
+ });
+
+ test("adds a new user", async ({ page }) => {
+ const username = `testuser-${Date.now()}`;
+
+ await page.fill("#user-username", username);
+ await page.fill("#user-first", "Test");
+ await page.fill("#user-last", "User");
+ await page.fill("#user-email", `${username}@example.com`);
+ await page.fill("#user-phone", "555-0000");
+ await page.fill("#user-password", "password123");
+
+ await page.getByRole("button", { name: /^add user$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#users-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#users-alert .alert-success")).toContainText(username);
+
+ // User appears in the table
+ const row = page.locator("#users-tbody tr").filter({ hasText: username });
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("looks up a user by username", async ({ page }) => {
+ await page.fill("#user-lookup-username", "user1");
+ await page.getByRole("button", { name: /look up/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#user-lookup-result .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#user-lookup-result .alert-success")).toContainText("user1");
+ });
+
+ test("shows error when looking up a non-existent user", async ({ page }) => {
+ await page.fill("#user-lookup-username", "no-such-user");
+ await page.getByRole("button", { name: /look up/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#user-lookup-result .alert-error")).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("edits a user", async ({ page }) => {
+ await page.waitForSelector("#users-tbody tr", { timeout: 10_000 });
+
+ // Click Edit on the first user row
+ await page.locator("#users-tbody tr").first().getByRole("button", { name: /edit/i }).click();
+
+ // Modal should open
+ await expect(page.locator("#edit-user-modal")).toHaveClass(/open/, {
+ timeout: 5_000,
+ });
+
+ // Change first name
+ const newFirst = `Edited-${Date.now()}`;
+ await page.fill("#edit-user-first", newFirst);
+ await page.getByRole("button", { name: /^save$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#users-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#users-alert .alert-success")).toContainText("updated");
+ });
+
+ test("deletes a user", async ({ page }) => {
+ // Create a user to delete
+ const username = `deleteme-${Date.now()}`;
+ await page.fill("#user-username", username);
+ await page.fill("#user-password", "pw");
+ await page.getByRole("button", { name: /^add user$/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ const row = page.locator("#users-tbody tr").filter({ hasText: username });
+ await expect(row).toBeVisible({ timeout: 10_000 });
+
+ // Delete it
+ page.once("dialog", (d) => d.accept());
+ await row.getByRole("button", { name: /delete/i }).click();
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.locator("#users-alert .alert-success")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.locator("#users-alert .alert-success")).toContainText("deleted");
+ await expect(row).toHaveCount(0, { timeout: 10_000 });
+ });
+});
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..4e2b8d3
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,1358 @@
+
+
+
+
+
+ 🐾 Pet Store
+
+
+
+
+
+ 🐾 Pet Store
+
+
+
+
+
+
+
+
+
+
+
🔍 Find Pets by Status
+
+
+
+
+
+
+
🏷️ Find Pets by Tags
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Pet ID |
+ Qty |
+ Ship Date |
+ Status |
+ |
+
+
+
+
+
+
+
+
+
🔎 Look Up Order by ID
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Username |
+ Name |
+ Email |
+ Phone |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
🔎 Look Up User by Username
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 8c2edc6901aff40e15cf310ab8935998e868ba2f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Apr 2026 00:55:06 +0000
Subject: [PATCH 3/3] refactor: extract TEST_API_URL to shared constants, fix
statusBadge consistency
Agent-Logs-Url: https://github.com/counterfact/example-petstore/sessions/15794b61-698a-4ef6-8833-bd28f7a06353
Co-authored-by: pmcelhaney <51504+pmcelhaney@users.noreply.github.com>
---
package.json | 2 +-
playwright.config.ts | 5 +++--
test/constants.ts | 9 +++++++++
test/petstore.ui.test.ts | 4 ++--
ui/index.html | 8 +++++++-
5 files changed, 22 insertions(+), 6 deletions(-)
create mode 100644 test/constants.ts
diff --git a/package.json b/package.json
index e0dfece..3c82e09 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"test": "node --test --import tsx/esm test/context.test.ts",
"test:ui": "playwright test",
"start": "counterfact --spec ./spec/petstore.yaml api",
- "start:test": "counterfact --spec ./spec/petstore.yaml api --port 3101",
+ "start:test": "counterfact --spec ./spec/petstore.yaml api",
"lint": "eslint .",
"format": "prettier --write ."
},
diff --git a/playwright.config.ts b/playwright.config.ts
index 2514d4e..2c76387 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
+import { TEST_API_PORT, TEST_API_URL } from "./test/constants.js";
export default defineConfig({
testDir: "./test",
@@ -20,8 +21,8 @@ export default defineConfig({
],
webServer: [
{
- command: "npm run start:test",
- url: "http://localhost:3101/store/inventory",
+ command: `npm run start:test -- --port ${TEST_API_PORT}`,
+ url: `${TEST_API_URL}/store/inventory`,
reuseExistingServer: !process.env.CI,
timeout: 20_000,
stdout: "ignore",
diff --git a/test/constants.ts b/test/constants.ts
new file mode 100644
index 0000000..8b52b35
--- /dev/null
+++ b/test/constants.ts
@@ -0,0 +1,9 @@
+/**
+ * Shared test configuration constants.
+ *
+ * Import these in both playwright.config.ts and UI test files
+ * to ensure the API URL is defined in a single place.
+ */
+
+export const TEST_API_PORT = parseInt(process.env.TEST_API_PORT ?? "3101", 10);
+export const TEST_API_URL = `http://localhost:${TEST_API_PORT}`;
diff --git a/test/petstore.ui.test.ts b/test/petstore.ui.test.ts
index 5f8446b..4891705 100644
--- a/test/petstore.ui.test.ts
+++ b/test/petstore.ui.test.ts
@@ -1,7 +1,7 @@
import { test, expect, type Page } from "@playwright/test";
+import { TEST_API_URL } from "./constants.js";
-const API_URL = "http://localhost:3101";
-const UI_BASE = `/?api=${encodeURIComponent(API_URL)}`;
+const UI_BASE = `/?api=${encodeURIComponent(TEST_API_URL)}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
diff --git a/ui/index.html b/ui/index.html
index 4e2b8d3..0c68682 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -172,6 +172,11 @@
color: #c2185b;
}
+ .status-unknown {
+ background: #eeeeee;
+ color: #757575;
+ }
+
.pet-card .actions {
display: flex;
gap: 6px;
@@ -898,7 +903,8 @@ 🐾 Pet Store
}
function statusBadge(status) {
- return `${status || "unknown"}`;
+ const s = status || "unknown";
+ return `${s}`;
}
// ─── Navigation ──────────────────────────────────────────────────────────