diff --git a/.gitignore b/.gitignore index cbf1c3e..d051448 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ Carthage/Build # Node node_modules/ +# Playwright +tests/playwright-report/ +tests/test-results/ + # Coverage coverage-html/ diff --git a/Makefile b/Makefile index 2761f22..3bd73c1 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,7 @@ sim-install: xcrun simctl install "$$BOOTED" "$$PRODUCTS/$(SCHEME)UITests-Runner.app"; \ echo "Installed on simulator $$BOOTED" -# Build, run mocha tests with code coverage +# Build, run Playwright tests with code coverage test-coverage: @rm -rf $(BUILD_DIR)/coverage.xcresult @BOOTED=$$(xcrun simctl list devices booted -j | jq -r '[.devices[][] | select(.state=="Booted")] | first | .udid'); \ @@ -133,9 +133,9 @@ test-coverage: # Generate HTML coverage report (run after test-coverage) coverage-html: - @PROFDATA=$$(find $(BUILD_DIR)/local/Build/ProfileData -name "Coverage.profdata" 2>/dev/null | head -1); \ + @PROFDATA="$(BUILD_DIR)/local/coverage/Coverage.profdata"; \ BINARY="$(BUILD_DIR)/local/Build/Products/Debug-iphonesimulator/$(SCHEME)UITests-Runner.app/PlugIns/$(SCHEME)UITests.xctest/$(SCHEME)UITests"; \ - if [ -z "$$PROFDATA" ] || [ ! -f "$$BINARY" ]; then echo "error: Run 'make test-coverage' first"; exit 1; fi; \ + if [ ! -f "$$PROFDATA" ] || [ ! -f "$$BINARY" ]; then echo "error: Run 'make test-coverage' first"; exit 1; fi; \ rm -rf coverage-html; \ xcrun llvm-cov show "$$BINARY" -instr-profile "$$PROFDATA" -format=html -output-dir=coverage-html \ -ignore-filename-regex='build/local/SourcePackages|DerivedSources'; \ diff --git a/scripts/test-coverage.sh b/scripts/test-coverage.sh index 4074d89..ed76a05 100755 --- a/scripts/test-coverage.sh +++ b/scripts/test-coverage.sh @@ -1,5 +1,7 @@ #!/bin/sh -# Run mocha tests against a coverage-instrumented XCUITest server. +# Build an instrumented XCUITest runner, install it on the booted simulator, and +# run the Playwright tests against it. Playwright's globalSetup launches the runner +# (on port 12004); this script collects Swift code coverage from the run. # Usage: scripts/test-coverage.sh set -e @@ -8,64 +10,53 @@ PROJECT="$1" SCHEME="$2" BOOTED="$3" BUILD_DIR="$4" -RESULT_BUNDLE="${BUILD_DIR}/coverage.xcresult" +RUNNER_BUNDLE_ID="com.mobilenext.devicekit-iosUITests.xctrunner" +PRODUCTS="${BUILD_DIR}/local/Build/Products/Debug-iphonesimulator" +COVERAGE_DIR="${BUILD_DIR}/local/coverage" -# Clean previous result bundle -rm -rf "$RESULT_BUNDLE" - -# Start xcodebuild test with coverage in the background -echo "Starting XCUITest server with coverage enabled..." -xcodebuild test \ +# Build the runner with coverage instrumentation +echo "Building instrumented XCUITest runner..." +xcodebuild build-for-testing \ -project "$PROJECT" \ -scheme "$SCHEME" \ -configuration Debug \ -destination "id=$BOOTED" \ -derivedDataPath "$BUILD_DIR/local" \ -enableCodeCoverage YES \ - -resultBundlePath "$RESULT_BUNDLE" \ - > /dev/null 2>&1 & -XCODEBUILD_PID=$! + > /dev/null -# Wait for the server to be ready -echo "Waiting for server on localhost:12004..." -RETRIES=0 -MAX_RETRIES=120 -while [ $RETRIES -lt $MAX_RETRIES ]; do - if ! kill -0 $XCODEBUILD_PID 2>/dev/null; then - echo "error: xcodebuild process ($XCODEBUILD_PID) exited before server became ready" - exit 1 - fi - if curl -s http://localhost:12004/health > /dev/null 2>&1; then - echo "Server is ready" - break - fi - RETRIES=$((RETRIES + 1)) - sleep 1 -done +# Install host app + runner on the booted simulator +echo "Installing apps on simulator $BOOTED..." +xcrun simctl install "$BOOTED" "$PRODUCTS/${SCHEME}.app" +xcrun simctl install "$BOOTED" "$PRODUCTS/${SCHEME}UITests-Runner.app" -if [ $RETRIES -eq $MAX_RETRIES ]; then - echo "error: Server did not start within ${MAX_RETRIES}s" - kill $XCODEBUILD_PID 2>/dev/null || true - exit 1 -fi +# Prepare coverage output dir. iOS simulators run on the host filesystem, so the +# instrumented runner writes .profraw straight to this host path via LLVM_PROFILE_FILE +# (forwarded into the simulator process by simctl as SIMCTL_CHILD_LLVM_PROFILE_FILE). +rm -rf "$COVERAGE_DIR" +mkdir -p "$COVERAGE_DIR" +COVERAGE_DIR_ABS="$(cd "$COVERAGE_DIR" && pwd)" -# Run mocha tests -echo "Running mocha tests..." -cd tests && npm test -TEST_EXIT=$? -cd .. +# Run Playwright tests. globalSetup terminates any stale runner and launches a fresh +# one on port 12004; DEVICEKIT_SIMULATOR_UDID pins it to this simulator. +echo "Running Playwright tests..." +TEST_EXIT=0 +( cd tests && \ + SIMCTL_CHILD_LLVM_PROFILE_FILE="${COVERAGE_DIR_ABS}/%p.profraw" \ + DEVICEKIT_SIMULATOR_UDID="$BOOTED" \ + npm test ) || TEST_EXIT=$? -# Shutdown the server gracefully and let xcodebuild collect coverage +# Terminate the runner so the instrumented binary flushes its .profraw on exit echo "Stopping XCUITest server..." -curl -s -X POST http://localhost:12004/shutdown > /dev/null 2>&1 || true -echo "Waiting for xcodebuild to finish and collect coverage..." -wait $XCODEBUILD_PID 2>/dev/null || true +xcrun simctl terminate "$BOOTED" "$RUNNER_BUNDLE_ID" > /dev/null 2>&1 || true +sleep 2 # give the profile writer a moment to flush -# Generate coverage report from profdata -PROFDATA=$(find "$BUILD_DIR/local/Build/ProfileData" -name "Coverage.profdata" 2>/dev/null | head -1) -BINARY="$BUILD_DIR/local/Build/Products/Debug-iphonesimulator/${SCHEME}UITests-Runner.app/PlugIns/${SCHEME}UITests.xctest/${SCHEME}UITests" +# Merge profraw -> profdata and report +PROFDATA="${COVERAGE_DIR_ABS}/Coverage.profdata" +BINARY="$PRODUCTS/${SCHEME}UITests-Runner.app/PlugIns/${SCHEME}UITests.xctest/${SCHEME}UITests" -if [ -n "$PROFDATA" ] && [ -f "$BINARY" ]; then +if ls "${COVERAGE_DIR_ABS}"/*.profraw > /dev/null 2>&1 && [ -f "$BINARY" ]; then + xcrun llvm-profdata merge -sparse "${COVERAGE_DIR_ABS}"/*.profraw -o "$PROFDATA" echo "" echo "=== Coverage Report ===" xcrun llvm-cov report "$BINARY" -instr-profile "$PROFDATA" -ignore-filename-regex='build/local/SourcePackages|DerivedSources' diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..fcdbe4f --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,97 @@ +import { execFileSync } from "node:child_process"; + +// A single booted simulator device entry from `simctl list devices -j`. +type SimDevice = { udid: string; state: string }; + +// Bundle id of the XCUITest runner (PRODUCT_BUNDLE_IDENTIFIER +// `com.mobilenext.devicekit-iosUITests` + the `.xctrunner` suffix Xcode adds). +// Launching it on a booted simulator starts the JSON-RPC server (see README). +const RUNNER_BUNDLE_ID = "com.mobilenext.devicekit-iosUITests.xctrunner"; + +// Must match the port in playwright.config.ts (baseURL http://localhost:12004). +const PORT = 12004; + +// How long to wait for /health after launching the runner. +const HEALTH_TIMEOUT_MS = 120_000; +const HEALTH_POLL_INTERVAL_MS = 1_000; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function findBootedSimulatorUdid(): string { + const override = process.env.DEVICEKIT_SIMULATOR_UDID; + if (override) { + return override; + } + + const json = execFileSync("xcrun", ["simctl", "list", "devices", "booted", "-j"], { + encoding: "utf8", + }); + const devices = JSON.parse(json).devices as Record; + const booted = Object.values(devices) + .flat() + .find((device) => device.state === "Booted"); + + if (!booted) { + throw new Error( + "No booted simulator found. Boot one (e.g. `xcrun simctl boot `) or set DEVICEKIT_SIMULATOR_UDID.", + ); + } + return booted.udid; +} + +function killRunnerOnSimulator(udid: string): void { + try { + execFileSync("xcrun", ["simctl", "terminate", udid, RUNNER_BUNDLE_ID], { stdio: "ignore" }); + } catch { + // Runner wasn't running — nothing to kill. + } +} + +function launchRunnerOnSimulator(udid: string): void { + execFileSync("xcrun", ["simctl", "launch", udid, RUNNER_BUNDLE_ID], { + stdio: "ignore", + // simctl forwards SIMCTL_CHILD_* vars to the launched app (minus the prefix), + // so the server binds the port we expect. Other SIMCTL_CHILD_* vars already in + // the environment (e.g. LLVM_PROFILE_FILE for coverage) are passed through too. + env: { + ...process.env, + SIMCTL_CHILD_DEVICEKIT_LISTEN_PORT: String(PORT), + }, + }); +} + +function waitForServerHealthy(): void { + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const body = execFileSync("curl", ["-s", `http://localhost:${PORT}/health`], { + encoding: "utf8", + }); + if (body.trim() === "OK") { + return; + } + } catch { + // Server not accepting connections yet. + } + sleepSync(HEALTH_POLL_INTERVAL_MS); + } + throw new Error( + `XCUITest server did not become healthy on port ${PORT} within ${HEALTH_TIMEOUT_MS / 1000}s`, + ); +} + +function globalSetup(): void { + const udid = findBootedSimulatorUdid(); + console.log(`[global-setup] using simulator ${udid}`); + + killRunnerOnSimulator(udid); + console.log(`[global-setup] launching ${RUNNER_BUNDLE_ID} on port ${PORT}`); + launchRunnerOnSimulator(udid); + + waitForServerHealthy(); + console.log(`[global-setup] server healthy on http://localhost:${PORT}`); +} + +export default globalSetup; diff --git a/tests/mjpeg.test.js b/tests/mjpeg.test.ts similarity index 56% rename from tests/mjpeg.test.js rename to tests/mjpeg.test.ts index 94ef673..034f182 100644 --- a/tests/mjpeg.test.js +++ b/tests/mjpeg.test.ts @@ -1,19 +1,25 @@ -const assert = require("assert"); -const http = require("http"); +import { test, expect } from "@playwright/test"; +import http from "node:http"; const BASE_URL = "http://localhost:12004"; const BOUNDARY = "--mjpeg-frame-boundary"; const JPEG_SOI = Buffer.from([0xff, 0xd8]); // JPEG Start Of Image marker +type StreamResult = { res: http.IncomingMessage; body: Buffer }; +type MjpegFrame = { headers: Record; jpegData: Buffer }; + /** * Connects to the MJPEG stream and collects data for `durationMs`, * then destroys the socket and returns the response + accumulated buffer. */ -function collectStreamBytes(path, { durationMs = 2000 } = {}) { +function collectStreamBytes( + path: string, + { durationMs = 2000 }: { durationMs?: number } = {}, +): Promise { const url = `${BASE_URL}${path}`; return new Promise((resolve, reject) => { const req = http.get(url, (res) => { - const chunks = []; + const chunks: Buffer[] = []; let resolved = false; const finish = () => { @@ -23,17 +29,20 @@ function collectStreamBytes(path, { durationMs = 2000 } = {}) { resolve({ res, body: Buffer.concat(chunks) }); }; - res.on("data", (chunk) => chunks.push(chunk)); + res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", finish); - res.on("error", (err) => { + res.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ECONNRESET") return finish(); - if (!resolved) { resolved = true; reject(err); } + if (!resolved) { + resolved = true; + reject(err); + } }); setTimeout(finish, durationMs); }); - req.on("error", (err) => { + req.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ECONNRESET") return; reject(err); }); @@ -44,9 +53,9 @@ function collectStreamBytes(path, { durationMs = 2000 } = {}) { * Parses MJPEG frames from a raw buffer. * Each frame starts with "--mjpeg-frame-boundary\r\n" followed by headers and JPEG data. */ -function parseMjpegFrames(buffer) { +function parseMjpegFrames(buffer: Buffer): MjpegFrame[] { const text = buffer.toString("binary"); - const frames = []; + const frames: MjpegFrame[] = []; let searchStart = 0; while (true) { @@ -58,7 +67,7 @@ function parseMjpegFrames(buffer) { if (headerEnd === -1) break; const headerBlock = text.slice(headerStart, headerEnd); - const headers = {}; + const headers: Record = {}; for (const line of headerBlock.split("\r\n")) { const colon = line.indexOf(":"); if (colon !== -1) { @@ -84,120 +93,120 @@ function parseMjpegFrames(buffer) { // --------------------------------------------------------------------------- // GET /mjpeg // --------------------------------------------------------------------------- -describe("GET /mjpeg", function () { - this.timeout(15000); +test.describe("GET /mjpeg", () => { + test.describe.configure({ timeout: 15000 }); - it("returns multipart content type with correct boundary", async function () { + test("returns multipart content type with correct boundary", async () => { const { res } = await collectStreamBytes("/mjpeg"); const contentType = res.headers["content-type"]; - assert.strictEqual(contentType, "multipart/x-mixed-replace; boundary=mjpeg-frame-boundary"); + expect(contentType).toBe("multipart/x-mixed-replace; boundary=mjpeg-frame-boundary"); }); - it("returns no-cache headers", async function () { + test("returns no-cache headers", async () => { const { res } = await collectStreamBytes("/mjpeg"); - assert.strictEqual(res.headers["cache-control"], "no-cache, no-store, must-revalidate"); - assert.strictEqual(res.headers["pragma"], "no-cache"); - assert.strictEqual(res.headers["expires"], "0"); + expect(res.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate"); + expect(res.headers["pragma"]).toBe("no-cache"); + expect(res.headers["expires"]).toBe("0"); }); - it("returns server header", async function () { + test("returns server header", async () => { const { res } = await collectStreamBytes("/mjpeg"); - assert.strictEqual(res.headers["server"], "DeviceKit-iOS"); + expect(res.headers["server"]).toBe("DeviceKit-iOS"); }); - it("streams valid MJPEG frames", async function () { + test("streams valid MJPEG frames", async () => { const { body } = await collectStreamBytes("/mjpeg"); const frames = parseMjpegFrames(body); - assert.ok(frames.length >= 1, `expected at least 1 frame, got ${frames.length}`); + expect(frames.length, `expected at least 1 frame, got ${frames.length}`).toBeGreaterThanOrEqual(1); for (const frame of frames) { - assert.strictEqual(frame.headers["content-type"], "image/jpeg"); - assert.ok(frame.headers["content-length"], "frame missing Content-Length"); - assert.strictEqual(frame.jpegData.length, parseInt(frame.headers["content-length"], 10)); + expect(frame.headers["content-type"]).toBe("image/jpeg"); + expect(frame.headers["content-length"], "frame missing Content-Length").toBeTruthy(); + expect(frame.jpegData.length).toBe(parseInt(frame.headers["content-length"], 10)); } }); - it("each frame contains valid JPEG data", async function () { + test("each frame contains valid JPEG data", async () => { const { body } = await collectStreamBytes("/mjpeg"); const frames = parseMjpegFrames(body); - assert.ok(frames.length >= 1, "no frames received"); + expect(frames.length, "no frames received").toBeGreaterThanOrEqual(1); for (const frame of frames) { const startsWithJpegMagic = frame.jpegData[0] === JPEG_SOI[0] && frame.jpegData[1] === JPEG_SOI[1]; - assert.ok(startsWithJpegMagic, "frame data does not start with JPEG SOI marker (0xFF 0xD8)"); - assert.ok(frame.jpegData.length > 100, "JPEG data suspiciously small"); + expect(startsWithJpegMagic, "frame data does not start with JPEG SOI marker (0xFF 0xD8)").toBe(true); + expect(frame.jpegData.length, "JPEG data suspiciously small").toBeGreaterThan(100); } }); - it("streams multiple frames over time", async function () { + test("streams multiple frames over time", async () => { const { body } = await collectStreamBytes("/mjpeg"); const frames = parseMjpegFrames(body); - assert.ok(frames.length >= 2, `expected at least 2 frames, got ${frames.length}`); + expect(frames.length, `expected at least 2 frames, got ${frames.length}`).toBeGreaterThanOrEqual(2); }); - it("accepts custom fps parameter", async function () { + test("accepts custom fps parameter", async () => { const { res, body } = await collectStreamBytes("/mjpeg?fps=1", { durationMs: 3000 }); - assert.strictEqual(res.statusCode, 200); + expect(res.statusCode).toBe(200); const frames = parseMjpegFrames(body); - assert.ok(frames.length >= 1, "no frames received with fps=1"); + expect(frames.length, "no frames received with fps=1").toBeGreaterThanOrEqual(1); }); - it("accepts custom quality parameter", async function () { + test("accepts custom quality parameter", async () => { const { body: lowQ } = await collectStreamBytes("/mjpeg?quality=1"); const { body: highQ } = await collectStreamBytes("/mjpeg?quality=100"); const lowFrames = parseMjpegFrames(lowQ); const highFrames = parseMjpegFrames(highQ); - assert.ok(lowFrames.length >= 1, "no frames at quality=1"); - assert.ok(highFrames.length >= 1, "no frames at quality=100"); + expect(lowFrames.length, "no frames at quality=1").toBeGreaterThanOrEqual(1); + expect(highFrames.length, "no frames at quality=100").toBeGreaterThanOrEqual(1); // Higher quality should produce larger JPEG data on average const avgLow = lowFrames.reduce((sum, f) => sum + f.jpegData.length, 0) / lowFrames.length; const avgHigh = highFrames.reduce((sum, f) => sum + f.jpegData.length, 0) / highFrames.length; - assert.ok(avgHigh > avgLow, `expected quality=100 (avg ${avgHigh}B) to produce larger frames than quality=1 (avg ${avgLow}B)`); + expect(avgHigh, `expected quality=100 (avg ${avgHigh}B) to produce larger frames than quality=1 (avg ${avgLow}B)`).toBeGreaterThan(avgLow); }); - it("accepts custom scale parameter", async function () { + test("accepts custom scale parameter", async () => { const { body: fullScale } = await collectStreamBytes("/mjpeg?scale=100"); const { body: halfScale } = await collectStreamBytes("/mjpeg?scale=50"); const fullFrames = parseMjpegFrames(fullScale); const halfFrames = parseMjpegFrames(halfScale); - assert.ok(fullFrames.length >= 1, "no frames at scale=100"); - assert.ok(halfFrames.length >= 1, "no frames at scale=50"); + expect(fullFrames.length, "no frames at scale=100").toBeGreaterThanOrEqual(1); + expect(halfFrames.length, "no frames at scale=50").toBeGreaterThanOrEqual(1); // Smaller scale should produce smaller JPEG data on average const avgFull = fullFrames.reduce((sum, f) => sum + f.jpegData.length, 0) / fullFrames.length; const avgHalf = halfFrames.reduce((sum, f) => sum + f.jpegData.length, 0) / halfFrames.length; - assert.ok(avgFull > avgHalf, `expected scale=100 (avg ${avgFull}B) to produce larger frames than scale=50 (avg ${avgHalf}B)`); + expect(avgFull, `expected scale=100 (avg ${avgFull}B) to produce larger frames than scale=50 (avg ${avgHalf}B)`).toBeGreaterThan(avgHalf); }); - it("clamps out-of-range fps values", async function () { + test("clamps out-of-range fps values", async () => { // fps=0 should be clamped to 1, fps=999 should be clamped to 60 — both should still stream const { res: lowRes } = await collectStreamBytes("/mjpeg?fps=0"); - assert.strictEqual(lowRes.statusCode, 200); + expect(lowRes.statusCode).toBe(200); const { res: highRes } = await collectStreamBytes("/mjpeg?fps=999"); - assert.strictEqual(highRes.statusCode, 200); + expect(highRes.statusCode).toBe(200); }); - it("clamps out-of-range quality values", async function () { + test("clamps out-of-range quality values", async () => { const { res: lowRes } = await collectStreamBytes("/mjpeg?quality=0"); - assert.strictEqual(lowRes.statusCode, 200); + expect(lowRes.statusCode).toBe(200); const { res: highRes } = await collectStreamBytes("/mjpeg?quality=999"); - assert.strictEqual(highRes.statusCode, 200); + expect(highRes.statusCode).toBe(200); }); - it("clamps out-of-range scale values", async function () { + test("clamps out-of-range scale values", async () => { const { res: lowRes } = await collectStreamBytes("/mjpeg?scale=0"); - assert.strictEqual(lowRes.statusCode, 200); + expect(lowRes.statusCode).toBe(200); const { res: highRes } = await collectStreamBytes("/mjpeg?scale=999"); - assert.strictEqual(highRes.statusCode, 200); + expect(highRes.statusCode).toBe(200); }); }); diff --git a/tests/package-lock.json b/tests/package-lock.json index 13ab5b7..cb1ffb0 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -1,1188 +1,111 @@ { - "name": "tests", + "name": "devicekit-ios-tests", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tests", + "name": "devicekit-ios-tests", "version": "1.0.0", - "license": "ISC", "devDependencies": { - "mocha": "^11.7.5" + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "playwright": "1.60.0" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "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", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "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/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "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", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/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/cliui/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/cliui/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/cliui/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/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "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/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/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "undici-types": "~6.21.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "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": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "playwright-core": "1.60.0" }, "bin": { - "glob": "dist/esm/bin.mjs" + "playwright": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "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-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" }, "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "fsevents": "2.3.2" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", - "dev": true, - "license": "MIT", - "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, + "license": "Apache-2.0", "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" + "playwright-core": "cli.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT" - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/string-width-cjs": { - "name": "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/string-width-cjs/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/string-width-cjs/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/string-width-cjs/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/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-ansi-cjs": { - "name": "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/strip-ansi-cjs/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/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, + "license": "Apache-2.0", "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", - "dev": true, - "license": "Apache-2.0" - }, - "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-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=8" + "node": ">=14.17" } }, - "node_modules/wrap-ansi-cjs/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/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/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/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/yargs/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==", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" - }, - "node_modules/yargs/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/yargs/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/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/tests/package.json b/tests/package.json index 12e3c48..532bcb7 100644 --- a/tests/package.json +++ b/tests/package.json @@ -3,9 +3,11 @@ "version": "1.0.0", "private": true, "scripts": { - "test": "mocha --timeout 10000 '*.test.js'" + "test": "playwright test" }, "devDependencies": { - "mocha": "^11.7.5" + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.0" } } diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 0000000..5146b55 --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "*.test.ts", + globalSetup: "./global-setup.ts", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 20000, + reporter: "list", + use: { + baseURL: "http://localhost:12004", + }, +}); diff --git a/tests/rpc.test.js b/tests/rpc.test.js deleted file mode 100644 index 9aa89c1..0000000 --- a/tests/rpc.test.js +++ /dev/null @@ -1,349 +0,0 @@ -const assert = require("assert"); - -const BASE_URL = "http://localhost:12004"; -let requestId = 0; - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function rpc(method, params = {}) { - const res = await fetch(`${BASE_URL}/rpc`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method, - params, - id: ++requestId, - }), - }); - return res.json(); -} - -function returnsResult(response) { - assert.strictEqual(response.jsonrpc, "2.0"); - assert.ok(response.result !== undefined, "expected result, got error: " + JSON.stringify(response.error)); - return response.result; -} - -function returnsError(response) { - assert.strictEqual(response.jsonrpc, "2.0"); - assert.ok(response.error !== undefined, "expected error, got result"); - return response.error; -} - -// --------------------------------------------------------------------------- -// device.info -// --------------------------------------------------------------------------- -describe("device.info", function () { - it("returns screen size and scale", async function () { - const result = returnsResult(await rpc("device.info")); - assert.ok(result.screenSize); - assert.ok(typeof result.screenSize.width === "number"); - assert.ok(typeof result.screenSize.height === "number"); - assert.ok(result.screenSize.width > 0); - assert.ok(result.screenSize.height > 0); - assert.ok(typeof result.scale === "number"); - assert.ok(result.scale > 0); - }); - - it("ignores extra params", async function () { - const result = returnsResult(await rpc("device.info", { foo: "bar" })); - assert.ok(result.screenSize); - }); -}); - -// --------------------------------------------------------------------------- -// device.apps.foreground -// --------------------------------------------------------------------------- -describe("device.apps.foreground", function () { - it("returns foreground app info", async function () { - const result = returnsResult(await rpc("device.apps.foreground")); - assert.ok("bundleId" in result); - assert.ok("name" in result); - assert.ok("pid" in result); - }); -}); - -// --------------------------------------------------------------------------- -// device.apps.launch & device.apps.terminate -// --------------------------------------------------------------------------- -describe("device.apps.launch and device.apps.terminate", function () { - it("fails to launch without bundleId", async function () { - const error = returnsError(await rpc("device.apps.launch")); - assert.ok(error.code); - }); - - it("fails to terminate without bundleId", async function () { - const error = returnsError(await rpc("device.apps.terminate")); - assert.ok(error.code); - }); - - it("terminating a non-running app returns terminated false", async function () { - const result = returnsResult(await rpc("device.apps.terminate", { - bundleId: "com.invalid.nonexistent", - })); - assert.strictEqual(result.terminated, false); - }); - - it("launch settings, verify foreground, terminate, verify springboard, terminate again returns false", async function () { - returnsResult(await rpc("device.apps.launch", { bundleId: "com.apple.Preferences" })); - await sleep(1000); - - const afterLaunch = returnsResult(await rpc("device.apps.foreground")); - assert.strictEqual(afterLaunch.bundleId, "com.apple.Preferences"); - - const first = returnsResult(await rpc("device.apps.terminate", { bundleId: "com.apple.Preferences" })); - assert.strictEqual(first.terminated, true); - await sleep(1000); - - const afterTerminate = returnsResult(await rpc("device.apps.foreground")); - assert.strictEqual(afterTerminate.bundleId, "com.apple.springboard"); - - const second = returnsResult(await rpc("device.apps.terminate", { bundleId: "com.apple.Preferences" })); - assert.strictEqual(second.terminated, false); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.tap -// --------------------------------------------------------------------------- -describe("device.io.tap", function () { - it("taps at coordinates", async function () { - const result = returnsResult(await rpc("device.io.tap", { x: 100, y: 100 })); - assert.ok(result); - }); - - it("fails without coordinates", async function () { - const error = returnsError(await rpc("device.io.tap")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.swipe -// --------------------------------------------------------------------------- -describe("device.io.swipe", function () { - it("swipes between two points", async function () { - const result = returnsResult(await rpc("device.io.swipe", { - x1: 200, y1: 400, x2: 200, y2: 200, - })); - assert.ok(result); - }); - - it("fails without coordinates", async function () { - const error = returnsError(await rpc("device.io.swipe")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.longpress -// --------------------------------------------------------------------------- -describe("device.io.longpress", function () { - it("long presses at coordinates", async function () { - const result = returnsResult(await rpc("device.io.longpress", { - x: 100, y: 100, duration: 0.5, - })); - assert.ok(result); - }); - - it("fails without params", async function () { - const error = returnsError(await rpc("device.io.longpress")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.gesture -// --------------------------------------------------------------------------- -describe("device.io.gesture", function () { - it("performs a tap gesture via actions", async function () { - const result = returnsResult(await rpc("device.io.gesture", { - actions: [ - { type: "press", x: 150, y: 150, duration: 0, button: 0 }, - { type: "release", x: 150, y: 150, duration: 0.1, button: 0 }, - ], - })); - assert.ok(result); - }); - - it("fails without actions", async function () { - const error = returnsError(await rpc("device.io.gesture")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.text -// --------------------------------------------------------------------------- -describe("device.io.text", function () { - it("types text", async function () { - const result = returnsResult(await rpc("device.io.text", { text: "hello" })); - assert.ok(result); - }); - - it("fails without text", async function () { - const error = returnsError(await rpc("device.io.text")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.button -// --------------------------------------------------------------------------- -describe("device.io.button", function () { - it("pressing home returns to springboard", async function () { - await rpc("device.apps.launch", { bundleId: "com.apple.Preferences" }); - await sleep(1000); - const before = returnsResult(await rpc("device.apps.foreground")); - assert.strictEqual(before.bundleId, "com.apple.Preferences"); - - returnsResult(await rpc("device.io.button", { button: "home" })); - await sleep(1000); - - const after = returnsResult(await rpc("device.apps.foreground")); - assert.strictEqual(after.bundleId, "com.apple.springboard"); - }); - - it("fails without button param", async function () { - const error = returnsError(await rpc("device.io.button")); - assert.ok(error.code); - }); - - it("fails with uppercase HOME", async function () { - const error = returnsError(await rpc("device.io.button", { button: "HOME" })); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.io.orientation -// --------------------------------------------------------------------------- -describe("device.io.orientation", function () { - it("sets orientation to landscape and back", async function () { - returnsResult(await rpc("device.io.orientation.set", { orientation: "LANDSCAPE" })); - const landscape = returnsResult(await rpc("device.io.orientation.get")); - assert.strictEqual(landscape.orientation, "LANDSCAPE"); - - returnsResult(await rpc("device.io.orientation.set", { orientation: "PORTRAIT" })); - const portrait = returnsResult(await rpc("device.io.orientation.get")); - assert.strictEqual(portrait.orientation, "PORTRAIT"); - }); - - it("fails without orientation param", async function () { - const error = returnsError(await rpc("device.io.orientation.set")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.screenshot -// --------------------------------------------------------------------------- -describe("device.screenshot", function () { - it("captures a png screenshot", async function () { - const result = returnsResult(await rpc("device.screenshot", { format: "png" })); - assert.ok(typeof result.data === "string"); - assert.ok(result.data.length > 0); - }); - - it("captures a jpeg screenshot", async function () { - const result = returnsResult(await rpc("device.screenshot", { - format: "jpeg", - quality: 50, - })); - assert.ok(typeof result.data === "string"); - assert.ok(result.data.length > 0); - }); - - it("fails without format", async function () { - const error = returnsError(await rpc("device.screenshot")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// device.dump.ui -// --------------------------------------------------------------------------- -describe("device.dump.ui", function () { - it("dumps the UI hierarchy without params", async function () { - const result = returnsResult(await rpc("device.dump.ui")); - assert.ok(result); - }); - - it("returns source tree format for json", async function () { - const result = returnsResult(await rpc("device.dump.ui", { format: "json" })); - assert.ok(typeof result.type === "string", "expected type to be a string"); - assert.ok(result.rect, "expected rect"); - assert.ok(typeof result.rect.x === "number", "expected rect.x"); - assert.ok(typeof result.rect.y === "number", "expected rect.y"); - assert.ok(typeof result.rect.width === "number", "expected rect.width"); - assert.ok(typeof result.rect.height === "number", "expected rect.height"); - assert.ok(!("elementType" in result), "should not have elementType"); - assert.ok(!("frame" in result), "should not have frame"); - assert.ok(!("isVisible" in result), "should not have isVisible"); - }); - - it("json children follow the same source tree format", async function () { - const result = returnsResult(await rpc("device.dump.ui", { format: "json" })); - assert.ok(Array.isArray(result.children), "expected children array"); - assert.ok(result.children.length > 0, "expected at least one child"); - const child = result.children[0]; - assert.ok(typeof child.type === "string", "child should have string type"); - assert.ok(child.rect, "child should have rect"); - assert.ok(typeof child.rect.x === "number", "child rect.x should be number"); - }); - - it("dumps the UI hierarchy as raw", async function () { - const result = returnsResult(await rpc("device.dump.ui", { format: "raw" })); - assert.ok(typeof result.elementType === "number", "raw format should have numeric elementType"); - assert.ok(result.frame, "raw format should have frame"); - }); -}); - -// --------------------------------------------------------------------------- -// device.url -// --------------------------------------------------------------------------- -describe("device.url", function () { - it("opens an https url", async function () { - const result = returnsResult(await rpc("device.url", { - url: "https://www.apple.com", - })); - assert.ok(result); - }); - - it("fails without url param", async function () { - const error = returnsError(await rpc("device.url")); - assert.ok(error.code); - }); -}); - -// --------------------------------------------------------------------------- -// error handling -// --------------------------------------------------------------------------- -describe("error handling", function () { - it("returns method not found for unknown method", async function () { - const error = returnsError(await rpc("nonexistent.method")); - assert.strictEqual(error.code, -32601); - }); -}); - -// --------------------------------------------------------------------------- -// health check -// --------------------------------------------------------------------------- -describe("GET /health", function () { - it("returns OK", async function () { - const res = await fetch(`${BASE_URL}/health`); - assert.strictEqual(res.status, 200); - const body = await res.text(); - assert.strictEqual(body, "OK"); - }); -}); - -// --------------------------------------------------------------------------- -// teardown -// --------------------------------------------------------------------------- -after(async function () { - await rpc("device.io.button", { button: "home" }); -}); diff --git a/tests/rpc.test.ts b/tests/rpc.test.ts new file mode 100644 index 0000000..6c8a3ef --- /dev/null +++ b/tests/rpc.test.ts @@ -0,0 +1,370 @@ +import { + test, + expect, + request as playwrightRequest, + type APIRequestContext, +} from "@playwright/test"; + +const BASE_URL = "http://localhost:12004"; + +type RpcError = { code: number; message?: string }; +type RpcResponse = { + jsonrpc: string; + id: number; + result?: any; + error?: RpcError; +}; + +let requestId = 0; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function rpc( + request: APIRequestContext, + method: string, + params: Record = {}, +): Promise { + const res = await request.post(`${BASE_URL}/rpc`, { + headers: { "Content-Type": "application/json" }, + data: { + jsonrpc: "2.0", + method, + params, + id: ++requestId, + }, + }); + return (await res.json()) as RpcResponse; +} + +function returnsResult(response: RpcResponse): any { + expect(response.jsonrpc).toBe("2.0"); + expect( + response.result, + "expected result, got error: " + JSON.stringify(response.error), + ).toBeDefined(); + return response.result; +} + +function returnsError(response: RpcResponse): RpcError { + expect(response.jsonrpc).toBe("2.0"); + expect(response.error, "expected error, got result").toBeDefined(); + return response.error!; +} + +// --------------------------------------------------------------------------- +// device.info +// --------------------------------------------------------------------------- +test.describe("device.info", () => { + test("returns screen size and scale", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.info")); + expect(result.screenSize).toBeTruthy(); + expect(typeof result.screenSize.width).toBe("number"); + expect(typeof result.screenSize.height).toBe("number"); + expect(result.screenSize.width).toBeGreaterThan(0); + expect(result.screenSize.height).toBeGreaterThan(0); + expect(typeof result.scale).toBe("number"); + expect(result.scale).toBeGreaterThan(0); + }); + + test("ignores extra params", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.info", { foo: "bar" })); + expect(result.screenSize).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.apps.foreground +// --------------------------------------------------------------------------- +test.describe("device.apps.foreground", () => { + test("returns foreground app info", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.apps.foreground")); + expect(result).toHaveProperty("bundleId"); + expect(result).toHaveProperty("name"); + expect(result).toHaveProperty("pid"); + }); +}); + +// --------------------------------------------------------------------------- +// device.apps.launch & device.apps.terminate +// --------------------------------------------------------------------------- +test.describe("device.apps.launch and device.apps.terminate", () => { + test("fails to launch without bundleId", async ({ request }) => { + const error = returnsError(await rpc(request, "device.apps.launch")); + expect(error.code).toBeTruthy(); + }); + + test("fails to terminate without bundleId", async ({ request }) => { + const error = returnsError(await rpc(request, "device.apps.terminate")); + expect(error.code).toBeTruthy(); + }); + + test("terminating a non-running app returns terminated false", async ({ request }) => { + const result = returnsResult( + await rpc(request, "device.apps.terminate", { bundleId: "com.invalid.nonexistent" }), + ); + expect(result.terminated).toBe(false); + }); + + test("launch settings, verify foreground, terminate, verify springboard, terminate again returns false", async ({ request }) => { + returnsResult(await rpc(request, "device.apps.launch", { bundleId: "com.apple.Preferences" })); + await sleep(1000); + + const afterLaunch = returnsResult(await rpc(request, "device.apps.foreground")); + expect(afterLaunch.bundleId).toBe("com.apple.Preferences"); + + const first = returnsResult(await rpc(request, "device.apps.terminate", { bundleId: "com.apple.Preferences" })); + expect(first.terminated).toBe(true); + await sleep(1000); + + const afterTerminate = returnsResult(await rpc(request, "device.apps.foreground")); + expect(afterTerminate.bundleId).toBe("com.apple.springboard"); + + const second = returnsResult(await rpc(request, "device.apps.terminate", { bundleId: "com.apple.Preferences" })); + expect(second.terminated).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.tap +// --------------------------------------------------------------------------- +test.describe("device.io.tap", () => { + test("taps at coordinates", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.io.tap", { x: 100, y: 100 })); + expect(result).toBeTruthy(); + }); + + test("fails without coordinates", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.tap")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.swipe +// --------------------------------------------------------------------------- +test.describe("device.io.swipe", () => { + test("swipes between two points", async ({ request }) => { + const result = returnsResult( + await rpc(request, "device.io.swipe", { x1: 200, y1: 400, x2: 200, y2: 200 }), + ); + expect(result).toBeTruthy(); + }); + + test("fails without coordinates", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.swipe")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.longpress +// --------------------------------------------------------------------------- +test.describe("device.io.longpress", () => { + test("long presses at coordinates", async ({ request }) => { + const result = returnsResult( + await rpc(request, "device.io.longpress", { x: 100, y: 100, duration: 0.5 }), + ); + expect(result).toBeTruthy(); + }); + + test("fails without params", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.longpress")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.gesture +// --------------------------------------------------------------------------- +test.describe("device.io.gesture", () => { + test("performs a tap gesture via actions", async ({ request }) => { + const result = returnsResult( + await rpc(request, "device.io.gesture", { + actions: [ + { type: "press", x: 150, y: 150, duration: 0, button: 0 }, + { type: "release", x: 150, y: 150, duration: 0.1, button: 0 }, + ], + }), + ); + expect(result).toBeTruthy(); + }); + + test("fails without actions", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.gesture")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.text +// --------------------------------------------------------------------------- +test.describe("device.io.text", () => { + test("types text", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.io.text", { text: "hello" })); + expect(result).toBeTruthy(); + }); + + test("fails without text", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.text")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.button +// --------------------------------------------------------------------------- +test.describe("device.io.button", () => { + test("pressing home returns to springboard", async ({ request }) => { + await rpc(request, "device.apps.launch", { bundleId: "com.apple.Preferences" }); + await sleep(1000); + const before = returnsResult(await rpc(request, "device.apps.foreground")); + expect(before.bundleId).toBe("com.apple.Preferences"); + + returnsResult(await rpc(request, "device.io.button", { button: "home" })); + await sleep(1000); + + const after = returnsResult(await rpc(request, "device.apps.foreground")); + expect(after.bundleId).toBe("com.apple.springboard"); + }); + + test("fails without button param", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.button")); + expect(error.code).toBeTruthy(); + }); + + test("fails with uppercase HOME", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.button", { button: "HOME" })); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.io.orientation +// --------------------------------------------------------------------------- +test.describe("device.io.orientation", () => { + test("sets orientation to landscape and back", async ({ request }) => { + returnsResult(await rpc(request, "device.io.orientation.set", { orientation: "LANDSCAPE" })); + const landscape = returnsResult(await rpc(request, "device.io.orientation.get")); + expect(landscape.orientation).toBe("LANDSCAPE"); + + returnsResult(await rpc(request, "device.io.orientation.set", { orientation: "PORTRAIT" })); + const portrait = returnsResult(await rpc(request, "device.io.orientation.get")); + expect(portrait.orientation).toBe("PORTRAIT"); + }); + + test("fails without orientation param", async ({ request }) => { + const error = returnsError(await rpc(request, "device.io.orientation.set")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.screenshot +// --------------------------------------------------------------------------- +test.describe("device.screenshot", () => { + test("captures a png screenshot", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.screenshot", { format: "png" })); + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + }); + + test("captures a jpeg screenshot", async ({ request }) => { + const result = returnsResult( + await rpc(request, "device.screenshot", { format: "jpeg", quality: 50 }), + ); + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + }); + + test("fails without format", async ({ request }) => { + const error = returnsError(await rpc(request, "device.screenshot")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.dump.ui +// --------------------------------------------------------------------------- +test.describe("device.dump.ui", () => { + test("dumps the UI hierarchy without params", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.dump.ui")); + expect(result).toBeTruthy(); + }); + + test("returns source tree format for json", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.dump.ui", { format: "json" })); + expect(typeof result.type, "expected type to be a string").toBe("string"); + expect(result.rect, "expected rect").toBeTruthy(); + expect(typeof result.rect.x, "expected rect.x").toBe("number"); + expect(typeof result.rect.y, "expected rect.y").toBe("number"); + expect(typeof result.rect.width, "expected rect.width").toBe("number"); + expect(typeof result.rect.height, "expected rect.height").toBe("number"); + expect("elementType" in result, "should not have elementType").toBe(false); + expect("frame" in result, "should not have frame").toBe(false); + expect("isVisible" in result, "should not have isVisible").toBe(false); + }); + + test("json children follow the same source tree format", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.dump.ui", { format: "json" })); + expect(Array.isArray(result.children), "expected children array").toBe(true); + expect(result.children.length, "expected at least one child").toBeGreaterThan(0); + const child = result.children[0]; + expect(typeof child.type, "child should have string type").toBe("string"); + expect(child.rect, "child should have rect").toBeTruthy(); + expect(typeof child.rect.x, "child rect.x should be number").toBe("number"); + }); + + test("dumps the UI hierarchy as raw", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.dump.ui", { format: "raw" })); + expect(typeof result.elementType, "raw format should have numeric elementType").toBe("number"); + expect(result.frame, "raw format should have frame").toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// device.url +// --------------------------------------------------------------------------- +test.describe("device.url", () => { + test("opens an https url", async ({ request }) => { + const result = returnsResult(await rpc(request, "device.url", { url: "https://www.apple.com" })); + expect(result).toBeTruthy(); + }); + + test("fails without url param", async ({ request }) => { + const error = returnsError(await rpc(request, "device.url")); + expect(error.code).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// error handling +// --------------------------------------------------------------------------- +test.describe("error handling", () => { + test("returns method not found for unknown method", async ({ request }) => { + const error = returnsError(await rpc(request, "nonexistent.method")); + expect(error.code).toBe(-32601); + }); +}); + +// --------------------------------------------------------------------------- +// health check +// --------------------------------------------------------------------------- +test.describe("GET /health", () => { + test("returns OK", async ({ request }) => { + const res = await request.get(`${BASE_URL}/health`); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toBe("OK"); + }); +}); + +// --------------------------------------------------------------------------- +// teardown +// --------------------------------------------------------------------------- +test.afterAll(async () => { + const context = await playwrightRequest.newContext(); + await rpc(context, "device.io.button", { button: "home" }); + await context.dispose(); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..ac001d0 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["*.ts"] +}