From 2ab216561727b5e61238d5c85a049691dd99f8d4 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Thu, 15 Oct 2026 06:26:32 +0000 Subject: [PATCH 1/5] Run the browser tests on a Modal GPU in CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++ AGENTS.md | 2 +- bun.lock | 95 ++++++++++++++++++++++++++++++++++++ package.json | 4 +- playwright.config.ts | 3 +- tests/modal.ts | 103 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 tests/modal.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b72bcb14..9f2b391e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,23 @@ jobs: - name: Typecheck and build run: bun run build + browser: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - run: bun install --frozen-lockfile + - name: Browser tests on a Modal GPU + run: bun run test:modal + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: test-results + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 5fb999d1..69de0a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A professional photo editor with little, readable code: Vite, React, [vgpu](http ## Commands -`bun install`, then `bun dev`. On every code change run `bun run check` (writes fixes), `bun run build`, and `bun run test` (Bun; GPU tests use `vgpu/mock`). `bun run test:browser [file]` runs `*.e2e.ts` in Chromium with real pixels; install its browser once per Playwright version with `bunx --no-install playwright install chromium`. It starts its own Vite: stop any other Vite server first, since concurrent servers reload each other's pages mid-test. Any other scripted browser reuses the launch flags in [playwright.config.ts](playwright.config.ts). The assistant at `/?assistant` needs `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. +`bun install`, then `bun dev`. On every code change run `bun run check` (writes fixes), `bun run build`, and `bun run test` (Bun; GPU tests use `vgpu/mock`). `bun run test:browser [file]` runs `*.e2e.ts` in Chromium with real pixels; install its browser once per Playwright version with `bunx --no-install playwright install chromium`. It starts its own Vite: stop any other Vite server first, since concurrent servers reload each other's pages mid-test. Any other scripted browser reuses the launch flags in [playwright.config.ts](playwright.config.ts). `bun run test:modal [file]` runs the same suite on a Modal L4 GPU, signed in through the Modal CLI or `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`; CI runs it on every pull request. The assistant at `/?assistant` needs `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. ## Structure diff --git a/bun.lock b/bun.lock index 29da01cc..f7f5e12d 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.1", "@xmldom/xmldom": "^0.9.12", + "modal": "^0.11.0", "typescript": "^7.0.2", "vite": "^8.2.2", }, @@ -70,6 +71,18 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q=="], + "@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="], + + "@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="], + + "@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="], + + "@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="], + + "@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="], + + "@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -86,6 +99,10 @@ "@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.6", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -96,6 +113,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], @@ -104,6 +123,24 @@ "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], @@ -242,34 +279,58 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], + "abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="], + "ai": ["ai@7.0.119", "", { "dependencies": { "@ai-sdk/gateway": "4.0.97", "@ai-sdk/provider": "4.0.19", "@ai-sdk/provider-utils": "5.0.50" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KlHbkKMmdb2g0DXdkhBXzSvLSkdoAg0MdShhTTOyuoHKJetEUlgjn2juZ05lHY97EqCwBuSK8dgiEeB0ty5xZw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="], + + "cbor-x": ["cbor-x@1.6.6", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -298,12 +359,24 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "modal": ["modal@0.11.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-Kvy2nejPkice2FI82p5e8TJrFDy0BaSYZjJVmvnTnu51bOH9Ok7IRtp8NavBQqMuXhbpbj6gRULwxsjNkweF6A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "nice-grpc": ["nice-grpc@2.1.17", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.4" } }, "sha512-pu9xYPlWSeqoYQOCqb2ftQqZi/P2DaI70PSuwnxvoyIRiilaOVtktyPe6LmuXegWB4Ic1sPuDGCnCCASbwzK0w=="], + + "nice-grpc-common": ["nice-grpc-common@2.0.4", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -318,26 +391,38 @@ "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + "raw-webgpu": ["raw-webgpu@0.1.1", "", {}, "sha512-JqFqFax+IAKb+Frlh5QTFD8viowAvBJ2ofY1lWSq76t1mjXa09oYuLYoClraaCuK1tOPpB9zOfr1Mmn7+aqLag=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -348,6 +433,8 @@ "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "vgpu": ["vgpu@0.3.1", "", { "dependencies": { "@modelcontextprotocol/server": "2.0.0", "@vgpu/adapter-mock": "0.3.1", "@vgpu/adapter-node": "0.3.1", "@vgpu/core": "0.3.1", "@vgpu/wgsl": "0.3.1", "@vgpu/wgsl-std": "0.3.1", "@webgpu/types": "^0.1.69", "pixelmatch": "^7.2.0", "pngjs": "^7.0.0", "wgpu-matrix": "^3.4.2", "zod": "^4.4.3" }, "bin": { "vgpu": "bin/vgpu.js" } }, "sha512-NeEETis2aCr0I9/8dy42lueNGNs3GZxL+0OmjLI82wlgPD/4WpW/XNX4xduc+ChxFqOJtjEYsz297VS9GmIuyA=="], "vgpu-react": ["vgpu-react@0.3.0", "", { "peerDependencies": { "react": ">=19", "vgpu": "^0.3.0" } }, "sha512-XtXKQf5fH0ylGFR3LLDDFdmxxGQu9jXnoG02sDfsePH/6N6oM539etWahB8mqzTqWCZfUIFAesqGQXavKrvhsg=="], @@ -358,6 +445,14 @@ "wgpu-matrix": ["wgpu-matrix@3.4.2", "", {}, "sha512-IfZFbG7olEYBD76VCZy2TzqM57GKy3LT++AZh37tMHPPByO6R2h61zXGBZFNPi2IzxNivNi8y5dvze5PjaczjA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.3", "", { "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" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="], diff --git a/package.json b/package.json index 2bb36b81..de0a00d4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "tsc && vite build", "check": "biome check --write", "test": "bun test", - "test:browser": "playwright test" + "test:browser": "playwright test", + "test:modal": "bun tests/modal.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", @@ -41,6 +42,7 @@ "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.1", "@xmldom/xmldom": "^0.9.12", + "modal": "^0.11.0", "typescript": "^7.0.2", "vite": "^8.2.2" } diff --git a/playwright.config.ts b/playwright.config.ts index a3ffb8da..a6893f3c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ testDir: "./tests", testMatch: "**/*.e2e.ts", use: { - channel: "chromium", + // Modal's GPU sandboxes give WebGPU to the headless shell build only. + channel: process.env.PLAYWRIGHT_CHANNEL ?? "chromium", baseURL: "http://127.0.0.1:4173", launchOptions: { args }, }, diff --git a/tests/modal.ts b/tests/modal.ts new file mode 100644 index 00000000..e55a4678 --- /dev/null +++ b/tests/modal.ts @@ -0,0 +1,103 @@ +/** + * Runs the browser tests on a Modal GPU: `bun run test:modal [playwright options]`. + * Signs in with MODAL_TOKEN_ID and MODAL_TOKEN_SECRET, or the Modal CLI's profile. + */ + +import playwright from "@playwright/test/package.json" with { type: "json" }; +import { $ } from "bun"; +import { ModalClient } from "modal"; + +const started = performance.now(); +function log(message: string) { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`[modal ${seconds}s] ${message}`); +} + +// Dependencies become an image layer keyed on the lockfile, so a sandbox starts with them installed. +const manifest = Buffer.from( + await $`tar -cz package.json bun.lock`.arrayBuffer(), +).toString("base64"); +const modal = new ModalClient(); +const app = await modal.apps.fromName("openlight-tests", { + createIfMissing: true, +}); +const image = modal.images + .fromRegistry(`mcr.microsoft.com/playwright:v${playwright.version}-noble`) + .dockerfileCommands([ + // Without Mesa's Vulkan drivers beside NVIDIA's, Chromium loses its WebGPU instance mid-test. + "RUN apt-get update && apt-get install -y --no-install-recommends libvulkan1 mesa-vulkan-drivers unzip && rm -rf /var/lib/apt/lists/*", + `RUN curl -fsSL https://bun.sh/install | bash -s bun-v${Bun.version} && ln -s /root/.bun/bin/bun /usr/local/bin/bun`, + `RUN mkdir /repo && cd /repo && echo ${manifest} | base64 -d | tar -xz && bun install --frozen-lockfile`, + // Modal's GPU sandboxes hand WebGPU to the headless shell build of Chromium only. + "ENV CI=1 NVIDIA_DRIVER_CAPABILITIES=all PLAYWRIGHT_CHANNEL=chromium-headless-shell", + ]); +const sandbox = await modal.sandboxes.create(app, image, { + gpu: "L4", + // Eight workers keep the GPU busy; more contend for it and time out. + cpu: 8, + memoryMiB: 16384, + timeoutMs: 20 * 60_000, + workdir: "/repo", +}); +log(`sandbox ${sandbox.sandboxId} ready`); + +try { + // The working tree as it is, tracked or new, without what Git ignores or deletes. + const listed = + await $`git ls-files -z --cached --others --exclude-standard`.text(); + const files = []; + for (const file of listed.split("\0")) { + if (file && (await Bun.file(file).exists())) { + files.push(file); + } + } + const source = `/tmp/openlight-${sandbox.sandboxId}.tgz`; + await $`tar -czf ${source} --null -T - < ${new Response(files.join("\0"))}`; + await sandbox.filesystem.copyFromLocal(source, "/tmp/source.tgz"); + await $`rm ${source}`; + log(`uploaded ${files.length} files`); + + const tests = await sandbox.exec( + [ + "bash", + "-c", + 'tar -xzf /tmp/source.tgz && exec bun run test:browser --workers=8 "$@"', + "playwright", + ...process.argv.slice(2), + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const forward = async ( + stream: ReadableStream, + output: NodeJS.WriteStream, + ) => { + for await (const chunk of stream) { + output.write(chunk); + } + }; + await Promise.all([ + forward(tests.stdout, process.stdout), + forward(tests.stderr, process.stderr), + ]); + const code = await tests.wait(); + log(`tests exited with ${code}`); + if (code !== 0) { + const report = await sandbox.exec([ + "tar", + "-czf", + "/tmp/results.tgz", + "test-results", + ]); + if ((await report.wait()) === 0) { + await sandbox.filesystem.copyToLocal( + "/tmp/results.tgz", + "test-results.tgz", + ); + await $`mkdir -p test-results && tar -xzf test-results.tgz && rm test-results.tgz`; + } + } + process.exitCode = code; +} finally { + await sandbox.terminate(); + modal.close(); +} From c83995b2240fe3db27b6dc584459740f0caf4076 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Thu, 15 Oct 2026 06:34:14 +0000 Subject: [PATCH 2/5] Log the image build apart from the sandbox start Co-Authored-By: Claude Opus 5.5 --- tests/modal.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/modal.ts b/tests/modal.ts index e55a4678..a8d17e5a 100644 --- a/tests/modal.ts +++ b/tests/modal.ts @@ -31,6 +31,8 @@ const image = modal.images // Modal's GPU sandboxes hand WebGPU to the headless shell build of Chromium only. "ENV CI=1 NVIDIA_DRIVER_CAPABILITIES=all PLAYWRIGHT_CHANNEL=chromium-headless-shell", ]); +await image.build(app); +log(`image ${image.imageId} built`); const sandbox = await modal.sandboxes.create(app, image, { gpu: "L4", // Eight workers keep the GPU busy; more contend for it and time out. From eef2bedac778e462ed47a6e7d54664dc9dbdadc5 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Thu, 15 Oct 2026 06:36:53 +0000 Subject: [PATCH 3/5] Key the dependency layer on file contents, not archive timestamps Co-Authored-By: Claude Opus 5.5 --- tests/modal.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/modal.ts b/tests/modal.ts index a8d17e5a..84a0384b 100644 --- a/tests/modal.ts +++ b/tests/modal.ts @@ -13,10 +13,13 @@ function log(message: string) { console.log(`[modal ${seconds}s] ${message}`); } -// Dependencies become an image layer keyed on the lockfile, so a sandbox starts with them installed. -const manifest = Buffer.from( - await $`tar -cz package.json bun.lock`.arrayBuffer(), -).toString("base64"); +// Dependencies become an image layer keyed on the lockfile's content, so a sandbox starts with them installed. +const manifest = await Promise.all( + ["package.json", "bun.lock"].map( + async (file) => + `echo ${Buffer.from(await Bun.file(file).arrayBuffer()).toString("base64")} | base64 -d > ${file}`, + ), +); const modal = new ModalClient(); const app = await modal.apps.fromName("openlight-tests", { createIfMissing: true, @@ -27,7 +30,7 @@ const image = modal.images // Without Mesa's Vulkan drivers beside NVIDIA's, Chromium loses its WebGPU instance mid-test. "RUN apt-get update && apt-get install -y --no-install-recommends libvulkan1 mesa-vulkan-drivers unzip && rm -rf /var/lib/apt/lists/*", `RUN curl -fsSL https://bun.sh/install | bash -s bun-v${Bun.version} && ln -s /root/.bun/bin/bun /usr/local/bin/bun`, - `RUN mkdir /repo && cd /repo && echo ${manifest} | base64 -d | tar -xz && bun install --frozen-lockfile`, + `RUN mkdir /repo && cd /repo && ${manifest.join(" && ")} && bun install --frozen-lockfile`, // Modal's GPU sandboxes hand WebGPU to the headless shell build of Chromium only. "ENV CI=1 NVIDIA_DRIVER_CAPABILITIES=all PLAYWRIGHT_CHANNEL=chromium-headless-shell", ]); From 143a21e97b1b6298faab124a3ef9a9b07dde5967 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Thu, 15 Oct 2026 07:29:37 +0000 Subject: [PATCH 4/5] Share the editor page across browser tests Each worker loads the editor once; a test opens its image as a new document, which the editor mounts afresh, instead of reloading the page. Tests that change the page itself, with routes, init scripts, a fake clock, or touch, keep a page of their own, and a failure drops the shared page. Co-Authored-By: Claude Opus 5.5 --- tests/assistant.e2e.ts | 3 + tests/auto-white-balance.e2e.ts | 8 +- tests/brush.e2e.ts | 14 ++-- tests/clarity.e2e.ts | 5 +- tests/crop.e2e.ts | 5 +- tests/draft.e2e.ts | 3 + tests/fixtures.ts | 125 ++++++++++++++++++++++++++++---- tests/grain.e2e.ts | 5 +- tests/heal-edit.e2e.ts | 7 +- tests/heal-settle.e2e.ts | 5 +- tests/heal.e2e.ts | 17 +---- tests/heic.e2e.ts | 4 +- tests/inpaint.e2e.ts | 3 + tests/layout.e2e.ts | 7 +- tests/lut.e2e.ts | 7 +- tests/mask-range.e2e.ts | 5 +- tests/pointer.ts | 2 + tests/range-picker.e2e.ts | 8 +- tests/rendering.e2e.ts | 5 +- tests/vignette.e2e.ts | 5 +- tests/webmcp.e2e.ts | 3 + 21 files changed, 166 insertions(+), 80 deletions(-) diff --git a/tests/assistant.e2e.ts b/tests/assistant.e2e.ts index 5cd43276..4e06beb3 100644 --- a/tests/assistant.e2e.ts +++ b/tests/assistant.e2e.ts @@ -1,5 +1,8 @@ import { expect, test } from "./fixtures"; +// A route stands in for the assistant's server. +test.use({ isolated: true }); + test("the assistant at /?assistant edits the photo it was asked about and selects the layer it edits", async ({ page, }) => { diff --git a/tests/auto-white-balance.e2e.ts b/tests/auto-white-balance.e2e.ts index 408b1fb2..241c4d63 100644 --- a/tests/auto-white-balance.e2e.ts +++ b/tests/auto-white-balance.e2e.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import type { Page } from "@playwright/test"; -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openApp, openImage, test } from "./fixtures"; import { readImage } from "./images"; /** Gray stripes under warm light, as linear sRGB encoded in an SVG; the middle stripe is middle gray. */ @@ -36,8 +36,7 @@ const undoCount = (page: Page) => test("Auto takes most of a warm photo's cast away as one undoable edit, and drops a stale result", async ({ page, }) => { - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "cast.svg", mimeType: "image/svg+xml", buffer: Buffer.from(castWedge()), @@ -79,8 +78,7 @@ test("Auto takes most of a warm photo's cast away as one undoable edit, and drop test("Auto moves a RAW photo's white balance from As Shot toward neutral", async ({ page, }) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); + await openApp(page); const bytes = [...(await readFile("tests/fixtures/raw/bayer.dng"))]; await page.evaluate( (bytes) => diff --git a/tests/brush.e2e.ts b/tests/brush.e2e.ts index 02e163e3..352a93af 100644 --- a/tests/brush.e2e.ts +++ b/tests/brush.e2e.ts @@ -1,4 +1,11 @@ -import { expect, expectNear, expectPixels, openPhoto, test } from "./fixtures"; +import { + expect, + expectNear, + expectPixels, + openImage, + openPhoto, + test, +} from "./fixtures"; import { readImage, redness } from "./images"; import { box, drag } from "./pointer"; @@ -159,10 +166,7 @@ test.describe("on a touch phone", () => { page, context, }) => { - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); await expect( page.getByRole("slider", { name: "Exposure", exact: true }), ).toHaveAttribute("aria-valuetext", "0.00"); diff --git a/tests/clarity.e2e.ts b/tests/clarity.e2e.ts index 52fe7d93..2f621950 100644 --- a/tests/clarity.e2e.ts +++ b/tests/clarity.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, openApp, test } from "./fixtures"; // Full-resolution Gaussian reference, independent of GPU passes and working-space conversion. function expectedStep(x: number, amount: number, sigma: number) { @@ -20,8 +20,7 @@ function expectedStep(x: number, amount: number, sigma: number) { test("detail filters follow Gaussian unsharp masking and preserve flat fields and alpha", async ({ page, }) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); + await openApp(page); const xs = [0, 384, 448, 504, 509, 510, 511, 512, 513, 514, 519, 576, 640]; const settings = [ { clarity: -100, sharpening: 0, sharpenRadius: 1 }, diff --git a/tests/crop.e2e.ts b/tests/crop.e2e.ts index aeec0e7a..15d1cf09 100644 --- a/tests/crop.e2e.ts +++ b/tests/crop.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, expectNear, openPhoto, test } from "./fixtures"; +import { expect, expectNear, openImage, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag, zoom } from "./pointer"; @@ -21,8 +21,7 @@ const keystone = Buffer.from( ); async function openKeystone(page: Page) { - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "keystone.svg", mimeType: "image/svg+xml", buffer: keystone, diff --git a/tests/draft.e2e.ts b/tests/draft.e2e.ts index 3cf61d4a..9c27f3bf 100644 --- a/tests/draft.e2e.ts +++ b/tests/draft.e2e.ts @@ -2,6 +2,9 @@ import type { Page } from "@playwright/test"; import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; +// Reloads find the drafts these tests save, and one removes IndexedDB before the editor loads. +test.use({ isolated: true }); + /** The stored draft's base exposure, read straight from IndexedDB. */ function savedExposure(page: Page) { return page.evaluate( diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 4d0a6534..a56cd7f7 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,4 +1,12 @@ -import { test as base, expect, type Page } from "@playwright/test"; +import { basename } from "node:path"; +import { + type BrowserContext, + test as base, + type ConsoleMessage, + expect, + type Page, + type Route, +} from "@playwright/test"; export { expect }; @@ -20,12 +28,53 @@ export function expectNear( } } +/** Shows the editor, loading it only when the page is not already on it. */ +export async function openApp(page: Page) { + const ready = await page.evaluate( + () => + location.pathname === "/" && !location.search && "openlight" in window, + ); + if (!ready) { + await page.goto("/"); + await page.waitForFunction(() => window.openlight); + } +} + +/** Opens an image as a new document: a fixture path served by the dev server, or a file's contents. */ +export async function openImage( + page: Page, + file: string | { name: string; mimeType: string; buffer: Buffer }, +) { + await openApp(page); + const failure = await page.evaluate( + async ([file]) => { + const api = window.openlight; + if (typeof file === "string") { + await api.loadUrl(`/${file}`); + } else { + await api.loadImage( + new File([new Uint8Array(file.bytes)], file.name, { + type: file.mimeType, + }), + ); + } + return api.getState().failure; + }, + [ + typeof file === "string" + ? file + : { name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] }, + ] as const, + ); + expect( + failure, + `open ${typeof file === "string" ? basename(file) : file.name}`, + ).toBeUndefined(); +} + /** Opens the test photo and waits for its controls. */ export async function openPhoto(page: Page) { - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); await expect( page.getByRole("textbox", { name: "Exposure", exact: true }), ).toHaveValue("0.00"); @@ -43,26 +92,70 @@ export function expectPixels( } } -export const test = base.extend<{ browserErrors: undefined }>({ +/** + * Each worker loads the editor once and its tests share the page: opening an image starts a new + * document, which the editor mounts afresh. A test that changes the page itself, with routes, + * init scripts, a fake clock, or touch, sets `isolated` for a page of its own; a failure drops the + * shared page so the next test starts clean. + */ +export const test = base.extend< + { isolated: boolean; browserErrors: undefined }, + { shared: { context: BrowserContext; page?: Page } } +>({ + isolated: [false, { option: true }], + shared: [ + async ({ browser }, use, workerInfo) => { + const context = await browser.newContext({ + baseURL: workerInfo.project.use.baseURL, + }); + await use({ context }); + await context.close(); + }, + { scope: "worker" }, + ], + page: async ( + { context, viewport, hasTouch, isolated, shared }, + use, + testInfo, + ) => { + if (isolated || hasTouch) { + await use(await context.newPage()); + return; + } + shared.page ??= await shared.context.newPage(); + const page = shared.page; + if (viewport) { + await page.setViewportSize(viewport); + } + await use(page); + if (testInfo.status !== testInfo.expectedStatus) { + shared.page = undefined; + await page.close(); + } + }, browserErrors: [ async ({ page, baseURL }, use) => { const errors: string[] = []; // Tests run offline: a request beyond the dev server fails the test. const origin = new URL(baseURL ?? "").origin; - await page.route( - (url) => url.origin !== origin, - (route) => { - errors.push(`Request outside the app: ${route.request().url()}`); - return route.abort(); - }, - ); - page.on("pageerror", (error) => errors.push(error.message)); - page.on("console", (message) => { + const outside = (url: URL) => url.origin !== origin; + const abort = (route: Route) => { + errors.push(`Request outside the app: ${route.request().url()}`); + return route.abort(); + }; + const pageError = (error: Error) => errors.push(error.message); + const consoleError = (message: ConsoleMessage) => { if (message.type() === "error") { errors.push(message.text()); } - }); + }; + await page.route(outside, abort); + page.on("pageerror", pageError); + page.on("console", consoleError); await use(undefined); + page.off("pageerror", pageError); + page.off("console", consoleError); + await page.unroute(outside, abort); expect(errors).toEqual([]); }, { auto: true }, diff --git a/tests/grain.e2e.ts b/tests/grain.e2e.ts index 81710d9a..436b1535 100644 --- a/tests/grain.e2e.ts +++ b/tests/grain.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, test } from "./fixtures"; +import { expect, openImage, test } from "./fixtures"; /** The exported image's red channel, row by row; grain keeps a gray image gray. */ async function exportRed(page: Page) { @@ -42,8 +42,7 @@ function expectSame(actual: readonly number[], expected: readonly number[]) { test("grain textures a gray image without shifting its tone, holds through a crop, and undoes", async ({ page, }) => { - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "gray.svg", mimeType: "image/svg+xml", buffer: Buffer.from( diff --git a/tests/heal-edit.e2e.ts b/tests/heal-edit.e2e.ts index 487de508..1934dd11 100644 --- a/tests/heal-edit.e2e.ts +++ b/tests/heal-edit.e2e.ts @@ -1,4 +1,4 @@ -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; @@ -6,10 +6,7 @@ test("Remove adds and subtracts separate strokes on the selected patch, on relea page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/spots.svg"); + await openImage(page, "tests/fixtures/spots.svg"); await expect( page.getByRole("textbox", { name: "Exposure", exact: true }), ).toHaveValue("0.00"); diff --git a/tests/heal-settle.e2e.ts b/tests/heal-settle.e2e.ts index bd63988d..2c54f5bf 100644 --- a/tests/heal-settle.e2e.ts +++ b/tests/heal-settle.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, openImage, test } from "./fixtures"; import { box } from "./pointer"; for (const gesture of ["stroke", "destination"] as const) { @@ -6,8 +6,7 @@ for (const gesture of ["stroke", "destination"] as const) { page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "small.svg", mimeType: "image/svg+xml", buffer: Buffer.from( diff --git a/tests/heal.e2e.ts b/tests/heal.e2e.ts index 9af21b9c..190243d6 100644 --- a/tests/heal.e2e.ts +++ b/tests/heal.e2e.ts @@ -1,4 +1,4 @@ -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; import { box, drag } from "./pointer"; @@ -56,10 +56,7 @@ test("Heal corrects donor color and Clone copies it, across an edge, with HDR, a test("Healing paints patches, edits them, and undoes", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); await expect( page.getByRole("textbox", { name: "Exposure", exact: true }), ).toHaveValue("0.00"); @@ -429,10 +426,7 @@ test("Healing paints patches, edits them, and undoes", async ({ page }) => { test("Healing takes the first stroke after selecting Heal and finds donors inside a mask", async ({ page, }) => { - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); await expect( page.getByRole("textbox", { name: "Exposure", exact: true }), ).toHaveValue("0.00"); @@ -493,10 +487,7 @@ test("retouch modes cycle, preserve each family's brush, and keep patches indepe page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); await expect( page.getByRole("textbox", { name: "Exposure", exact: true }), ).toHaveValue("0.00"); diff --git a/tests/heic.e2e.ts b/tests/heic.e2e.ts index 29871c28..15094326 100644 --- a/tests/heic.e2e.ts +++ b/tests/heic.e2e.ts @@ -1,8 +1,8 @@ -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openApp, test } from "./fixtures"; import { readImage } from "./images"; test("a HEIC image opens with its size and pixels", async ({ page }) => { - await page.goto("/"); + await openApp(page); const supported = await page.evaluate( async () => "VideoDecoder" in window && diff --git a/tests/inpaint.e2e.ts b/tests/inpaint.e2e.ts index 99f2a543..9673faca 100644 --- a/tests/inpaint.e2e.ts +++ b/tests/inpaint.e2e.ts @@ -2,6 +2,9 @@ import { expect, expectNear, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; +// The first test fakes the page's clock. +test.use({ isolated: true }); + test("Remove previews each frame, solves on release, exports, undoes, cancels, and moves on drop", async ({ page, }) => { diff --git a/tests/layout.e2e.ts b/tests/layout.e2e.ts index 02381383..e90511b2 100644 --- a/tests/layout.e2e.ts +++ b/tests/layout.e2e.ts @@ -1,4 +1,4 @@ -import { expect, openPhoto, test } from "./fixtures"; +import { expect, openImage, openPhoto, test } from "./fixtures"; import { box } from "./pointer"; test("edit on a phone and carry the canvas across the breakpoint", async ({ @@ -51,10 +51,7 @@ test.describe("on a touch phone", () => { page, context, }) => { - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); const exposure = page.getByRole("slider", { name: "Exposure", exact: true, diff --git a/tests/lut.e2e.ts b/tests/lut.e2e.ts index 9a84b213..77aa92e9 100644 --- a/tests/lut.e2e.ts +++ b/tests/lut.e2e.ts @@ -1,4 +1,4 @@ -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; test("a LUT layer grades the photo from a .cube file at the layer's opacity, undoes, and saves with the scene", async ({ @@ -6,10 +6,7 @@ test("a LUT layer grades the photo from a .cube file at the layer's opacity, und }) => { test.setTimeout(90_000); await page.setViewportSize({ width: 1440, height: 1000 }); - await page.goto("/"); - await page - .locator('input[type="file"]') - .setInputFiles("tests/fixtures/photo.svg"); + await openImage(page, "tests/fixtures/photo.svg"); const exposure = page.getByRole("textbox", { name: "Exposure", exact: true }); await expect(exposure).toHaveValue("0.00"); // The blue patch, #305080, and the gray middle. diff --git a/tests/mask-range.e2e.ts b/tests/mask-range.e2e.ts index a6aed390..0ad72ace 100644 --- a/tests/mask-range.e2e.ts +++ b/tests/mask-range.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; @@ -14,8 +14,7 @@ const quadrants = Buffer.from( ); async function open(page: Page) { - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "quadrants.svg", mimeType: "image/svg+xml", buffer: quadrants, diff --git a/tests/pointer.ts b/tests/pointer.ts index f3b9dbda..0031e398 100644 --- a/tests/pointer.ts +++ b/tests/pointer.ts @@ -31,4 +31,6 @@ export async function zoom(page: Page, factor: number) { export async function choose(page: Page, select: Locator, option: string) { await select.click(); await page.getByRole("option", { name: option, exact: true }).click(); + // Back on the select, the pointer leaves no tooltip open where the list was to take the next Escape. + await select.hover(); } diff --git a/tests/range-picker.e2e.ts b/tests/range-picker.e2e.ts index 73ccb563..a107dd65 100644 --- a/tests/range-picker.e2e.ts +++ b/tests/range-picker.e2e.ts @@ -1,8 +1,11 @@ import type { Page } from "@playwright/test"; -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; import { box } from "./pointer"; +// An init script holds the picker's GPU readbacks until a test releases them. +test.use({ isolated: true }); + declare global { interface Window { rangeSampleGate: { @@ -65,8 +68,7 @@ async function open(page: Page, buffer = photo) { }); }; }); - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "orange.svg", mimeType: "image/svg+xml", buffer, diff --git a/tests/rendering.e2e.ts b/tests/rendering.e2e.ts index d1d69814..db44f789 100644 --- a/tests/rendering.e2e.ts +++ b/tests/rendering.e2e.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openApp, test } from "./fixtures"; function linear(value: number) { const encoded = value / 255; @@ -11,8 +11,7 @@ function linear(value: number) { test("tone adjustments preserve ramps, colors and alpha, compose, and reset", async ({ page, }) => { - await page.goto("/"); - await page.waitForFunction(() => window.openlight); + await openApp(page); const bytes = await readFile("tests/fixtures/tones.png"); const result = await page.evaluate( async (bytes) => { diff --git a/tests/vignette.e2e.ts b/tests/vignette.e2e.ts index 2d7d9903..5c9ff328 100644 --- a/tests/vignette.e2e.ts +++ b/tests/vignette.e2e.ts @@ -1,11 +1,10 @@ -import { expect, expectNear, test } from "./fixtures"; +import { expect, expectNear, openImage, test } from "./fixtures"; import { readImage } from "./images"; test("vignette darkens a white image's edges, softens, undoes and disables", async ({ page, }) => { - await page.goto("/"); - await page.locator('input[type="file"]').setInputFiles({ + await openImage(page, { name: "white.svg", mimeType: "image/svg+xml", buffer: Buffer.from( diff --git a/tests/webmcp.e2e.ts b/tests/webmcp.e2e.ts index 0e7bb3dc..6c4c41ec 100644 --- a/tests/webmcp.e2e.ts +++ b/tests/webmcp.e2e.ts @@ -2,6 +2,9 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./fixtures"; import { readImage } from "./images"; +// A route serves the photo from another origin. +test.use({ isolated: true }); + /** Calls a tool as a browser agent does, with its input as JSON. */ function callTool(page: Page, name: string, input: object = {}) { return page.evaluate( From bc155d87c2c6e0f615ed5523ccce96f71fb662b6 Mon Sep 17 00:00:00 2001 From: Rodrigo Belfiore Date: Thu, 15 Oct 2026 07:32:51 +0000 Subject: [PATCH 5/5] Note the shared editor page for browser tests Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 69de0a70..31c9a8b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Frame rate and low memory are product requirements, on phones as much as desktop ## Tests -Tests follow what a person verifies: open an image, use a tool, undo, export, check the output. Add one when a workflow or its processing changes or a regression is fixed; core algorithms may have unit tests. Layout, styling, and copy are checked by looking at the app. Tests run offline: the browser fixture fails any request beyond the dev server. +Tests follow what a person verifies: open an image, use a tool, undo, export, check the output. Add one when a workflow or its processing changes or a regression is fixed; core algorithms may have unit tests. Layout, styling, and copy are checked by looking at the app. Tests run offline: the browser fixture fails any request beyond the dev server. Browser tests share one editor page per worker: open images with `openImage` or `openPhoto` rather than reloading, and set `isolated` for a test that changes the page itself. Browser tests pass on any GPU: GPUs round filtering, `pow`, and half floats differently. Check pixels as a person would, with `expectNear`, directions, and outcomes rather than exact bytes or golden constants; read image content from the export and the preview only for what only it shows.