{"owner":"sanity-io","repo":"sanity","hasSkills":true,"totalSkillsCount":195,"totalTokensCount":313206,"categories":["anthropic-skill","root-instruction","claude-rule","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/before-and-after/SKILL.md",".agents/skills/code-review-and-quality/SKILL.md",".agents/skills/code-simplification/SKILL.md",".agents/skills/deslop/SKILL.md",".agents/skills/find-skills/SKILL.md",".agents/skills/grill-me/SKILL.md",".agents/skills/improve-codebase-architecture/REFERENCE.md",".agents/skills/improve-codebase-architecture/SKILL.md",".agents/skills/migrate-styled-components-to-panda-css/SKILL.md",".agents/skills/performance-optimization/SKILL.md",".agents/skills/playwright-best-practices/LICENSE.md",".agents/skills/playwright-best-practices/README.md",".agents/skills/playwright-best-practices/SKILL.md",".agents/skills/playwright-best-practices/advanced/authentication-flows.md",".agents/skills/playwright-best-practices/advanced/authentication.md",".agents/skills/playwright-best-practices/advanced/clock-mocking.md",".agents/skills/playwright-best-practices/advanced/mobile-testing.md",".agents/skills/playwright-best-practices/advanced/multi-context.md",".agents/skills/playwright-best-practices/advanced/multi-user.md",".agents/skills/playwright-best-practices/advanced/network-advanced.md",".agents/skills/playwright-best-practices/advanced/third-party.md",".agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md",".agents/skills/playwright-best-practices/architecture/test-architecture.md",".agents/skills/playwright-best-practices/architecture/when-to-mock.md",".agents/skills/playwright-best-practices/browser-apis/browser-apis.md",".agents/skills/playwright-best-practices/browser-apis/iframes.md",".agents/skills/playwright-best-practices/browser-apis/service-workers.md",".agents/skills/playwright-best-practices/browser-apis/websockets.md",".agents/skills/playwright-best-practices/core/annotations.md",".agents/skills/playwright-best-practices/core/assertions-waiting.md",".agents/skills/playwright-best-practices/core/configuration.md",".agents/skills/playwright-best-practices/core/fixtures-hooks.md",".agents/skills/playwright-best-practices/core/global-setup.md",".agents/skills/playwright-best-practices/core/locators.md",".agents/skills/playwright-best-practices/core/page-object-model.md",".agents/skills/playwright-best-practices/core/projects-dependencies.md",".agents/skills/playwright-best-practices/core/test-data.md",".agents/skills/playwright-best-practices/core/test-suite-structure.md",".agents/skills/playwright-best-practices/core/test-tags.md",".agents/skills/playwright-best-practices/debugging/console-errors.md",".agents/skills/playwright-best-practices/debugging/debugging.md",".agents/skills/playwright-best-practices/debugging/error-testing.md",".agents/skills/playwright-best-practices/debugging/flaky-tests.md",".agents/skills/playwright-best-practices/frameworks/angular.md",".agents/skills/playwright-best-practices/frameworks/nextjs.md",".agents/skills/playwright-best-practices/frameworks/react.md",".agents/skills/playwright-best-practices/frameworks/vue.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md",".agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md",".agents/skills/playwright-best-practices/testing-patterns/accessibility.md",".agents/skills/playwright-best-practices/testing-patterns/api-testing.md",".agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md",".agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md",".agents/skills/playwright-best-practices/testing-patterns/component-testing.md",".agents/skills/playwright-best-practices/testing-patterns/drag-drop.md",".agents/skills/playwright-best-practices/testing-patterns/electron.md",".agents/skills/playwright-best-practices/testing-patterns/file-operations.md",".agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md",".agents/skills/playwright-best-practices/testing-patterns/forms-validation.md",".agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md",".agents/skills/playwright-best-practices/testing-patterns/i18n.md",".agents/skills/playwright-best-practices/testing-patterns/performance-testing.md",".agents/skills/playwright-best-practices/testing-patterns/security-testing.md",".agents/skills/playwright-best-practices/testing-patterns/visual-regression.md",".agents/skills/playwright-cli/SKILL.md",".agents/skills/playwright-cli/references/request-mocking.md",".agents/skills/playwright-cli/references/running-code.md",".agents/skills/playwright-cli/references/session-management.md",".agents/skills/playwright-cli/references/storage-state.md",".agents/skills/playwright-cli/references/test-generation.md",".agents/skills/playwright-cli/references/tracing.md",".agents/skills/playwright-cli/references/video-recording.md",".agents/skills/pr-description/SKILL.md",".agents/skills/react-devtools/SKILL.md",".agents/skills/react-devtools/references/commands.md",".agents/skills/react-devtools/references/profiling-guide.md",".agents/skills/react-devtools/references/setup.md",".agents/skills/react-rx-hook-audit/SKILL.md",".agents/skills/rxjs-like-a-pro/SKILL.md",".agents/skills/rxjs-like-a-pro/references/custom-operators.md",".agents/skills/rxjs-like-a-pro/references/inner-observable-chains.md",".agents/skills/rxjs-like-a-pro/references/loading-state-patterns.md",".agents/skills/rxjs-like-a-pro/references/massive-observable.md",".agents/skills/sanity-bench/SKILL.md",".agents/skills/sanity-config-reducers/SKILL.md",".agents/skills/sanity-default-plugins/SKILL.md",".agents/skills/sanity-i18n-translate/SKILL.md",".agents/skills/sanity-plugin-authoring/SKILL.md",".agents/skills/sanity-radar-investigate/SKILL.md",".agents/skills/sanity-radar/SKILL.md",".agents/skills/sanity-radar/references/groq-recipes.md",".agents/skills/sanity-tsdown-config/SKILL.md",".agents/skills/sanity-ui-migration-progress/SKILL.md",".agents/skills/sanity-visual-coverage/SKILL.md",".agents/skills/sanity-visual-regression/REFERENCE.md",".agents/skills/sanity-visual-regression/SKILL.md",".agents/skills/stories/SKILL.md",".agents/skills/storybook-init/SKILL.md",".agents/skills/storybook-setup/SKILL.md",".agents/skills/storybook-upgrade/SKILL.md",".agents/skills/tdd/SKILL.md",".agents/skills/tdd/deep-modules.md",".agents/skills/tdd/interface-design.md",".agents/skills/tdd/mocking.md",".agents/skills/tdd/refactoring.md",".agents/skills/tdd/tests.md",".agents/skills/vercel-react-best-practices/AGENTS.md",".agents/skills/vercel-react-best-practices/SKILL.md",".agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md",".agents/skills/vercel-react-best-practices/rules/advanced-init-once.md",".agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md",".agents/skills/vercel-react-best-practices/rules/async-api-routes.md",".agents/skills/vercel-react-best-practices/rules/async-defer-await.md",".agents/skills/vercel-react-best-practices/rules/async-dependencies.md",".agents/skills/vercel-react-best-practices/rules/async-parallel.md",".agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md",".agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md",".agents/skills/vercel-react-best-practices/rules/bundle-conditional.md",".agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md",".agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md",".agents/skills/vercel-react-best-practices/rules/bundle-preload.md",".agents/skills/vercel-react-best-practices/rules/client-event-listeners.md",".agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md",".agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md",".agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md",".agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md",".agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md",".agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md",".agents/skills/vercel-react-best-practices/rules/js-cache-storage.md",".agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md",".agents/skills/vercel-react-best-practices/rules/js-early-exit.md",".agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md",".agents/skills/vercel-react-best-practices/rules/js-index-maps.md",".agents/skills/vercel-react-best-practices/rules/js-length-check-first.md",".agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md",".agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md",".agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md",".agents/skills/vercel-react-best-practices/rules/rendering-activity.md",".agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md",".agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md",".agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md",".agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md",".agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md",".agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md",".agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md",".agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md",".agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md",".agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md",".agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md",".agents/skills/vercel-react-best-practices/rules/rerender-memo.md",".agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md",".agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md",".agents/skills/vercel-react-best-practices/rules/rerender-transitions.md",".agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md",".agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md",".agents/skills/vercel-react-best-practices/rules/server-auth-actions.md",".agents/skills/vercel-react-best-practices/rules/server-cache-lru.md",".agents/skills/vercel-react-best-practices/rules/server-cache-react.md",".agents/skills/vercel-react-best-practices/rules/server-dedup-props.md",".agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md",".agents/skills/vercel-react-best-practices/rules/server-serialization.md",".agents/skills/write-a-skill/SKILL.md","AGENTS.md","CLAUDE.md","packages/@repo/ailf/README.md","packages/@repo/debug-proxy/README.md","packages/@repo/pandacss/README.md","packages/@repo/release-notes/README.md","packages/@sanity/diff/README.md","packages/@sanity/mutator/README.md","packages/@sanity/schema/README.md","packages/@sanity/schema/src/descriptors/README.md","packages/@sanity/util/README.md","packages/@sanity/validation/README.md","packages/@sanity/vision/README.md","packages/groq/README.md","packages/sanity/AGENTS.md","packages/sanity/README.md","packages/sanity/src/core/error/sentry/README.md","packages/sanity/src/core/scheduled-publishing/README.md","packages/sanity/src/core/scheduled-publishing/components/dateInputs/README.md","packages/sanity/src/core/store/events/README.md","packages/sanity/src/core/variants/README.md","packages/sanity/src/router/README.md"],"skills":{".agents/skills/before-and-after/SKILL.md":"---\nname: before-and-after\ndescription: Add existing screenshots or screen recordings to a GitHub pull request as a before/after or preview block. Use when a PR needs visual media attached to its description. Browser navigation and capture belong to agent-browser.\n---\n\n# Add visual media to a PR\n\nUse `agent-browser` to create the screenshots or recordings. This skill only owns the GitHub PR attachment workflow.\n\n## Capture\n\n1. Load the version-matched core instructions with `agent-browser skills get core --full` and follow them for sessions, navigation, page state, screenshots, and recordings.\n2. If a Vercel URL is protected, load `agent-browser skills get protected-vercel-deployments --full`. Do not reproduce its authentication workflow here.\n3. Save media under the repository with paths that contain no whitespace, for example:\n\n   ```text\n   captures/desktop-before.png\n   captures/desktop-after.png\n   captures/mobile-before.png\n   captures/mobile-after.png\n   ```\n\nThe formatter supports PNG, JPEG, GIF, WebP, MP4, MOV, and WebM files. Use an `--after` file without a matching `--before` file for a net-new preview.\n\n### Screen recordings\n\n`agent-browser record start` creates a fresh browser context. It preserves cookies and local storage, but an origin-scoped header used to open a protected Vercel Preview may not carry into that new context. Before recording a protected Preview:\n\n1. Load `agent-browser skills get protected-vercel-deployments --full`.\n2. Start the recording context on a blank page.\n3. Apply the authentication method from that skill inside the recording context.\n4. Navigate to the Preview only after authentication is active, then trim the navigation lead-in if necessary.\n\nDo not assume that a page which worked before `record start` will remain authenticated after it.\n\nAgent-browser `0.35.2` and `0.36.0` capture at a hardcoded 10 fps with no CLI or environment override. Inspect the installed version and its recording reference rather than assuming this stays true in later releases. Preserve the source cadence when transcoding: changing the container to 30 or 60 fps only duplicates frames and does not make motion smoother. For animation evidence that requires a higher real frame rate, use a genuinely higher-cadence capture path instead of upsampling agent-browser output.\n\n### Equal-height image pairs\n\nGitHub vertically centers a shorter image inside a Markdown table cell. For full-page before/after screenshots, make both files the same pixel height so their top edges align:\n\n1. Open both pages at the same viewport and state.\n2. Read `document.documentElement.scrollHeight` in both sessions.\n3. Append bottom-only space to the shorter page until both scroll heights match, then take both `--full` screenshots.\n\nThe padding may be transparent or use the capture tool's default canvas. Never add space above the page. For component or section comparisons, capture the same scoped region instead of padding unrelated page content.\n\nUse raw `agent-browser eval` for this DOM-only adjustment; do not add an image-processing dependency to this skill. Confirm the resulting files have equal pixel dimensions before publishing.\n\n## Format\n\nPass one `--before` and `--after` pair for each comparison. Repeat `--label` to identify multiple pairs:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  --before captures/mobile-before.png \\\n  --after captures/mobile-after.png \\\n  --label Desktop \\\n  --label Mobile \\\n  > /tmp/before-and-after.md\n```\n\nFor an after-only preview:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --after captures/new-page.png \\\n  > /tmp/before-and-after.md\n```\n\nAdd `--attribution \"<name>\"` to prefix the block with a `> Before/after by <name>` line when the PR should credit who produced the evidence.\n\nImages render in tables. Local videos initially render on their own lines so `gh --attach` can upload them and expose their final attachment URLs. Before/after video tables use the two-step workflow below because `gh --attach` does not rewrite local references inside `<video src>` attributes.\n\n## Place the evidence\n\nRead the existing PR description before inserting a new marked block. Put visual evidence near the top, after the short opening context and an existing Preview or deployment-link section when present, but before implementation-heavy sections such as Details, Changes, Testing, or Notes.\n\nUse this reading order inside the visual evidence:\n\n1. Put the real before/after or Preview evidence that proves the PR first.\n2. Put supplemental formats, alternate states, or demonstrations after the primary evidence.\n3. Label anything that demonstrates this skill rather than the PR itself as a demo, and state material limitations beside it. For example, note the installed agent-browser recorder's current 10 fps limit when motion smoothness matters.\n\nHeadings are semantic hints, not required names. Never invent or rewrite prose merely to create an anchor, and never split a paragraph, list, table, code block, or other Markdown structure. If no safe anchor is clear, append the block rather than risking damage. If a marked block already exists, move or replace that whole block only; preserve every byte of unrelated prose.\n\nAfter publishing, open the rendered PR and confirm the primary evidence appears before supplemental demos and before the implementation details.\n\n## Publish\n\nPreserve the existing PR description and replace only this skill's marked block:\n\n```bash\nPR=123\ngh pr view \"$PR\" --json body --jq .body > /tmp/pr-body.md\n\nnode skill/scripts/format.mjs \\\n  --body-file /tmp/pr-body.md \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  > /tmp/pr-body-next.md\n\nATTACH_ARGS=()\nwhile IFS= read -r file; do\n  ATTACH_ARGS+=(--attach \"$file\")\ndone < <(\n  node skill/scripts/format.mjs \\\n    --attach-list \\\n    --before captures/desktop-before.png \\\n    --after captures/desktop-after.png\n)\n\ngh pr edit \"$PR\" --body-file /tmp/pr-body-next.md \"${ATTACH_ARGS[@]}\"\n```\n\nRun the formatter and `gh` from the same directory. `gh --attach` uploads the local files to GitHub and rewrites their matching local references in the PR body.\n\nAfter publishing, fetch or open the PR description and confirm that no `./captures/...` references remain inside the marked block and that the evidence appears in the intended reading order.\n\n### Publish a video table\n\nVideo comparisons are a first-class two-step publish operation:\n\n1. Upload the local videos in a temporary PR comment using the normal own-line output and `gh pr comment --attach`.\n2. Fetch that comment through `gh api` and collect the stable `https://github.com/user-attachments/assets/...` URLs in before/after order.\n3. Generate the final HTML table from those URLs and replace the marked PR block:\n\n   ```bash\n   node skill/scripts/format.mjs \\\n     --body-file /tmp/pr-body.md \\\n     --before-video-url https://github.com/user-attachments/assets/BEFORE_ID \\\n     --after-video-url https://github.com/user-attachments/assets/AFTER_ID \\\n     --label \"Desktop hero\" \\\n     > /tmp/pr-body-next.md\n\n   gh pr edit \"$PR\" --body-file /tmp/pr-body-next.md\n   ```\n\n4. Fetch the edited PR body before deleting the temporary comment. Confirm both final URLs are present and no local video paths remain, then delete the comment.\n5. Open the rendered PR and confirm both `<video>` elements are inside the comparison table, reach a playable ready state, and show controls.\n\nIf URL extraction, formatting, or PR verification fails, keep the temporary comment so its uploaded attachments remain recoverable and retry from the last successful phase. Use own-line videos as the simple fallback.\n\nDo not publish captures containing Vercel OIDC tokens, bypass secrets, authenticated query parameters, or browser state files.\n\n## Script contract\n\n`scripts/format.mjs` is intentionally the only bundled script. It:\n\n- formats existing local media;\n- formats final GitHub video attachment URLs as HTML comparison tables;\n- labels after-only media as `Preview`;\n- emits the exact attachment path list;\n- inserts or replaces `<!-- before-and-after:start/end -->` without changing other PR prose.\n\nIts arguments version with this skill and are not a public library API.\n",".agents/skills/code-review-and-quality/SKILL.md":"---\nname: code-review-and-quality\ndescription: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.\n---\n\n# Code Review and Quality\n\n## Overview\n\nMulti-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.\n\n**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.\n\n## When to Use\n\n- Before merging any PR or change\n- After completing a feature implementation\n- When another agent or model produced code you need to evaluate\n- When refactoring existing code\n- After any bug fix (review both the fix and the regression test)\n\n## The Five-Axis Review\n\nEvery review evaluates code across these dimensions:\n\n### 1. Correctness\n\nDoes the code do what it claims to do?\n\n- Does it match the spec or task requirements?\n- Are edge cases handled (null, empty, boundary values)?\n- Are error paths handled (not just the happy path)?\n- Does it pass all tests? Are the tests actually testing the right things?\n- Are there off-by-one errors, race conditions, or state inconsistencies?\n\n### 2. Readability & Simplicity\n\nCan another engineer (or agent) understand this code without the author explaining it?\n\n- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)\n- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?\n- Is the code organized logically (related code grouped, clear module boundaries)?\n- Are there any \"clever\" tricks that should be simplified?\n- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)\n- **Are abstractions earning their complexity?** (Don't generalize until the third use case)\n- Would comments help clarify non-obvious intent? (But don't comment obvious code.)\n- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?\n\n### 3. Architecture\n\nDoes the change fit the system's design?\n\n- Does it follow existing patterns or introduce a new one? If new, is it justified?\n- Does it maintain clean module boundaries?\n- Is there code duplication that should be shared?\n- Are dependencies flowing in the right direction (no circular dependencies)?\n- Is the abstraction level appropriate (not over-engineered, not too coupled)?\n\n### 4. Security\n\nFor detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?\n\n- Is user input validated and sanitized?\n- Are secrets kept out of code, logs, and version control?\n- Is authentication/authorization checked where needed?\n- Are SQL queries parameterized (no string concatenation)?\n- Are outputs encoded to prevent XSS?\n- Are dependencies from trusted sources with no known vulnerabilities?\n- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?\n- Are external data flows validated at system boundaries before use in logic or rendering?\n\n### 5. Performance\n\nFor detailed profiling and optimization, see `performance-optimization`. Does the change introduce performance problems?\n\n- Any N+1 query patterns?\n- Any unbounded loops or unconstrained data fetching?\n- Any synchronous operations that should be async?\n- Any unnecessary re-renders in UI components?\n- Any missing pagination on list endpoints?\n- Any large objects created in hot paths?\n\n## Change Sizing\n\nSmall, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:\n\n```\n~100 lines changed   → Good. Reviewable in one sitting.\n~300 lines changed   → Acceptable if it's a single logical change.\n~1000 lines changed  → Too large. Split it.\n```\n\n**What counts as \"one change\":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.\n\n**Splitting strategies when a change is too large:**\n\n| Strategy          | How                                                     | When                    |\n| ----------------- | ------------------------------------------------------- | ----------------------- |\n| **Stack**         | Submit a small change, start the next one based on it   | Sequential dependencies |\n| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns  |\n| **Horizontal**    | Create shared code/stubs first, then consumers          | Layered architecture    |\n| **Vertical**      | Break into smaller full-stack slices of the feature     | Feature work            |\n\n**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.\n\n**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.\n\n## Change Descriptions\n\nEvery change needs a description that stands alone in version control history.\n\n**First line:** Short, imperative, standalone. \"Delete the FizzBuzz RPC\" not \"Deleting the FizzBuzz RPC.\" Must be informative enough that someone searching history can understand the change without reading the diff.\n\n**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.\n\n**Anti-patterns:** \"Fix bug,\" \"Fix build,\" \"Add patch,\" \"Moving code from A to B,\" \"Phase 1,\" \"Add convenience functions.\"\n\n## Review Process\n\n### Step 1: Understand the Context\n\nBefore looking at code, understand the intent:\n\n```\n- What is this change trying to accomplish?\n- What spec or task does it implement?\n- What is the expected behavior change?\n```\n\n### Step 2: Review the Tests First\n\nTests reveal intent and coverage:\n\n```\n- Do tests exist for the change?\n- Do they test behavior (not implementation details)?\n- Are edge cases covered?\n- Do tests have descriptive names?\n- Would the tests catch a regression if the code changed?\n```\n\n### Step 3: Review the Implementation\n\nWalk through the code with the five axes in mind:\n\n```\nFor each file changed:\n1. Correctness: Does this code do what the test says it should?\n2. Readability: Can I understand this without help?\n3. Architecture: Does this fit the system?\n4. Security: Any vulnerabilities?\n5. Performance: Any bottlenecks?\n```\n\n### Step 4: Categorize Findings\n\nLabel every comment with its severity so the author knows what's required vs optional:\n\n| Prefix                        | Meaning            | Author Action                                           |\n| ----------------------------- | ------------------ | ------------------------------------------------------- |\n| _(no prefix)_                 | Required change    | Must address before merge                               |\n| **Critical:**                 | Blocks merge       | Security vulnerability, data loss, broken functionality |\n| **Nit:**                      | Minor, optional    | Author may ignore — formatting, style preferences       |\n| **Optional:** / **Consider:** | Suggestion         | Worth considering but not required                      |\n| **FYI**                       | Informational only | No action needed — context for future reference         |\n\nThis prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.\n\n### Step 5: Verify the Verification\n\nCheck the author's verification story:\n\n```\n- What tests were run?\n- Did the build pass?\n- Was the change tested manually?\n- Are there screenshots for UI changes?\n- Is there a before/after comparison?\n```\n\n## Multi-Model Review Pattern\n\nUse different models for different review perspectives:\n\n```\nModel A writes the code\n    │\n    ▼\nModel B reviews for correctness and architecture\n    │\n    ▼\nModel A addresses the feedback\n    │\n    ▼\nHuman makes the final call\n```\n\nThis catches issues that a single model might miss — different models have different blind spots.\n\n**Example prompt for a review agent:**\n\n```\nReview this code change for correctness, security, and adherence to\nour project conventions. The spec says [X]. The change should [Y].\nFlag any issues as Critical, Important, or Suggestion.\n```\n\n## Dead Code Hygiene\n\nAfter any refactoring or implementation change, check for orphaned code:\n\n1. Identify code that is now unreachable or unused\n2. List it explicitly\n3. **Ask before deleting:** \"Should I remove these now-unused elements: [list]?\"\n\nDon't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.\n\n```\nDEAD CODE IDENTIFIED:\n- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()\n- OldTaskCard component in src/components/ — replaced by TaskCard\n- LEGACY_API_URL constant in src/config.ts — no remaining references\n→ Safe to remove these?\n```\n\n## Review Speed\n\nSlow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.\n\n- **Respond within one business day** — this is the maximum, not the target\n- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day\n- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed\n- **Large changes:** Ask the author to split them rather than reviewing one massive changeset\n\n## Handling Disagreements\n\nWhen resolving review disputes, apply this hierarchy:\n\n1. **Technical facts and data** override opinions and preferences\n2. **Style guides** are the absolute authority on style matters\n3. **Software design** must be evaluated on engineering principles, not personal preference\n4. **Codebase consistency** is acceptable if it doesn't degrade overall health\n\n**Don't accept \"I'll clean it up later.\"** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.\n\n## Honesty in Review\n\nWhen reviewing code — whether written by you, another agent, or a human:\n\n- **Don't rubber-stamp.** \"LGTM\" without evidence of review helps no one.\n- **Don't soften real issues.** \"This might be a minor concern\" when it's a bug that will hit production is dishonest.\n- **Quantify problems when possible.** \"This N+1 query will add ~50ms per item in the list\" is better than \"this could be slow.\"\n- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.\n- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.\n\n## Dependency Discipline\n\nPart of code review is dependency review:\n\n**Before adding any dependency:**\n\n1. Does the existing stack solve this? (Often it does.)\n2. How large is the dependency? (Check bundle impact.)\n3. Is it actively maintained? (Check last commit, open issues.)\n4. Does it have known vulnerabilities? (`npm audit`)\n5. What's the license? (Must be compatible with the project.)\n\n**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.\n\n## The Review Checklist\n\n```markdown\n## Review: [PR/Change title]\n\n### Context\n\n- [ ] I understand what this change does and why\n\n### Correctness\n\n- [ ] Change matches spec/task requirements\n- [ ] Edge cases handled\n- [ ] Error paths handled\n- [ ] Tests cover the change adequately\n\n### Readability\n\n- [ ] Names are clear and consistent\n- [ ] Logic is straightforward\n- [ ] No unnecessary complexity\n\n### Architecture\n\n- [ ] Follows existing patterns\n- [ ] No unnecessary coupling or dependencies\n- [ ] Appropriate abstraction level\n\n### Security\n\n- [ ] No secrets in code\n- [ ] Input validated at boundaries\n- [ ] No injection vulnerabilities\n- [ ] Auth checks in place\n- [ ] External data sources treated as untrusted\n\n### Performance\n\n- [ ] No N+1 patterns\n- [ ] No unbounded operations\n- [ ] Pagination on list endpoints\n\n### Verification\n\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] Manual verification done (if applicable)\n\n### Verdict\n\n- [ ] **Approve** — Ready to merge\n- [ ] **Request changes** — Issues must be addressed\n```\n\n## See Also\n\n- For detailed security review guidance, see `references/security-checklist.md`\n- For performance review checks, see `references/performance-checklist.md`\n\n## Common Rationalizations\n\n| Rationalization                      | Reality                                                                                                                   |\n| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |\n| \"It works, that's good enough\"       | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds.                           |\n| \"I wrote it, so I know it's correct\" | Authors are blind to their own assumptions. Every change benefits from another set of eyes.                               |\n| \"We'll clean it up later\"            | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after.                      |\n| \"AI-generated code is probably fine\" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong.                                     |\n| \"The tests pass, so it's good\"       | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |\n\n## Red Flags\n\n- PRs merged without any review\n- Review that only checks if tests pass (ignoring other axes)\n- \"LGTM\" without evidence of actual review\n- Security-sensitive changes without security-focused review\n- Large PRs that are \"too big to review properly\" (split them)\n- No regression tests with bug fix PRs\n- Review comments without severity labels — makes it unclear what's required vs optional\n- Accepting \"I'll fix it later\" — it never happens\n\n## Verification\n\nAfter review is complete:\n\n- [ ] All Critical issues are resolved\n- [ ] All Important issues are resolved or explicitly deferred with justification\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] The verification story is documented (what changed, how it was verified)\n",".agents/skills/code-simplification/SKILL.md":"---\nname: code-simplification\ndescription: Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity.\n---\n\n# Code Simplification\n\n> Inspired by the [Claude Code Simplifier plugin](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/code-simplifier/agents/code-simplifier.md). Adapted here as a model-agnostic, process-driven skill for any AI coding agent.\n\n## Overview\n\nSimplify code by reducing complexity while preserving exact behavior. The goal is not fewer lines — it's code that is easier to read, understand, modify, and debug. Every simplification must pass a simple test: \"Would a new team member understand this faster than the original?\"\n\n## When to Use\n\n- After a feature is working and tests pass, but the implementation feels heavier than it needs to be\n- During code review when readability or complexity issues are flagged\n- When you encounter deeply nested logic, long functions, or unclear names\n- When refactoring code written under time pressure\n- When consolidating related logic scattered across files\n- After merging changes that introduced duplication or inconsistency\n\n**When NOT to use:**\n\n- Code is already clean and readable — don't simplify for the sake of it\n- You don't understand what the code does yet — comprehend before you simplify\n- The code is performance-critical and the \"simpler\" version would be measurably slower\n- You're about to rewrite the module entirely — simplifying throwaway code wastes effort\n\n## The Five Principles\n\n### 1. Preserve Behavior Exactly\n\nDon't change what the code does — only how it expresses it. All inputs, outputs, side effects, error behavior, and edge cases must remain identical. If you're not sure a simplification preserves behavior, don't make it.\n\n```\nASK BEFORE EVERY CHANGE:\n→ Does this produce the same output for every input?\n→ Does this maintain the same error behavior?\n→ Does this preserve the same side effects and ordering?\n→ Do all existing tests still pass without modification?\n```\n\n### 2. Follow Project Conventions\n\nSimplification means making code more consistent with the codebase, not imposing external preferences. Before simplifying:\n\n```\n1. Read CLAUDE.md / project conventions\n2. Study how neighboring code handles similar patterns\n3. Match the project's style for:\n   - Import ordering and module system\n   - Function declaration style\n   - Naming conventions\n   - Error handling patterns\n   - Type annotation depth\n```\n\nSimplification that breaks project consistency is not simplification — it's churn.\n\n### 3. Prefer Clarity Over Cleverness\n\nExplicit code is better than compact code when the compact version requires a mental pause to parse.\n\n```typescript\n// UNCLEAR: Dense ternary chain\nconst label = isNew ? 'New' : isUpdated ? 'Updated' : isArchived ? 'Archived' : 'Active'\n\n// CLEAR: Readable mapping\nfunction getStatusLabel(item: Item): string {\n  if (item.isNew) return 'New'\n  if (item.isUpdated) return 'Updated'\n  if (item.isArchived) return 'Archived'\n  return 'Active'\n}\n```\n\n```typescript\n// UNCLEAR: Chained reduces with inline logic\nconst result = items.reduce(\n  (acc, item) => ({\n    ...acc,\n    [item.id]: {...acc[item.id], count: (acc[item.id]?.count ?? 0) + 1},\n  }),\n  {},\n)\n\n// CLEAR: Named intermediate step\nconst countById = new Map<string, number>()\nfor (const item of items) {\n  countById.set(item.id, (countById.get(item.id) ?? 0) + 1)\n}\n```\n\n### 4. Maintain Balance\n\nSimplification has a failure mode: over-simplification. Watch for these traps:\n\n- **Inlining too aggressively** — removing a helper that gave a concept a name makes the call site harder to read\n- **Combining unrelated logic** — two simple functions merged into one complex function is not simpler\n- **Removing \"unnecessary\" abstraction** — some abstractions exist for extensibility or testability, not complexity\n- **Optimizing for line count** — fewer lines is not the goal; easier comprehension is\n\n### 5. Scope to What Changed\n\nDefault to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions.\n\n## The Simplification Process\n\n### Step 1: Understand Before Touching (Chesterton's Fence)\n\nBefore changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies.\n\n```\nBEFORE SIMPLIFYING, ANSWER:\n- What is this code's responsibility?\n- What calls it? What does it call?\n- What are the edge cases and error paths?\n- Are there tests that define the expected behavior?\n- Why might it have been written this way? (Performance? Platform constraint? Historical reason?)\n- Check git blame: what was the original context for this code?\n```\n\nIf you can't answer these, you're not ready to simplify. Read more context first.\n\n### Step 2: Identify Simplification Opportunities\n\nScan for these patterns — each one is a concrete signal, not a vague smell:\n\n**Structural complexity:**\n\n| Pattern                    | Signal                             | Simplification                                            |\n| -------------------------- | ---------------------------------- | --------------------------------------------------------- |\n| Deep nesting (3+ levels)   | Hard to follow control flow        | Extract conditions into guard clauses or helper functions |\n| Long functions (50+ lines) | Multiple responsibilities          | Split into focused functions with descriptive names       |\n| Nested ternaries           | Requires mental stack to parse     | Replace with if/else chains, switch, or lookup objects    |\n| Boolean parameter flags    | `doThing(true, false, true)`       | Replace with options objects or separate functions        |\n| Repeated conditionals      | Same `if` check in multiple places | Extract to a well-named predicate function                |\n\n**Naming and readability:**\n\n| Pattern                    | Signal                                         | Simplification                                                           |\n| -------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |\n| Generic names              | `data`, `result`, `temp`, `val`, `item`        | Rename to describe the content: `userProfile`, `validationErrors`        |\n| Abbreviated names          | `usr`, `cfg`, `btn`, `evt`                     | Use full words unless the abbreviation is universal (`id`, `url`, `api`) |\n| Misleading names           | Function named `get` that also mutates state   | Rename to reflect actual behavior                                        |\n| Comments explaining \"what\" | `// increment counter` above `count++`         | Delete the comment — the code is clear enough                            |\n| Comments explaining \"why\"  | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express                    |\n\n**Redundancy:**\n\n| Pattern                   | Signal                                                       | Simplification                                            |\n| ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |\n| Duplicated logic          | Same 5+ lines in multiple places                             | Extract to a shared function                              |\n| Dead code                 | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead)                 |\n| Unnecessary abstractions  | Wrapper that adds no value                                   | Inline the wrapper, call the underlying function directly |\n| Over-engineered patterns  | Factory-for-a-factory, strategy-with-one-strategy            | Replace with the simple direct approach                   |\n| Redundant type assertions | Casting to a type that's already inferred                    | Remove the assertion                                      |\n\n### Step 3: Apply Changes Incrementally\n\nMake one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them.\n\n```\nFOR EACH SIMPLIFICATION:\n1. Make the change\n2. Run the test suite\n3. If tests pass → commit (or continue to next simplification)\n4. If tests fail → revert and reconsider\n```\n\nAvoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it.\n\n**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review.\n\n### Step 4: Verify the Result\n\nAfter all simplifications, step back and evaluate the whole:\n\n```\nCOMPARE BEFORE AND AFTER:\n- Is the simplified version genuinely easier to understand?\n- Did you introduce any new patterns inconsistent with the codebase?\n- Is the diff clean and reviewable?\n- Would a teammate approve this change?\n```\n\nIf the \"simplified\" version is harder to understand or review, revert. Not every simplification attempt succeeds.\n\n## Language-Specific Guidance\n\n### TypeScript / JavaScript\n\n```typescript\n// SIMPLIFY: Unnecessary async wrapper\n// Before\nasync function getUser(id: string): Promise<User> {\n  return await userService.findById(id)\n}\n// After\nfunction getUser(id: string): Promise<User> {\n  return userService.findById(id)\n}\n\n// SIMPLIFY: Verbose conditional assignment\n// Before\nlet displayName: string\nif (user.nickname) {\n  displayName = user.nickname\n} else {\n  displayName = user.fullName\n}\n// After\nconst displayName = user.nickname || user.fullName\n\n// SIMPLIFY: Manual array building\n// Before\nconst activeUsers: User[] = []\nfor (const user of users) {\n  if (user.isActive) {\n    activeUsers.push(user)\n  }\n}\n// After\nconst activeUsers = users.filter((user) => user.isActive)\n\n// SIMPLIFY: Redundant boolean return\n// Before\nfunction isValid(input: string): boolean {\n  if (input.length > 0 && input.length < 100) {\n    return true\n  }\n  return false\n}\n// After\nfunction isValid(input: string): boolean {\n  return input.length > 0 && input.length < 100\n}\n```\n\n### Python\n\n```python\n# SIMPLIFY: Verbose dictionary building\n# Before\nresult = {}\nfor item in items:\n    result[item.id] = item.name\n# After\nresult = {item.id: item.name for item in items}\n\n# SIMPLIFY: Nested conditionals with early return\n# Before\ndef process(data):\n    if data is not None:\n        if data.is_valid():\n            if data.has_permission():\n                return do_work(data)\n            else:\n                raise PermissionError(\"No permission\")\n        else:\n            raise ValueError(\"Invalid data\")\n    else:\n        raise TypeError(\"Data is None\")\n# After\ndef process(data):\n    if data is None:\n        raise TypeError(\"Data is None\")\n    if not data.is_valid():\n        raise ValueError(\"Invalid data\")\n    if not data.has_permission():\n        raise PermissionError(\"No permission\")\n    return do_work(data)\n```\n\n### React / JSX\n\n```tsx\n// SIMPLIFY: Verbose conditional rendering\n// Before\nfunction UserBadge({user}: Props) {\n  if (user.isAdmin) {\n    return <Badge variant=\"admin\">Admin</Badge>\n  } else {\n    return <Badge variant=\"default\">User</Badge>\n  }\n}\n// After\nfunction UserBadge({user}: Props) {\n  const variant = user.isAdmin ? 'admin' : 'default'\n  const label = user.isAdmin ? 'Admin' : 'User'\n  return <Badge variant={variant}>{label}</Badge>\n}\n\n// SIMPLIFY: Prop drilling through intermediate components\n// Before — consider whether context or composition solves this better.\n// This is a judgment call — flag it, don't auto-refactor.\n```\n\n## Common Rationalizations\n\n| Rationalization                                      | Reality                                                                                                                                               |\n| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"It's working, no need to touch it\"                  | Working code that's hard to read will be hard to fix when it breaks. Simplifying now saves time on every future change.                               |\n| \"Fewer lines is always simpler\"                      | A 1-line nested ternary is not simpler than a 5-line if/else. Simplicity is about comprehension speed, not line count.                                |\n| \"I'll just quickly simplify this unrelated code too\" | Unscoped simplification creates noisy diffs and risks regressions in code you didn't intend to change. Stay focused.                                  |\n| \"The types make it self-documenting\"                 | Types document structure, not intent. A well-named function explains _why_ better than a type signature explains _what_.                              |\n| \"This abstraction might be useful later\"             | Don't preserve speculative abstractions. If it's not used now, it's complexity without value. Remove it and re-add when needed.                       |\n| \"The original author must have had a reason\"         | Maybe. Check git blame — apply Chesterton's Fence. But accumulated complexity often has no reason; it's just the residue of iteration under pressure. |\n| \"I'll refactor while adding this feature\"            | Separate refactoring from feature work. Mixed changes are harder to review, revert, and understand in history.                                        |\n\n## Red Flags\n\n- Simplification that requires modifying tests to pass (you likely changed behavior)\n- \"Simplified\" code that is longer and harder to follow than the original\n- Renaming things to match your preferences rather than project conventions\n- Removing error handling because \"it makes the code cleaner\"\n- Simplifying code you don't fully understand\n- Batching many simplifications into one large, hard-to-review commit\n- Refactoring code outside the scope of the current task without being asked\n\n## Verification\n\nAfter completing a simplification pass:\n\n- [ ] All existing tests pass without modification\n- [ ] Build succeeds with no new warnings\n- [ ] Linter/formatter passes (no style regressions)\n- [ ] Each simplification is a reviewable, incremental change\n- [ ] The diff is clean — no unrelated changes mixed in\n- [ ] Simplified code follows project conventions (checked against CLAUDE.md or equivalent)\n- [ ] No error handling was removed or weakened\n- [ ] No dead code was left behind (unused imports, unreachable branches)\n- [ ] A teammate or review agent would approve the change as a net improvement\n",".agents/skills/deslop/SKILL.md":"---\nname: deslop\ndescription: Remove AI-generated code slop and clean up code style\n---\n\n# Remove AI code slop\n\nCheck the diff against main and remove AI-generated slop introduced in the branch.\n\n## Focus Areas\n\n- Extra comments that are unnecessary or inconsistent with local style\n- Defensive checks or try/catch blocks that are abnormal for trusted code paths\n- Casts to `any` used only to bypass type issues\n- Deeply nested code that should be simplified with early returns\n- Other patterns inconsistent with the file and surrounding codebase\n\n## Guardrails\n\n- Keep behavior unchanged unless fixing a clear bug.\n- Prefer minimal, focused edits over broad rewrites.\n- Keep the final summary concise (1-3 sentences).\n",".agents/skills/find-skills/SKILL.md":"---\nname: find-skills\ndescription: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n---\n\n# Find Skills\n\nThis skill helps you discover and install skills from the open agent skills ecosystem.\n\n## When to Use This Skill\n\nUse this skill when the user:\n\n- Asks \"how do I do X\" where X might be a common task with an existing skill\n- Says \"find a skill for X\" or \"is there a skill for X\"\n- Asks \"can you do X\" where X is a specialized capability\n- Expresses interest in extending agent capabilities\n- Wants to search for tools, templates, or workflows\n- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)\n\n## What is the Skills CLI?\n\nThe Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.\n\n**Key commands:**\n\n- `npx skills find [query]` - Search for skills interactively or by keyword\n- `npx skills add <package>` - Install a skill from GitHub or other sources\n- `npx skills check` - Check for skill updates\n- `npx skills update` - Update all installed skills\n\n**Browse skills at:** https://skills.sh/\n\n## How to Help Users Find Skills\n\n### Step 1: Understand What They Need\n\nWhen a user asks for help with something, identify:\n\n1. The domain (e.g., React, testing, design, deployment)\n2. The specific task (e.g., writing tests, creating animations, reviewing PRs)\n3. Whether this is a common enough task that a skill likely exists\n\n### Step 2: Search for Skills\n\nRun the find command with a relevant query:\n\n```bash\nnpx skills find [query]\n```\n\nFor example:\n\n- User asks \"how do I make my React app faster?\" → `npx skills find react performance`\n- User asks \"can you help me with PR reviews?\" → `npx skills find pr review`\n- User asks \"I need to create a changelog\" → `npx skills find changelog`\n\nThe command will return results like:\n\n```\nInstall with npx skills add <owner/repo@skill>\n\nvercel-labs/agent-skills@vercel-react-best-practices\n└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 3: Present Options to the User\n\nWhen you find relevant skills, present them to the user with:\n\n1. The skill name and what it does\n2. The install command they can run\n3. A link to learn more at skills.sh\n\nExample response:\n\n```\nI found a skill that might help! The \"vercel-react-best-practices\" skill provides\nReact and Next.js performance optimization guidelines from Vercel Engineering.\n\nTo install it:\nnpx skills add vercel-labs/agent-skills@vercel-react-best-practices\n\nLearn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 4: Offer to Install\n\nIf the user wants to proceed, you can install the skill for them:\n\n```bash\nnpx skills add <owner/repo@skill> -g -y\n```\n\nThe `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.\n\n## Common Skill Categories\n\nWhen searching, consider these common categories:\n\n| Category        | Example Queries                          |\n| --------------- | ---------------------------------------- |\n| Web Development | react, nextjs, typescript, css, tailwind |\n| Testing         | testing, jest, playwright, e2e           |\n| DevOps          | deploy, docker, kubernetes, ci-cd        |\n| Documentation   | docs, readme, changelog, api-docs        |\n| Code Quality    | review, lint, refactor, best-practices   |\n| Design          | ui, ux, design-system, accessibility     |\n| Productivity    | workflow, automation, git                |\n\n## Tips for Effective Searches\n\n1. **Use specific keywords**: \"react testing\" is better than just \"testing\"\n2. **Try alternative terms**: If \"deploy\" doesn't work, try \"deployment\" or \"ci-cd\"\n3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`\n\n## When No Skills Are Found\n\nIf no relevant skills exist:\n\n1. Acknowledge that no existing skill was found\n2. Offer to help with the task directly using your general capabilities\n3. Suggest the user could create their own skill with `npx skills init`\n\nExample:\n\n```\nI searched for skills related to \"xyz\" but didn't find any matches.\nI can still help you with this task directly! Would you like me to proceed?\n\nIf this is something you do often, you could create your own skill:\nnpx skills init my-xyz-skill\n```\n",".agents/skills/grill-me/SKILL.md":"---\nname: grill-me\ndescription: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions \"grill me\".\n---\n\nInterview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.\n\nIf a question can be answered by exploring the codebase, explore the codebase instead.\n",".agents/skills/improve-codebase-architecture/REFERENCE.md":"# Reference\n\n## Dependency Categories\n\nWhen assessing a candidate for deepening, classify its dependencies:\n\n### 1. In-process\n\nPure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly.\n\n### 2. Local-substitutable\n\nDependencies that have local test stand-ins (e.g., PGLite for Postgres, in-memory filesystem). Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite.\n\n### 3. Remote but owned (Ports & Adapters)\n\nYour own services across a network boundary (microservices, internal APIs). Define a port (interface) at the module boundary. The deep module owns the logic; the transport is injected. Tests use an in-memory adapter. Production uses the real HTTP/gRPC/queue adapter.\n\nRecommendation shape: \"Define a shared interface (port), implement an HTTP adapter for production and an in-memory adapter for testing, so the logic can be tested as one deep module even though it's deployed across a network boundary.\"\n\n### 4. True external (Mock)\n\nThird-party services (Stripe, Twilio, etc.) you don't control. Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock implementation.\n\n## Testing Strategy\n\nThe core principle: **replace, don't layer.**\n\n- Old unit tests on shallow modules are waste once boundary tests exist — delete them\n- Write new tests at the deepened module's interface boundary\n- Tests assert on observable outcomes through the public interface, not internal state\n- Tests should survive internal refactors — they describe behavior, not implementation\n\n## Issue Template\n\n<issue-template>\n\n## Problem\n\nDescribe the architectural friction:\n\n- Which modules are shallow and tightly coupled\n- What integration risk exists in the seams between them\n- Why this makes the codebase harder to navigate and maintain\n\n## Proposed Interface\n\nThe chosen interface design:\n\n- Interface signature (types, methods, params)\n- Usage example showing how callers use it\n- What complexity it hides internally\n\n## Dependency Strategy\n\nWhich category applies and how dependencies are handled:\n\n- **In-process**: merged directly\n- **Local-substitutable**: tested with [specific stand-in]\n- **Ports & adapters**: port definition, production adapter, test adapter\n- **Mock**: mock boundary for external services\n\n## Testing Strategy\n\n- **New boundary tests to write**: describe the behaviors to verify at the interface\n- **Old tests to delete**: list the shallow module tests that become redundant\n- **Test environment needs**: any local stand-ins or adapters required\n\n## Implementation Recommendations\n\nDurable architectural guidance that is NOT coupled to current file paths:\n\n- What the module should own (responsibilities)\n- What it should hide (implementation details)\n- What it should expose (the interface contract)\n- How callers should migrate to the new interface\n\n</issue-template>\n",".agents/skills/improve-codebase-architecture/SKILL.md":"---\nname: improve-codebase-architecture\ndescription: Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable.\n---\n\n# Improve Codebase Architecture\n\nExplore a codebase like an AI would, surface architectural friction, discover opportunities for improving testability, and propose module-deepening refactors as GitHub issue RFCs.\n\nA **deep module** (John Ousterhout, \"A Philosophy of Software Design\") has a small interface hiding a large implementation. Deep modules are more testable, more AI-navigable, and let you test at the boundary instead of inside.\n\n## Process\n\n### 1. Explore the codebase\n\nUse the Agent tool with subagent_type=Explore to navigate the codebase naturally. Do NOT follow rigid heuristics — explore organically and note where you experience friction:\n\n- Where does understanding one concept require bouncing between many small files?\n- Where are modules so shallow that the interface is nearly as complex as the implementation?\n- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called?\n- Where do tightly-coupled modules create integration risk in the seams between them?\n- Which parts of the codebase are untested, or hard to test?\n\nThe friction you encounter IS the signal.\n\n### 2. Present candidates\n\nPresent a numbered list of deepening opportunities. For each candidate, show:\n\n- **Cluster**: Which modules/concepts are involved\n- **Why they're coupled**: Shared types, call patterns, co-ownership of a concept\n- **Dependency category**: See [REFERENCE.md](REFERENCE.md) for the four categories\n- **Test impact**: What existing tests would be replaced by boundary tests\n\nDo NOT propose interfaces yet. Ask the user: \"Which of these would you like to explore?\"\n\n### 3. User picks a candidate\n\n### 4. Frame the problem space\n\nBefore spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:\n\n- The constraints any new interface would need to satisfy\n- The dependencies it would need to rely on\n- A rough illustrative code sketch to make the constraints concrete — this is not a proposal, just a way to ground the constraints\n\nShow this to the user, then immediately proceed to Step 5. The user reads and thinks about the problem while the sub-agents work in parallel.\n\n### 5. Design multiple interfaces\n\nSpawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.\n\nPrompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category, what's being hidden). This brief is independent of the user-facing explanation in Step 4. Give each agent a different design constraint:\n\n- Agent 1: \"Minimize the interface — aim for 1-3 entry points max\"\n- Agent 2: \"Maximize flexibility — support many use cases and extension\"\n- Agent 3: \"Optimize for the most common caller — make the default case trivial\"\n- Agent 4 (if applicable): \"Design around the ports & adapters pattern for cross-boundary dependencies\"\n\nEach sub-agent outputs:\n\n1. Interface signature (types, methods, params)\n2. Usage example showing how callers use it\n3. What complexity it hides internally\n4. Dependency strategy (how deps are handled — see [REFERENCE.md](REFERENCE.md))\n5. Trade-offs\n\nPresent designs sequentially, then compare them in prose.\n\nAfter comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not just a menu.\n\n### 6. User picks an interface (or accepts recommendation)\n\n### 7. Create GitHub issue\n\nCreate a refactor RFC as a GitHub issue using `gh issue create`. Use the template in [REFERENCE.md](REFERENCE.md). Do NOT ask the user to review before creating — just create it and share the URL.\n",".agents/skills/migrate-styled-components-to-panda-css/SKILL.md":"---\nname: migrate-styled-components-to-panda-css\ndescription: Step-by-step procedure for migrating components in the Sanity monorepo off styled-components to Panda CSS (zero-runtime, build-time extracted CSS). Use when converting styled() components to .css.ts files, creating .css.ts files, handling CSS specificity against @sanity/ui, styling child elements with Panda, or when asked to migrate styling to Panda CSS.\n---\n\n# Migrate styled-components to Panda CSS\n\nThe repeatable procedure for converting this monorepo's styling from `styled-components` (the\nStudio's legacy styling library) to [Panda CSS](https://panda-css.com). Adapted from the\n`sanity-io/plugins` skill for vanilla-extract, which this repo used before Panda.\n\n**The infrastructure is already wired — never touch it during a component migration.**\n`packages/sanity` builds its `.css.ts` files into `lib/bundle.css` through the `panda` option of\n`@sanity/tsdown-config` in its `tsdown.config.ts` (`panda.config.ts` next to it), exposes the\n`./bundle.css` export, registers `@pandacss/vite` in its `vitest.config.mts`, and the dev\nstudios, Storybook and the browser suite point their own `@pandacss/vite` instance at the\npackage (`packages/sanity/panda.config.ts` is the repository's only Panda project). A migration\nPR only converts component styles.\n\n## Ground rules\n\n- **Identical visual output is the goal.** This is a refactor, not a redesign — every rule and\n  specificity outcome must be preserved. Copy CSS values verbatim; do not \"improve\" them.\n- **Small PRs.** Migrate one component (or one tightly-coupled cluster) per PR, conventional-commit\n  titled, e.g. `refactor(structure): migrate Scroller to Panda CSS`.\n- **Reference implementations:**\n  `packages/sanity/src/core/changeIndicators/ElementWithChangeBar.*` (data attributes for\n  parent-state selectors), `packages/sanity/src/structure/panes/document/document-layout/DocumentLayout.*`,\n  and for the dynamic shapes `packages/sanity/src/core/components/popoverDialog/PopoverContainer.*`\n  (responsive widths through per-breakpoint variables) and\n  `packages/sanity/src/core/form/inputs/PortableText/text/TextBlock.styles.*` (per-level variants,\n  private custom properties, theme-driven colors).\n\n## Step 1: Inventory\n\nClassify each usage before touching code:\n\n```bash\nrg \"from 'styled-components'\" packages/sanity/src <target-dir>\n```\n\n| Usage                                               | Migration target                                                                                                          |\n| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| Static `styled.div` / `styled(Primitive)`           | `css()` in a colocated `.css.ts` + thin wrapper component (or plain `className`)                                          |\n| Overrides of `@sanity/ui` primitives' own styles    | `css()` with the `&&` specificity trick (see below)                                                                       |\n| Theme reads (`({theme}) => ...`, `getTheme_v2`)     | `--card-*` CSS custom properties if one exists, else a named custom property set through inline `style` + `useTheme_v2()` |\n| Prop-driven variants (`$isInvalid`, `css` branches) | one `css()` per state + conditional `clsx()` composition (the modifier doubled with `&&`), or an object of `css()` calls  |\n| `keyframes` animations                              | Panda `keyframes()` from `#styled-system/css`                                                                             |\n| Descendant selectors (`& img`, third-party classes) | class on the child directly, or a nested `'& img'` / `'& .third-party'` selector on the parent class                      |\n| `createGlobalStyle`                                 | nested selectors under a local class (never leak outside the component)                                                   |\n| `*.styled.tsx` modules                              | `.css.ts` + thin wrappers; keep the `.styled.tsx` file when call sites need it                                            |\n| Computed inline `style={{}}` objects                | static parts into `css()`, changing values through custom properties (Shape C below)                                      |\n\n## Step 2: Migrate the code\n\nCreate a `ComponentName.css.ts` next to the component. Only `.css.ts` files are Panda sources\n(`include` in `panda.config.ts`): a `css()` call anywhere else is neither extracted into\n`bundle.css` nor rewritten to class names, so its styles never render. Import from\n`#styled-system/css` (the generated runtime, mapped by the package's `imports`; it exists after\n`pnpm install`, which runs `panda codegen`). The build rewrites every statically resolvable call\ninto its class names, so the published code carries no Panda runtime — keep the style objects\nliteral (no loops, no values computed from imports) and pass the module's own constants only.\n\n**Shape A — keep the component layer.** When the styled element is used like a component, replace\nit with a `css()` rule plus a thin wrapper keeping the same name and API, so call sites don't\nchange. Type with `ComponentProps<typeof Primitive>` (or `ComponentProps<'div'>`) — never\n`forwardRef`; `ref` is a regular prop on React 19 and flows through the spread. **Merge, don't\nclobber:** a fixed `className={...}` after `{...props}` silently drops a caller's `className` —\nmerge with `clsx` (already a dependency):\n\n```tsx\nimport {Card} from '@sanity/ui'\nimport {clsx} from 'clsx'\nimport {type ComponentProps} from 'react'\n\nimport {floatingCard} from './FloatingCard.css'\n\nexport function FloatingCard(props: ComponentProps<typeof Card>) {\n  const {className, ...rest} = props\n  return <Card {...rest} className={clsx(floatingCard, className)} />\n}\n```\n\nRenaming a `Component.styled.ts` to `.tsx` (or `.ts` → `.tsx`) keeps extension-less import paths\nat call sites unchanged.\n\n**Shape B — flatten single-use wrappers.** When the styled element was an internal, single-use\n`styled.div` with no meaningful API, delete it and put the class directly on the element:\n`<div className={paragraph}>{children}</div>`.\n\n**Shape C — dynamic values through CSS variables.** First check whether `@sanity/ui` already\nexposes the value as a CSS custom property — prefer e.g. `var(--card-bg-color)` over JS theme\nreads (see `ElementWithChangeBar.css.ts`). Otherwise keep static parts in `css()` and bridge only\nchanging values with a named custom property the wrapper sets inline, reading the theme with\n`useTheme_v2()` (alias it `useTheme_v2 as useThemeV2` to satisfy the camelCase lint):\n\n```ts\n// Wrapper.css.ts\nimport {css} from '#styled-system/css'\n\n/** Set inline by `Wrapper` from the theme */\nexport const paddingVar = '--wrapper-padding'\nexport const wrapper = css({paddingBottom: `var(${paddingVar})`})\n```\n\n```tsx\n<div className={wrapper} style={{[paddingVar]: `${space[4]}px`} as CSSProperties} />\n```\n\nName the property after the component and the theme key (`--wrapper-space-4`), since it is\nglobal to the page; one class serves every instance.\n\n### Specificity against @sanity/ui: the `&&` trick\n\n`@sanity/ui` v4 still uses styled-components, which injects its styles at runtime — **after** the\nextracted stylesheet, so an equal-specificity class loses. When overriding a primitive's own\nstyles (e.g. `styled(Card)` overriding Card's background), double the class:\n\n```ts\nexport const transparentCard = css({\n  '&&': {background: 'none'},\n})\n```\n\nWhere styled-components silently won specificity battles via CSSOM insertion order, reach for\n`&&` rather than `!important`. Plain-element styles (`styled.div`) need no trick.\n\n### The cascade model (why order is not yours to rely on)\n\nThree stylesheets meet on a migrated element:\n\n| Sheet                                     | `sanity build`                                      | `sanity dev`                                                                                                |\n| ----------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Panda (`bundle.css`)                      | one static file, imported first by the entry chunks | one stylesheet (`@repo/pandacss/panda.css`) filled by `@pandacss/vite`, imported first by the runtime entry |\n| `@sanity/ui` (styled-components, runtime) | `<style data-styled>` appended on first render      | same                                                                                                        |\n| `ui5` (`styles.css`)                      | static file                                         | static file                                                                                                 |\n\nPanda writes its rules into cascade layers; the studio ships them without any (`cascadeLayers:\nfalse` for `bundle.css`, the same pass for the dev stylesheet — see the \"Panda CSS\" section of\nAGENTS.md). CSS outside any layer beats every layered rule regardless of specificity, so layers\nwould hand the studio's hover, focus and selected states to any unlayered rule that also matches.\nUnlayered, the studio's rules cascade like the vanilla-extract output did: by specificity, then\nsource order. Do not write `@layer` in a `.css.ts`.\n\nConsequences:\n\n- **Beat `@sanity/ui`'s rules by specificity, not by order.** `@sanity/ui`'s styled-components\n  rules are injected at runtime, after the static stylesheets, and `ui5`'s component classes load\n  as static CSS too; order is theirs, so a rule of yours that must win over one of theirs has to\n  be more specific: `&&` (0,2,0) beats a plain component class (0,1,0). If the primitive rule you\n  override already has two classes or a pseudo-class chain (e.g. Card's\n  `&[data-as='button']:not(:disabled)[data-selected]`), count its specificity and exceed it —\n  `&&&`, or `&&[data-as='button']`. Read the rule in `node_modules/@sanity/ui/dist/*.js` when\n  unsure. `!important` stays banned.\n- **Panda emits one rule per declaration, not one per class.** `css({display: 'flex', opacity: 1})`\n  returns two class names, one per declaration, and `bundle.css` holds every distinct\n  `property: value` pair once, grouped by property with the values **sorted alphabetically**. So\n  when two of your classes set the same property on one element (`clsx(rootBox, muted && rootBoxMuted)`),\n  the value that sorts last wins — `opacity: 1` beats `opacity: 0.8` whatever the declaration\n  order. A modifier that must win doubles its class: `rootBoxMuted = css({'&&': {opacity: 0.8}})`.\n  The same holds for a `styled(LocalStyledThing)` chain, which styled-components ordered for you.\n- **Shorthands come before longhands.** `padding: 0` in one class and `paddingLeft: '4px'` in\n  another on the same element always resolve to the longhand, regardless of order.\n- **Parent-state selectors key on data attributes, never on an empty class.** A Panda class name\n  is a list of atomic names, so `` `${parent}:hover &` `` cannot be built from it, and a class\n  that exists only to be targeted is not an option either. Have the parent render its identity\n  and state as data attributes (`data-change-bar-wrapper=\"\"`, `data-focused={hasFocus ? '' :\n  undefined}`) and write `'[data-change-bar-wrapper][data-focused] &': {...}` or\n  `'[data-change-bar-wrapper]:hover &': {...}` on the child — see `ElementWithChangeBar.css.ts`.\n  Read a state attribute together with the identity attribute: generic names like `data-focused`\n  and `data-disabled` are rendered by other components too, and `:not([data-changed])` on its own\n  matches every ancestor. Attributes a library component already renders (`'[data-ui=\"Card\"] &'`)\n  work the same way.\n- **`@media` blocks are grouped.** Panda emits a file's media queries after the base rules,\n  which is what styled-components effectively did too. Media conditions cannot read custom\n  properties, so theme breakpoints are written as literals from the default theme (table\n  below) with a comment naming the theme key they mirror.\n\n#### What each `@sanity/ui` primitive sets on itself\n\nUse `&&` when the migrated rule overrides one of these; plain `css()` otherwise.\n\n| Primitive                                                                                         | Own declarations (via its props or defaults)                                                                                                                                                                                                                                                                  |\n| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Box`                                                                                             | `display` (block, or the `display` prop), `box-sizing`, `flex`/`flex-shrink`/`flex-grow` props, `margin*`, `padding*`, `height`, `overflow`, `column`/`row` grid props, `list-style: none` for `as=\"ul\"/\"ol\"`                                                                                                 |\n| `Flex`                                                                                            | Box + `display: flex`, `flex-direction`, `flex-wrap`, `align-items`, `justify-content`, `gap`                                                                                                                                                                                                                 |\n| `Stack`                                                                                           | Box + `display: grid`, `grid-template-columns`, `grid-auto-rows`, `gap`                                                                                                                                                                                                                                       |\n| `Grid`, `Inline`                                                                                  | Box + `display`, `gap`, `grid-*`, `align-items`                                                                                                                                                                                                                                                               |\n| `Card`                                                                                            | Box + `background-color`, `color`, `color-scheme`, every `--card-*` variable, `border`/`border-color` (`border` prop), `border-radius` (`radius`), `box-shadow` (`shadow`, focus ring), button/link hover/pressed/selected/disabled variants, `outline`, `font`, `text-align`, `appearance`, `width` (button) |\n| `Container`                                                                                       | Box + `max-width`, `margin: 0 auto`                                                                                                                                                                                                                                                                           |\n| `Text`, `Label`, `Heading`, `Code`                                                                | `font-family`, `font-size`, `font-weight`, `line-height`, `letter-spacing`, `color` (`--card-fg-color` / muted / accent), `text-align`; descendant rules for `& code`, `& a` (color `--card-link-color`, focus ring), `& strong`, `& svg`; the inner `span` handles `text-overflow`                           |\n| `Button`                                                                                          | Card-like colors per `mode`/`tone`, `padding`, `border-radius`, `font`, `box-shadow`, `cursor`, `&:disabled`, `&:not(:disabled):hover`, `& [data-ui='Text']`                                                                                                                                                  |\n| `TextInput`, `TextArea`, `Select`                                                                 | wrapper `position: relative`; the inner `input`/`textarea`/`select` gets font, color, padding, border via presentation `span`; `--input-*` variables                                                                                                                                                          |\n| `Skeleton`, `TextSkeleton`, `LabelSkeleton`                                                       | `background-color`, `border-radius`, `animation`, `width`, `height`                                                                                                                                                                                                                                           |\n| `Popover`, `Tooltip`, `Layer`, `Dialog`                                                           | `position`, `z-index`, `pointer-events`, `max-width`; Popover/Tooltip content Card colors                                                                                                                                                                                                                     |\n| `Badge`, `KBD`, `Spinner`, `Switch`, `Checkbox`, `Radio`, `Avatar`, `Tab`, `TreeItem`, `MenuItem` | fully self-styled — treat every declaration as an override (`&&`)                                                                                                                                                                                                                                             |\n\n#### Theme reads → CSS variables\n\n`getTheme_v2(theme)` inside a styled template and `useTheme_v2()` inside the wrapper read the\nsame context, including the nearest `Card`'s `tone` and `scheme`. Prefer a variable `@sanity/ui`\nalready publishes on the nearest Card (they follow tone and scheme for free):\n\n| Theme read                                                        | CSS variable                                                                           |\n| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `color.bg`, `color.fg`, `color.border`, `color.icon`              | `--card-bg-color`, `--card-fg-color`, `--card-border-color`, `--card-icon-color`       |\n| `color.muted.bg`, `color.muted.fg`, `color.accent.fg`             | `--card-muted-bg-color`, `--card-muted-fg-color`, `--card-accent-fg-color`             |\n| `color.focusRing`, `color.backdrop`                               | `--card-focus-ring-color`, `--card-backdrop-color`                                     |\n| `color.badge.<tone>.{bg,fg,dot,icon}`                             | `--card-badge-<tone>-{bg,fg,dot,icon}-color`                                           |\n| `color.avatar.<hue>.{bg,fg}`                                      | `--card-avatar-<hue>-{bg,fg}-color`                                                    |\n| `color.link.fg`, `color.code.{bg,fg}`, `color.kbd.{bg,fg,border}` | `--card-link-fg-color`, `--card-code-{bg,fg}-color`, `--card-kbd-{bg,fg,border}-color` |\n| `color.shadow.{outline,umbra,penumbra,ambient}`                   | `--card-shadow-{outline,umbra,penumbra,ambient}-color`                                 |\n| `color.skeleton.{from,to}`                                        | `--card-skeleton-color-{from,to}`                                                      |\n| `color.hairline.{soft,hard}`                                      | `--card-hairline-{soft,hard}-color`                                                    |\n| card focus ring box-shadow (`focusRingStyle(...)`)                | `--card-focus-ring-box-shadow` (set by Card/Button on `:focus-visible`)                |\n\nEverything else — `color.selectable.*`, `color.solid.*`, `color.input.*`, `color.button.*`,\n`color.syntax.*`, `color._dark`, `space[n]`, `radius[n]`, `font.*`, `container[n]`,\n`avatar.sizes[n]`, `input.*`, `shadow[n]` — goes through Shape C: a named custom property in the\n`.css.ts`, `useTheme_v2()` + an inline `style` object in the wrapper. Keep the same theme key\nnames in the property names (`--wrapper-space-4`, `--wrapper-radius-2`) so the intent survives. Do\nnot hardcode scale values: a studio may build its own theme with different `space`, `radius`, or\n`font` scales.\n\nOnly `@media` conditions get literals, mirrored from the default theme:\n\n| Key         | Default values                         |\n| ----------- | -------------------------------------- |\n| `media`     | `[360, 600, 900, 1200, 1800, 2400]` px |\n| `container` | `[320, 640, 960, 1280, 1600, 1920]` px |\n\nWrite them as `'@media screen and (min-width: 600px)': {...}` with a comment such as\n`// media[1]`.\n\n### Wrapper contract\n\nA wrapper that replaces an exported styled component must keep call sites working without edits:\n\n- Same export name, same prop names — including transient `$props`, which become regular props\n  the wrapper consumes (do not rename them; callers in other directories still pass them).\n- `className` and `style` **merge** (`clsx`, spread) — never clobber.\n- `ref` flows through props (React 19). No `forwardRef`, no `displayName`.\n- `.attrs({...})` values become JSX props on the primitive inside the wrapper; `forwardedAs`\n  becomes `as`.\n- A prop-driven `css` branch becomes one `css()` per branch plus `clsx(cond && cls)`, the branch\n  doubled with `&&` when it overrides the base class (see the cascade model); a prop-driven\n  **value** becomes a custom property set inline. Prefer an object of `css()` calls for a closed\n  set of values (`root.narrow`, `root.wide`).\n- Shared `css` mixins (an `export const focusRingStyles = css` tagged template) become a\n  `css.raw({...})` object in a `.css.ts`; importers compose it with `css(mixin, {...})`, which\n  merges the objects before emitting the class names.\n- A test that asserted a computed style of a migrated component (`toHaveStyle`) cannot pass in\n  jsdom anymore (no stylesheet is loaded there). Assert the state through a `data-*` attribute the\n  wrapper sets instead; add the attribute if none exists.\n\n### Child and descendant selectors\n\nA nested key with `&` is a selector where `&` stands for the class's own element, in any\nposition: `'& img'`, `'& > span'`, `'& .third-party-class'`, `'[data-ui=\"Card\"] &'`. In order of\npreference:\n\n1. Put a class directly on the child if you render it yourself.\n2. `'[data-parent][data-state] &': {...}` on the child class for parent-state-dependent rules,\n   keyed on data attributes the parent renders (see the cascade model).\n3. `'& img': {...}` on the parent class when you don't control the child (library-rendered DOM) —\n   always scoped under a local class.\n\n### Strict property values\n\n`panda.config.ts` sets `strictPropertyValues`, which restricts enumerable properties (`display`,\n`position`, `scrollbarWidth`, …) to their CSS keywords and catches typos. A `var()` on such a\nproperty needs Panda's escape hatch, square brackets around the value:\n`scrollbarWidth: '[var(--scrollbar-width)]'` emits `scrollbar-width: var(--scrollbar-width)`.\n\n## Step 3: Verify\n\n```bash\npnpm lint:fix                                  # oxfmt + oxlint (includes type checking)\npnpm build && pnpm vitest run --project=sanity <affected test paths>\n```\n\nCheck `packages/sanity/lib/bundle.css` after the build: every migrated rule must appear there.\nThe build warns about a `css()` call it could not resolve statically; such a call keeps Panda's\nruntime in the bundle and must be rewritten with literal values.\n\nVerify **visual fidelity** in the dev studio (`pnpm dev`, see AGENTS.md for cloud-agent auth):\nexercise the migrated component and compare against the pre-migration rendering — theme tones,\nspacing, hover/focus states, stacking. Jsdom tests must not assert on Panda class names or\ncomputed styles (no stylesheet is loaded there — see AGENTS.md); assert on `data-testid`. For\nhigh-traffic components, add a Chromatic story per the `sanity-visual-regression` skill so the\nmigration is sentinel-protected.\n\n## End state\n\nThe monorepo's own code no longer imports `styled-components`; `eslint/no-restricted-imports` in\n`.oxlintrc.json` bans the module everywhere (the global rule and every override that repeats it).\nThe dependency itself stays declared because `@sanity/ui@4` requires it as a peer and still\ninjects its own styles at runtime — which is exactly why the cascade rules above still matter for\nevery new `.css.ts`. Use this skill for new components and for plugin or example code that still\ncarries styled-components.\n\n## Checklist\n\n- [ ] `styled-components` import removed from the migrated component; emptied `.styled.tsx`\n      modules deleted\n- [ ] `.css.ts` colocated; CSS values copied verbatim; component layer preserved where call sites\n      need it; `className` merged, not clobbered\n- [ ] `&&` used where the original relied on styled-components injection order to beat @sanity/ui,\n      and on modifier classes that override a base class on the same element\n- [ ] No build config, `package.json`, or exports changes (infra is already wired)\n- [ ] `pnpm lint:fix`, `pnpm build`, and affected tests pass; rules present in `lib/bundle.css`\n- [ ] Visual fidelity verified against the pre-migration rendering\n",".agents/skills/performance-optimization/SKILL.md":"---\nname: performance-optimization\ndescription: Optimizes application performance. Use when performance requirements exist, when you suspect performance regressions, or when Core Web Vitals or load times need improvement. Use when profiling reveals bottlenecks that need fixing.\n---\n\n# Performance Optimization\n\n## Overview\n\nMeasure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.\n\n## When to Use\n\n- Performance requirements exist in the spec (load time budgets, response time SLAs)\n- Users or monitoring report slow behavior\n- Core Web Vitals scores are below thresholds\n- You suspect a change introduced a regression\n- Building features that handle large datasets or high traffic\n\n**When NOT to use:** Don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains.\n\n## Core Web Vitals Targets\n\n| Metric                              | Good    | Needs Improvement | Poor    |\n| ----------------------------------- | ------- | ----------------- | ------- |\n| **LCP** (Largest Contentful Paint)  | ≤ 2.5s  | ≤ 4.0s            | > 4.0s  |\n| **INP** (Interaction to Next Paint) | ≤ 200ms | ≤ 500ms           | > 500ms |\n| **CLS** (Cumulative Layout Shift)   | ≤ 0.1   | ≤ 0.25            | > 0.25  |\n\n## The Optimization Workflow\n\n```\n1. MEASURE  → Establish baseline with real data\n2. IDENTIFY → Find the actual bottleneck (not assumed)\n3. FIX      → Address the specific bottleneck\n4. VERIFY   → Measure again, confirm improvement\n5. GUARD    → Add monitoring or tests to prevent regression\n```\n\n### Step 1: Measure\n\nTwo complementary approaches — use both:\n\n- **Synthetic (Lighthouse, DevTools Performance tab):** Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.\n- **RUM (web-vitals library, CrUX):** Real user data in real conditions. Required to validate that a fix actually improved user experience.\n\n**Frontend:**\n\n```bash\n# Synthetic: Lighthouse in Chrome DevTools (or CI)\n# Chrome DevTools → Performance tab → Record\n# Chrome DevTools MCP → Performance trace\n\n# RUM: Web Vitals library in code\nimport { onLCP, onINP, onCLS } from 'web-vitals';\n\nonLCP(console.log);\nonINP(console.log);\nonCLS(console.log);\n```\n\n**Backend:**\n\n```bash\n# Response time logging\n# Application Performance Monitoring (APM)\n# Database query logging with timing\n\n# Simple timing\nconsole.time('db-query');\nconst result = await db.query(...);\nconsole.timeEnd('db-query');\n```\n\n### Where to Start Measuring\n\nUse the symptom to decide what to measure first:\n\n```\nWhat is slow?\n├── First page load\n│   ├── Large bundle? --> Measure bundle size, check code splitting\n│   ├── Slow server response? --> Measure TTFB in DevTools Network waterfall\n│   │   ├── DNS long? --> Add dns-prefetch / preconnect for known origins\n│   │   ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive\n│   │   └── Waiting (server) long? --> Profile backend, check queries and caching\n│   └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking\n├── Interaction feels sluggish\n│   ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)\n│   ├── Form input lag? --> Check re-renders, controlled component overhead\n│   └── Animation jank? --> Check layout thrashing, forced reflows\n├── Page after navigation\n│   ├── Data loading? --> Measure API response times, check for waterfalls\n│   └── Client rendering? --> Profile component render time, check for N+1 fetches\n└── Backend / API\n    ├── Single endpoint slow? --> Profile database queries, check indexes\n    ├── All endpoints slow? --> Check connection pool, memory, CPU\n    └── Intermittent slowness? --> Check for lock contention, GC pauses, external deps\n```\n\n### Step 2: Identify the Bottleneck\n\nCommon bottlenecks by category:\n\n**Frontend:**\n\n| Symptom           | Likely Cause                                                 | Investigation                         |\n| ----------------- | ------------------------------------------------------------ | ------------------------------------- |\n| Slow LCP          | Large images, render-blocking resources, slow server         | Check network waterfall, image sizes  |\n| High CLS          | Images without dimensions, late-loading content, font shifts | Check layout shift attribution        |\n| Poor INP          | Heavy JavaScript on main thread, large DOM updates           | Check long tasks in Performance trace |\n| Slow initial load | Large bundle, many network requests                          | Check bundle size, code splitting     |\n\n**Backend:**\n\n| Symptom            | Likely Cause                                         | Investigation                    |\n| ------------------ | ---------------------------------------------------- | -------------------------------- |\n| Slow API responses | N+1 queries, missing indexes, unoptimized queries    | Check database query log         |\n| Memory growth      | Leaked references, unbounded caches, large payloads  | Heap snapshot analysis           |\n| CPU spikes         | Synchronous heavy computation, regex backtracking    | CPU profiling                    |\n| High latency       | Missing caching, redundant computation, network hops | Trace requests through the stack |\n\n### Step 3: Fix Common Anti-Patterns\n\n#### N+1 Queries (Backend)\n\n```typescript\n// BAD: N+1 — one query per task for the owner\nconst tasks = await db.tasks.findMany()\nfor (const task of tasks) {\n  task.owner = await db.users.findUnique({where: {id: task.ownerId}})\n}\n\n// GOOD: Single query with join/include\nconst tasks = await db.tasks.findMany({\n  include: {owner: true},\n})\n```\n\n#### Unbounded Data Fetching\n\n```typescript\n// BAD: Fetching all records\nconst allTasks = await db.tasks.findMany()\n\n// GOOD: Paginated with limits\nconst tasks = await db.tasks.findMany({\n  take: 20,\n  skip: (page - 1) * 20,\n  orderBy: {createdAt: 'desc'},\n})\n```\n\n#### Missing Image Optimization (Frontend)\n\n```html\n<!-- BAD: No dimensions, no format optimization -->\n<img src=\"/hero.jpg\" />\n\n<!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority -->\n<!--\n  Two techniques combined:\n  - Art direction (media): different crop/composition per breakpoint\n  - Resolution switching (srcset + sizes): right file size per screen density\n-->\n<picture>\n  <!-- Mobile: portrait crop (8:10) -->\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/avif\"\n  />\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/webp\"\n  />\n  <!-- Desktop: landscape crop (2:1) -->\n  <source\n    srcset=\"/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/avif\"\n  />\n  <source\n    srcset=\"/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/webp\"\n  />\n  <img\n    src=\"/hero-desktop.jpg\"\n    width=\"1200\"\n    height=\"600\"\n    fetchpriority=\"high\"\n    alt=\"Hero image description\"\n  />\n</picture>\n\n<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->\n<img\n  src=\"/content.webp\"\n  width=\"800\"\n  height=\"400\"\n  loading=\"lazy\"\n  decoding=\"async\"\n  alt=\"Content image description\"\n/>\n```\n\n#### Unnecessary Re-renders (React)\n\n```tsx\n// BAD: Creates new object on every render, causing children to re-render\nfunction TaskList() {\n  return <TaskFilters options={{sortBy: 'date', order: 'desc'}} />\n}\n\n// GOOD: Stable reference\nconst DEFAULT_OPTIONS = {sortBy: 'date', order: 'desc'} as const\nfunction TaskList() {\n  return <TaskFilters options={DEFAULT_OPTIONS} />\n}\n\n// Use React.memo for expensive components\nconst TaskItem = React.memo(function TaskItem({task}: Props) {\n  return <div>{/* expensive render */}</div>\n})\n\n// Use useMemo for expensive computations\nfunction TaskStats({tasks}: Props) {\n  const stats = useMemo(() => calculateStats(tasks), [tasks])\n  return (\n    <div>\n      {stats.completed} / {stats.total}\n    </div>\n  )\n}\n```\n\n#### Large Bundle Size\n\n```typescript\n// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,\n// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.\n// Profile before changing import styles — the real gains come from splitting and lazy loading.\n\n// GOOD: Dynamic import for heavy, rarely-used features\nconst ChartLibrary = lazy(() => import('./ChartLibrary'));\n\n// GOOD: Route-level code splitting wrapped in Suspense\nconst SettingsPage = lazy(() => import('./pages/Settings'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spinner />}>\n      <SettingsPage />\n    </Suspense>\n  );\n}\n```\n\n#### Missing Caching (Backend)\n\n```typescript\n// Cache frequently-read, rarely-changed data\nconst CACHE_TTL = 5 * 60 * 1000 // 5 minutes\nlet cachedConfig: AppConfig | null = null\nlet cacheExpiry = 0\n\nasync function getAppConfig(): Promise<AppConfig> {\n  if (cachedConfig && Date.now() < cacheExpiry) {\n    return cachedConfig\n  }\n  cachedConfig = await db.config.findFirst()\n  cacheExpiry = Date.now() + CACHE_TTL\n  return cachedConfig\n}\n\n// HTTP caching headers for static assets\napp.use(\n  '/static',\n  express.static('public', {\n    maxAge: '1y', // Cache for 1 year\n    immutable: true, // Never revalidate (use content hashing in filenames)\n  }),\n)\n\n// Cache-Control for API responses\nres.set('Cache-Control', 'public, max-age=300') // 5 minutes\n```\n\n## Performance Budget\n\nSet budgets and enforce them:\n\n```\nJavaScript bundle: < 200KB gzipped (initial load)\nCSS: < 50KB gzipped\nImages: < 200KB per image (above the fold)\nFonts: < 100KB total\nAPI response time: < 200ms (p95)\nTime to Interactive: < 3.5s on 4G\nLighthouse Performance score: ≥ 90\n```\n\n**Enforce in CI:**\n\n```bash\n# Bundle size check\nnpx bundlesize --config bundlesize.config.json\n\n# Lighthouse CI\nnpx lhci autorun\n```\n\n## See Also\n\nFor detailed performance checklists, optimization commands, and anti-pattern reference, see `references/performance-checklist.md`.\n\n## Common Rationalizations\n\n| Rationalization                     | Reality                                                                                |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| \"We'll optimize later\"              | Performance debt compounds. Fix obvious anti-patterns now, defer micro-optimizations.  |\n| \"It's fast on my machine\"           | Your machine isn't the user's. Profile on representative hardware and networks.        |\n| \"This optimization is obvious\"      | If you didn't measure, you don't know. Profile first.                                  |\n| \"Users won't notice 100ms\"          | Research shows 100ms delays impact conversion rates. Users notice more than you think. |\n| \"The framework handles performance\" | Frameworks prevent some issues but can't fix N+1 queries or oversized bundles.         |\n\n## Red Flags\n\n- Optimization without profiling data to justify it\n- N+1 query patterns in data fetching\n- List endpoints without pagination\n- Images without dimensions, lazy loading, or responsive sizes\n- Bundle size growing without review\n- No performance monitoring in production\n- `React.memo` and `useMemo` everywhere (overusing is as bad as underusing)\n\n## Verification\n\nAfter any performance-related change:\n\n- [ ] Before and after measurements exist (specific numbers)\n- [ ] The specific bottleneck is identified and addressed\n- [ ] Core Web Vitals are within \"Good\" thresholds\n- [ ] Bundle size hasn't increased significantly\n- [ ] No N+1 queries in new data fetching code\n- [ ] Performance budget passes in CI (if configured)\n- [ ] Existing tests still pass (optimization didn't break behavior)\n",".agents/skills/playwright-best-practices/LICENSE.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FLICENSE.md -->",".agents/skills/playwright-best-practices/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FREADME.md -->",".agents/skills/playwright-best-practices/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FSKILL.md -->",".agents/skills/playwright-best-practices/advanced/authentication-flows.md":"# Complex Authentication Flow Patterns\n\n## Table of Contents\n\n1. [Email Verification Flows](#email-verification-flows)\n2. [Password Reset](#password-reset)\n3. [Session Timeout](#session-timeout)\n4. [Remember Me Persistence](#remember-me-persistence)\n5. [Logout Patterns](#logout-patterns)\n6. [Tips](#tips)\n7. [Related](#related)\n\n> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).\n\n---\n\n## Email Verification Flows\n\n### Capturing Verification Tokens\n\nIntercept API responses to capture verification tokens for testing:\n\n```typescript\ntest('completes registration with email verification', async ({page}) => {\n  let capturedToken = ''\n\n  await page.route('**/api/auth/register', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    capturedToken = body.verificationToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Name').fill('New User')\n  await page.getByLabel('Email').fill('newuser@test.com')\n  await page.getByLabel('Password', {exact: true}).fill('SecurePass!')\n  await page.getByLabel('Confirm password').fill('SecurePass!')\n  await page.getByRole('button', {name: 'Create account'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  expect(capturedToken).toBeTruthy()\n  await page.goto(`/verify?token=${capturedToken}`)\n\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n### Fully Mocked Verification\n\n```typescript\ntest('verifies email with mocked endpoints', async ({page}) => {\n  const mockToken = 'test-verification-abc123'\n\n  await page.route('**/api/auth/register', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Verification sent', verificationToken: mockToken}),\n    })\n  })\n\n  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({verified: true}),\n    })\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password', {exact: true}).fill('Password123!')\n  await page.getByRole('button', {name: 'Sign up'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  await page.goto(`/verify?token=${mockToken}`)\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n---\n\n## Password Reset\n\n### Complete Reset Flow\n\n```typescript\ntest('resets password through email link', async ({page}) => {\n  let resetToken = ''\n\n  await page.route('**/api/auth/forgot-password', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    resetToken = body.resetToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/forgot-password')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByRole('button', {name: 'Send link'}).click()\n\n  await expect(page.getByText('Reset email sent')).toBeVisible()\n\n  expect(resetToken).toBeTruthy()\n  await page.goto(`/reset-password?token=${resetToken}`)\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPassword456!')\n  await page.getByLabel('Confirm password').fill('NewPassword456!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText('Password updated')).toBeVisible()\n})\n```\n\n### Expired Token Handling\n\n```typescript\ntest('shows error for expired reset token', async ({page}) => {\n  await page.goto('/reset-password?token=expired-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPass!')\n  await page.getByLabel('Confirm password').fill('NewPass!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i)\n})\n```\n\n### Password Strength Validation\n\n```typescript\ntest('enforces password requirements on reset', async ({page}) => {\n  await page.goto('/reset-password?token=valid-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('weak')\n  await page.getByLabel('Confirm password').fill('weak')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()\n})\n```\n\n---\n\n## Session Timeout\n\n### Detecting Expired Sessions\n\n```typescript\ntest('redirects to signin after session expires', async ({page, context}) => {\n  await page.goto('/signin')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByLabel('Password').fill('Password!')\n  await page.getByRole('button', {name: 'Sign in'}).click()\n  await expect(page).toHaveURL('/home')\n\n  const cookies = await context.cookies()\n  const sessionCookie = cookies.find((c) => c.name.includes('session'))\n\n  if (sessionCookie) {\n    await context.clearCookies({name: sessionCookie.name})\n  }\n\n  await page.goto('/profile')\n  await expect(page).toHaveURL(/\\/signin/)\n  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible()\n})\n```\n\n### Session Extension Warning\n\n```typescript\ntest('shows warning before session expires', async ({page}) => {\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByText(/session.*expir/i)).toBeVisible({timeout: 10000})\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible()\n})\n```\n\n### Session Extension Action\n\n```typescript\ntest('extends session when user clicks extend', async ({page}) => {\n  let sessionExtended = false\n\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.route('**/api/auth/refresh', async (route) => {\n    sessionExtended = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 3600}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible({\n    timeout: 10000,\n  })\n  await page.getByRole('button', {name: /extend|stay signed in/i}).click()\n\n  expect(sessionExtended).toBe(true)\n  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()\n})\n```\n\n---\n\n## Remember Me Persistence\n\n### Persistent Session\n\n```typescript\ntest('persists session with remember me enabled', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  await page1.getByLabel('Keep me signed in').check()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  const state = await ctx1.storageState()\n  await ctx1.close()\n\n  const ctx2 = await browser.newContext({storageState: state})\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n  await expect(page2).toHaveURL('/home')\n  await expect(page2.getByText('Welcome')).toBeVisible()\n\n  await ctx2.close()\n})\n```\n\n### Session-Only Login\n\n```typescript\ntest('session-only login does not persist across browser restarts', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  // Leave \"Remember me\" unchecked\n  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  // Only keep persistent cookies (filter out session cookies)\n  const cookies = await ctx1.cookies()\n  await ctx1.close()\n\n  const persistentCookies = cookies.filter((c) => c.expires > 0)\n  const ctx2 = await browser.newContext()\n  await ctx2.addCookies(persistentCookies)\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n\n  // Should redirect to login since session was not persisted\n  await expect(page2).toHaveURL(/\\/signin/)\n\n  await ctx2.close()\n})\n```\n\n---\n\n## Logout Patterns\n\n### Standard Logout with Session Cleanup\n\n```typescript\ntest.use({storageState: '.auth/user.json'})\n\ntest('logs out and clears session', async ({page, context}) => {\n  await page.goto('/home')\n\n  await page.getByRole('button', {name: /account|menu/i}).click()\n  await page.getByRole('menuitem', {name: 'Sign out'}).click()\n\n  await expect(page).toHaveURL('/signin')\n\n  const cookies = await context.cookies()\n  const sessionCookies = cookies.filter(\n    (c) => c.name.includes('session') || c.name.includes('token'),\n  )\n  expect(sessionCookies).toHaveLength(0)\n\n  await page.goto('/home')\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n### Logout from All Devices\n\n```typescript\ntest('logs out from all devices', async ({page}) => {\n  let logoutAllCalled = false\n\n  await page.route('**/api/auth/logout-all', async (route) => {\n    logoutAllCalled = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Logged out everywhere'}),\n    })\n  })\n\n  await page.goto('/settings/security')\n\n  await page.getByRole('button', {name: 'Sign out everywhere'}).click()\n  await page.getByRole('dialog').getByRole('button', {name: 'Confirm'}).click()\n\n  expect(logoutAllCalled).toBe(true)\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n---\n\n## Tips\n\n1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests\n2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens\n3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared\n4. **Test the full flow end-to-end** — Password reset should verify login with new password works\n\n---\n\n## Related\n\n- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures\n- [third-party.md](./third-party.md) — Mocking external auth providers\n",".agents/skills/playwright-best-practices/advanced/authentication.md":"# Authentication Testing\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.\n\n## Quick Reference\n\n```typescript\n// Storage state reuse — the #1 pattern for fast auth\nawait page.goto('/login')\nawait page.getByLabel('Username').fill('testuser@example.com')\nawait page.getByLabel('Password').fill('secretPass123')\nawait page.getByRole('button', {name: 'Log in'}).click()\nawait page.context().storageState({path: '.auth/session.json'})\n\n// Reuse in config — every test starts authenticated\n{\n  use: {\n    storageState: '.auth/session.json'\n  }\n}\n\n// API login — skip the UI entirely\nconst context = await browser.newContext()\nconst response = await context.request.post('/api/auth/login', {\n  data: {email: 'testuser@example.com', password: 'secretPass123'},\n})\nawait context.storageState({path: '.auth/session.json'})\n```\n\n## Patterns\n\n### Storage State Reuse\n\n**Use when**: You need authenticated tests and want to avoid logging in before every test.\n**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.\n\n`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.\n\n```typescript\n// scripts/generate-auth.ts — run once to generate the state file\nimport {chromium} from '@playwright/test'\n\nasync function generateAuthState() {\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto('http://localhost:4000/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\ngenerateAuthState()\n```\n\n```typescript\n// playwright.config.ts — load saved state for all tests\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\n```typescript\n// tests/home.spec.ts — test starts already logged in\nimport {test, expect} from '@playwright/test'\n\ntest('authenticated user sees home page', async ({page}) => {\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n### Global Setup Authentication\n\n**Use when**: You want to authenticate once before the entire test suite runs.\n**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.\n\n```typescript\n// global-setup.ts\nimport {chromium, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto(`${baseURL}/login`)\n  await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('**/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\nAdd `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.\n\n### Per-Worker Authentication\n\n**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.\n**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.\n\n> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.\n\n```typescript\n// fixtures/auth.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedContext: BrowserContext\n}\n\nexport const test = base.extend<{}, AuthFixtures>({\n  authenticatedContext: [\n    async ({browser}, use) => {\n      const context = await browser.newContext()\n      const page = await context.newPage()\n\n      await page.goto('/login')\n      await page.getByLabel('Username').fill(`worker-${test.info().parallelIndex}@example.com`)\n      await page.getByLabel('Password').fill('secretPass123')\n      await page.getByRole('button', {name: 'Log in'}).click()\n      await page.waitForURL('/home')\n      await page.close()\n\n      await use(context)\n      await context.close()\n    },\n    {scope: 'worker'},\n  ],\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/settings.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('update display name', async ({authenticatedContext}) => {\n  const page = await authenticatedContext.newPage()\n  await page.goto('/settings/profile')\n  await page.getByLabel('Display name').fill('Updated Name')\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expect(page.getByText('Profile saved')).toBeVisible()\n})\n```\n\n### Multiple Roles\n\n**Use when**: Your app has role-based access control and you need to test different permission levels.\n**Avoid when**: Your app has a single user role.\n\n```typescript\n// global-setup.ts — authenticate all roles\nimport {chromium, type FullConfig} from '@playwright/test'\n\nconst accounts = [\n  {\n    role: 'admin',\n    email: 'admin@example.com',\n    password: process.env.ADMIN_PASSWORD!,\n  },\n  {\n    role: 'member',\n    email: 'member@example.com',\n    password: process.env.MEMBER_PASSWORD!,\n  },\n  {\n    role: 'guest',\n    email: 'guest@example.com',\n    password: process.env.GUEST_PASSWORD!,\n  },\n]\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  for (const {role, email, password} of accounts) {\n    const browser = await chromium.launch()\n    const context = await browser.newContext()\n    const page = await context.newPage()\n\n    await page.goto(`${baseURL}/login`)\n    await page.getByLabel('Username').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('**/home')\n\n    await context.storageState({path: `.auth/${role}.json`})\n    await browser.close()\n  }\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts — one project per role\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  projects: [\n    {\n      name: 'admin',\n      use: {storageState: '.auth/admin.json'},\n      testMatch: '**/*.admin.spec.ts',\n    },\n    {\n      name: 'member',\n      use: {storageState: '.auth/member.json'},\n      testMatch: '**/*.member.spec.ts',\n    },\n    {\n      name: 'guest',\n      use: {storageState: '.auth/guest.json'},\n      testMatch: '**/*.guest.spec.ts',\n    },\n    {\n      name: 'anonymous',\n      use: {storageState: {cookies: [], origins: []}},\n      testMatch: '**/*.anon.spec.ts',\n    },\n  ],\n})\n```\n\n```typescript\n// tests/admin-panel.admin.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('admin can access user management', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByRole('heading', {name: 'User Management'})).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Remove user'})).toBeEnabled()\n})\n```\n\n```typescript\n// tests/admin-panel.guest.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('guest cannot access admin panel', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n})\n```\n\n**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth.ts — role-based fixture\nimport {test as base, type Page} from '@playwright/test'\n\ntype RoleFixtures = {\n  loginAs: (role: 'admin' | 'member' | 'guest') => Promise<Page>\n}\n\nexport const test = base.extend<RoleFixtures>({\n  loginAs: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (role) => {\n      const statePath = `.auth/${role}.json`\n      if (!fs.existsSync(statePath)) {\n        throw new Error(`Auth state for role \"${role}\" not found at ${statePath}`)\n      }\n      const context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/role-comparison.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('admin sees remove button, guest does not', async ({loginAs}) => {\n  const adminPage = await loginAs('admin')\n  await adminPage.goto('/admin/users')\n  await expect(adminPage.getByRole('button', {name: 'Remove user'})).toBeVisible()\n\n  const guestPage = await loginAs('guest')\n  await guestPage.goto('/admin/users')\n  await expect(guestPage.getByText('Access denied')).toBeVisible()\n})\n```\n\n### OAuth/SSO Mocking\n\n**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.\n**Avoid when**: You have a dedicated test tenant on the OAuth provider.\n\nA typical OAuth flow works like this:\n\n1. User clicks \"Sign in with Provider\" → browser navigates to `https://accounts.provider.com/authorize?...`\n2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)\n3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page\n\nIn tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.\n\nFor cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.\n\n```typescript\n// tests/oauth-login.spec.ts — mock the callback route\nimport {test, expect} from '@playwright/test'\n\ntest('login via mocked OAuth flow', async ({page}) => {\n  await page.route('https://accounts.provider.com/**', async (route) => {\n    const callbackUrl = new URL('http://localhost:4000/auth/callback')\n    callbackUrl.searchParams.set('code', 'mock-auth-code-xyz')\n    callbackUrl.searchParams.set('state', 'expected-state-value')\n    await route.fulfill({\n      status: 302,\n      headers: {location: callbackUrl.toString()},\n    })\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Provider'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n```typescript\n// tests/oauth-login.spec.ts — API-based session injection\nimport {test, expect} from '@playwright/test'\n\ntest('bypass OAuth entirely via API session injection', async ({page}) => {\n  // Call a test-only endpoint that creates a session without OAuth\n  const response = await page.request.post('/api/test/create-session', {\n    data: {\n      email: 'oauth-user@example.com',\n      provider: 'provider',\n      role: 'member',\n    },\n  })\n  expect(response.ok()).toBeTruthy()\n\n  await page.context().storageState({path: '.auth/oauth-user.json'})\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.\n\n### MFA Handling\n\n**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).\n**Avoid when**: MFA is optional and you can disable it for test accounts.\n\n**Strategy 1**: Generate real TOTP codes from a shared secret.\n\n```typescript\n// helpers/totp.ts\nimport * as OTPAuth from 'otpauth'\n\nexport function generateTOTP(secret: string): string {\n  const totp = new OTPAuth.TOTP({\n    secret: OTPAuth.Secret.fromBase32(secret),\n    digits: 6,\n    period: 30,\n    algorithm: 'SHA1',\n  })\n  return totp.generate()\n}\n```\n\n```typescript\n// tests/mfa-login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {generateTOTP} from '../helpers/totp'\n\ntest('login with TOTP two-factor auth', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('mfa-user@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n\n  await expect(page.getByText('Enter your authentication code')).toBeVisible()\n\n  const code = generateTOTP(process.env.MFA_TOTP_SECRET!)\n  await page.getByLabel('Authentication code').fill(code)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.\n\n**Strategy 3**: Disable MFA for test accounts at the infrastructure level.\n\n### Session Refresh\n\n**Use when**: Your tokens expire during long test runs.\n**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth-with-refresh.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<AuthFixtures>({\n  authenticatedPage: async ({browser}, use) => {\n    const statePath = '.auth/session.json'\n\n    let context: BrowserContext\n    if (fs.existsSync(statePath)) {\n      context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n\n      const response = await page.request.get('/api/auth/me')\n      if (response.ok()) {\n        await use(page)\n        await context.close()\n        return\n      }\n      await context.close()\n    }\n\n    context = await browser.newContext()\n    const page = await context.newPage()\n    await page.goto('/login')\n    await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n    await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('/home')\n\n    await context.storageState({path: statePath})\n\n    await use(page)\n    await context.close()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Login Page Object\n\n**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.\n**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.\n\n```typescript\n// page-objects/LoginPage.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly usernameInput: Locator\n  readonly passwordInput: Locator\n  readonly loginButton: Locator\n  readonly errorMessage: Locator\n  readonly forgotPasswordLink: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.usernameInput = page.getByLabel('Username')\n    this.passwordInput = page.getByLabel('Password')\n    this.loginButton = page.getByRole('button', {name: 'Log in'})\n    this.errorMessage = page.getByRole('alert')\n    this.forgotPasswordLink = page.getByRole('link', {\n      name: 'Forgot password',\n    })\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n    await expect(this.loginButton).toBeVisible()\n  }\n\n  async login(username: string, password: string) {\n    await this.usernameInput.fill(username)\n    await this.passwordInput.fill(password)\n    await this.loginButton.click()\n  }\n\n  async loginAndWaitForHome(username: string, password: string) {\n    await this.login(username, password)\n    await this.page.waitForURL('/home')\n  }\n\n  async expectError(message: string | RegExp) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n\n  async expectFieldError(field: 'username' | 'password', message: string) {\n    const input = field === 'username' ? this.usernameInput : this.passwordInput\n    await expect(input).toHaveAttribute('aria-invalid', 'true')\n    const errorId = await input.getAttribute('aria-describedby')\n    if (errorId) {\n      await expect(this.page.locator(`#${errorId}`)).toContainText(message)\n    }\n  }\n}\n```\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../page-objects/LoginPage'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('login page', () => {\n  let loginPage: LoginPage\n\n  test.beforeEach(async ({page}) => {\n    loginPage = new LoginPage(page)\n    await loginPage.goto()\n  })\n\n  test('successful login redirects to home', async ({page}) => {\n    await loginPage.loginAndWaitForHome('testuser@example.com', 'secretPass123')\n    await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  })\n\n  test('wrong password shows error', async () => {\n    await loginPage.login('testuser@example.com', 'wrong-password')\n    await loginPage.expectError('Invalid username or password')\n  })\n\n  test('empty fields show validation errors', async () => {\n    await loginPage.loginButton.click()\n    await loginPage.expectFieldError('username', 'Username is required')\n  })\n\n  test('forgot password link navigates correctly', async ({page}) => {\n    await loginPage.forgotPasswordLink.click()\n    await page.waitForURL('/forgot-password')\n    await expect(page.getByRole('heading', {name: 'Reset password'})).toBeVisible()\n  })\n})\n```\n\n### API-Based Login\n\n**Use when**: You want the fastest possible authentication without any browser interaction.\n**Avoid when**: You are specifically testing the login UI.\n\nAPI login is typically 5-10x faster than UI login.\n\n```typescript\n// global-setup.ts — API-based login (fastest)\nimport {request, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  const requestContext = await request.newContext({baseURL})\n\n  const response = await requestContext.post('/api/auth/login', {\n    data: {\n      email: process.env.TEST_USER_EMAIL!,\n      password: process.env.TEST_USER_PASSWORD!,\n    },\n  })\n\n  if (!response.ok()) {\n    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)\n  }\n\n  await requestContext.storageState({path: '.auth/session.json'})\n  await requestContext.dispose()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// fixtures/api-auth.ts — fixture version for per-test authentication\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend({\n  authenticatedPage: async ({browser, playwright}, use) => {\n    const apiContext = await playwright.request.newContext({\n      baseURL: 'http://localhost:4000',\n    })\n\n    await apiContext.post('/api/auth/login', {\n      data: {\n        email: 'testuser@example.com',\n        password: 'secretPass123',\n      },\n    })\n\n    const state = await apiContext.storageState()\n    const context = await browser.newContext({storageState: state})\n    const page = await context.newPage()\n\n    await use(page)\n\n    await context.close()\n    await apiContext.dispose()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Unauthenticated Tests\n\n**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.\n**Avoid when**: The test requires a logged-in user.\n\nWhen your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.\n\n```typescript\n// tests/public-pages.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('unauthenticated access', () => {\n  test('homepage is accessible without login', async ({page}) => {\n    await page.goto('/')\n    await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()\n    await expect(page.getByRole('link', {name: 'Log in'})).toBeVisible()\n  })\n\n  test('protected route redirects to login', async ({page}) => {\n    await page.goto('/home')\n    await page.waitForURL('**/login**')\n    expect(page.url()).toContain('redirect=%2Fhome')\n  })\n\n  test('expired session shows re-login prompt', async ({page, context}) => {\n    await page.goto('/home')\n    await context.clearCookies()\n\n    await page.goto('/settings')\n    await page.waitForURL('**/login**')\n    await expect(page.getByText('Your session has expired')).toBeVisible()\n  })\n\n  test('signup flow creates account', async ({page}) => {\n    await page.goto('/signup')\n    await page.getByLabel('Name').fill('New User')\n    await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`)\n    await page.getByLabel('Password', {exact: true}).fill('secretPass123')\n    await page.getByLabel('Confirm password').fill('secretPass123')\n    await page.getByRole('button', {name: 'Create account'}).click()\n\n    await page.waitForURL('/onboarding')\n    await expect(page.getByText('Welcome, New User')).toBeVisible()\n  })\n})\n```\n\n## Decision Guide\n\n| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                 |\n| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |\n| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project                               |\n| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data          |\n| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/member/guest roles                               |\n| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` |\n| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI                           |\n| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes or use a test-mode bypass             |\n| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use                       |\n| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles                                 |\n| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth                                     |\n\n### UI Login vs API Login vs Storage State\n\n```text\nNeed to test the login page itself?\n├── Yes → UI login with LoginPage POM, no storageState\n└── No → Do you have a login API endpoint?\n    ├── Yes → API login in global setup, save storageState (fastest)\n    └── No → UI login in global setup, save storageState\n              └── Tokens expire quickly?\n                  ├── Yes → Add session refresh fixture\n                  └── No → Standard storageState reuse is fine\n```\n\n## Anti-Patterns\n\n| Don't Do This                                                             | Problem                                     | Do This Instead                                                           |\n| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |\n| Log in via UI before every test                                           | Adds 2-5 seconds per test                   | Use `storageState` to skip login entirely                                 |\n| Share a single auth state file across parallel workers that mutate state  | Race conditions                             | Use per-worker fixtures with `{ scope: 'worker' }`                        |\n| Hardcode credentials in test files                                        | Security risk                               | Use environment variables and `.env` files                                |\n| Ignore token expiration                                                   | Tests fail intermittently with 401 errors   | Add a session validity check in your auth fixture                         |\n| Hit real OAuth providers in CI                                            | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection                      |\n| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay                             | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |\n| Store `.auth/*.json` files in git                                         | Tokens in version control                   | Add `.auth/` to `.gitignore`                                              |\n| Create one \"god\" test account with all permissions                        | Cannot test role-based access control       | Create separate accounts per role                                         |\n| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated        | Pass `storageState` when creating the context                             |\n| Test MFA by disabling it everywhere                                       | You never test the MFA flow                 | Use TOTP generation for at least one test                                 |\n\n## Troubleshooting\n\n### Global setup fails with \"Target page, context or browser has been closed\"\n\n**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.\n\n**Fix**:\n\n- Add `await page.waitForURL()` after the login action\n- Check that `baseURL` in your config matches the actual server URL and protocol\n- Add error handling to global setup:\n\n```typescript\nconst response = await page.waitForResponse('**/api/auth/**')\nif (!response.ok()) {\n  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)\n}\n```\n\n### Tests fail with 401 Unauthorized after running for a while\n\n**Cause**: The session token saved in `storageState` has expired.\n\n**Fix**:\n\n- Use the session refresh fixture pattern\n- Increase token expiry in test environment configuration\n- Switch to API-based login in a worker-scoped fixture\n\n### `storageState` file is empty or contains no cookies\n\n**Cause**: `storageState()` was called before the login response set cookies.\n\n**Fix**:\n\n- Wait for the post-login page to load: `await page.waitForURL('/home')`\n- Verify cookies exist before saving:\n\n```typescript\nconst cookies = await context.cookies()\nif (cookies.length === 0) {\n  throw new Error('No cookies found after login')\n}\nawait context.storageState({path: '.auth/session.json'})\n```\n\n### Different browsers get different cookies\n\n**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.\n\n**Fix**:\n\n- Generate separate auth state files per browser project\n- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:\n\n```typescript\nprojects: [\n  {\n    name: 'chromium',\n    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },\n  },\n  {\n    name: 'firefox',\n    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },\n  },\n],\n```\n\n### Parallel tests interfere with each other's sessions\n\n**Cause**: Multiple workers share the same test account and one worker's actions affect others.\n\n**Fix**:\n\n- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`\n- Use the per-worker authentication fixture pattern\n- Make tests idempotent\n\n### OAuth mock does not work — still redirects to real provider\n\n**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.\n\n**Fix**:\n\n- Register route handlers before any navigation: call `page.route()` before `page.goto()`\n- Log the actual redirect URL to verify the pattern:\n\n```typescript\npage.on('request', (req) => {\n  if (req.url().includes('oauth') || req.url().includes('accounts.provider')) {\n    console.log('OAuth request:', req.url())\n  }\n})\n```\n\n## Related\n\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown\n- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration\n- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies\n- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking\n- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login\n- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness\n",".agents/skills/playwright-best-practices/advanced/clock-mocking.md":"# Date, Time & Clock Mocking\n\n## Table of Contents\n\n1. [Clock API Basics](#clock-api-basics)\n2. [Fixed Time Testing](#fixed-time-testing)\n3. [Time Advancement](#time-advancement)\n4. [Timezone Testing](#timezone-testing)\n5. [Timer Mocking](#timer-mocking)\n\n## Clock API Basics\n\n### Install Clock\n\n```typescript\ntest('mock current time', async ({page}) => {\n  // Install clock before navigating\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  await page.goto('/dashboard')\n\n  // Page sees January 15, 2025 as current date\n  await expect(page.getByText('January 15, 2025')).toBeVisible()\n})\n```\n\n### Clock with Fixture\n\n```typescript\n// fixtures/clock.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClockFixtures = {\n  mockTime: (date: Date | string) => Promise<void>\n}\n\nexport const test = base.extend<ClockFixtures>({\n  mockTime: async ({page}, use) => {\n    await use(async (date) => {\n      const time = typeof date === 'string' ? new Date(date) : date\n      await page.clock.install({time})\n    })\n  },\n})\n\n// Usage\ntest('subscription expiry', async ({page, mockTime}) => {\n  await mockTime('2025-12-31T23:59:00')\n  await page.goto('/subscription')\n\n  await expect(page.getByText('Expires today')).toBeVisible()\n})\n```\n\n## Fixed Time Testing\n\n### Test Date-Dependent Features\n\n```typescript\ntest('show holiday banner in December', async ({page}) => {\n  await page.clock.install({time: new Date('2025-12-20T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeVisible()\n})\n\ntest('no holiday banner in January', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeHidden()\n})\n```\n\n### Test Relative Time Display\n\n```typescript\ntest('shows relative time correctly', async ({page}) => {\n  // Fix time to control \"posted 2 hours ago\" text\n  await page.clock.install({time: new Date('2025-06-15T14:00:00')})\n\n  // Mock API to return post with known timestamp\n  await page.route('**/api/posts/1', (route) =>\n    route.fulfill({\n      json: {\n        id: 1,\n        title: 'Test Post',\n        createdAt: '2025-06-15T12:00:00Z', // 2 hours before mock time\n      },\n    }),\n  )\n\n  await page.goto('/posts/1')\n\n  await expect(page.getByText('2 hours ago')).toBeVisible()\n})\n```\n\n### Test Date Boundaries\n\n```typescript\ntest.describe('end of month billing', () => {\n  test('shows billing on last day of month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-31T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('Payment due today')).toBeVisible()\n  })\n\n  test('shows days remaining mid-month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('16 days until payment')).toBeVisible()\n  })\n})\n```\n\n## Time Advancement\n\n### Advance Time Manually\n\n```typescript\ntest('session timeout warning', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/dashboard')\n\n  // Advance 25 minutes (session timeout at 30 min)\n  await page.clock.fastForward('25:00')\n\n  await expect(page.getByText('Session expires in 5 minutes')).toBeVisible()\n\n  // Advance 5 more minutes\n  await page.clock.fastForward('05:00')\n\n  await expect(page.getByText('Session expired')).toBeVisible()\n})\n```\n\n### Pause and Resume Time\n\n```typescript\ntest('countdown timer', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/sale')\n\n  // Initial state\n  await expect(page.getByText('Sale ends in 2:00:00')).toBeVisible()\n\n  // Advance 1 hour\n  await page.clock.fastForward('01:00:00')\n\n  await expect(page.getByText('Sale ends in 1:00:00')).toBeVisible()\n\n  // Advance past end\n  await page.clock.fastForward('01:00:01')\n\n  await expect(page.getByText('Sale ended')).toBeVisible()\n})\n```\n\n### Run Pending Timers\n\n```typescript\ntest('debounced search', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/search')\n\n  await page.getByLabel('Search').fill('playwright')\n\n  // Search is debounced by 300ms, won't fire yet\n  await expect(page.getByTestId('search-results')).toBeHidden()\n\n  // Fast forward past debounce\n  await page.clock.fastForward(300)\n\n  // Now search should execute\n  await expect(page.getByTestId('search-results')).toBeVisible()\n})\n```\n\n## Timezone Testing\n\n### Test Different Timezones\n\n```typescript\ntest.describe('timezone display', () => {\n  test('shows correct time in PST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'America/Los_Angeles',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 9 AM PST\n    await expect(page.getByText('9:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n\n  test('shows correct time in JST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'Asia/Tokyo',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 2 AM next day JST\n    await expect(page.getByText('2:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n})\n```\n\n### Timezone Fixture\n\n```typescript\n// fixtures/timezone.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype TimezoneFixtures = {\n  pageInTimezone: (timezone: string) => Promise<Page>\n}\n\nexport const test = base.extend<TimezoneFixtures>({\n  pageInTimezone: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (timezone) => {\n      const context = await browser.newContext({timezoneId: timezone})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    // Cleanup\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n```\n\n## Timer Mocking\n\n### Mock setInterval\n\n```typescript\ntest('auto-refresh data', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  let apiCalls = 0\n  await page.route('**/api/data', (route) => {\n    apiCalls++\n    route.fulfill({json: {value: apiCalls}})\n  })\n\n  await page.goto('/live-data') // Sets up 30s refresh interval\n\n  expect(apiCalls).toBe(1) // Initial load\n\n  // Advance 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(2) // First refresh\n\n  // Advance another 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(3) // Second refresh\n})\n```\n\n### Mock setTimeout Chains\n\n```typescript\ntest('notification queue', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/notifications')\n\n  // Trigger 3 notifications that show sequentially\n  await page.getByRole('button', {name: 'Show All'}).click()\n\n  // First notification appears immediately\n  await expect(page.getByText('Notification 1')).toBeVisible()\n\n  // Second appears after 2 seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 2')).toBeVisible()\n\n  // Third appears after 2 more seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 3')).toBeVisible()\n})\n```\n\n### Test Animation Frames\n\n```typescript\ntest('animation completes', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/animation-demo')\n\n  await page.getByRole('button', {name: 'Animate'}).click()\n\n  // Animation runs for 500ms\n  const element = page.getByTestId('animated-box')\n  await expect(element).toHaveCSS('opacity', '0')\n\n  // Fast forward through animation\n  await page.clock.fastForward(500)\n\n  await expect(element).toHaveCSS('opacity', '1')\n})\n```\n\n## Best Practices\n\n### Always Install Clock Before Navigation\n\n```typescript\n// Good\ntest('date test', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15')})\n  await page.goto('/') // Page loads with mocked time\n})\n\n// Bad - time already captured by page\ntest('date test', async ({page}) => {\n  await page.goto('/')\n  await page.clock.install({time: new Date('2025-01-15')}) // Too late!\n})\n```\n\n### Use ISO Strings for Clarity\n\n```typescript\n// Good - explicit timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00Z')})\n\n// Ambiguous - uses local timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00')})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                             | Problem                         | Solution                               |\n| ---------------------------------------- | ------------------------------- | -------------------------------------- |\n| Installing clock after navigation        | Page already captured real time | Install clock before `goto()`          |\n| Hardcoded relative dates                 | Tests break over time           | Use fixed dates with clock mock        |\n| Not accounting for timezone              | Tests fail in different regions | Use explicit UTC times or set timezone |\n| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers    | Use `fastForward` instead              |\n\n## Related References\n\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures\n",".agents/skills/playwright-best-practices/advanced/mobile-testing.md":"# Mobile & Responsive Testing\n\n## Table of Contents\n\n1. [Device Emulation](#device-emulation)\n2. [Touch Gestures](#touch-gestures)\n3. [Viewport Testing](#viewport-testing)\n4. [Mobile-Specific UI](#mobile-specific-ui)\n5. [Responsive Breakpoints](#responsive-breakpoints)\n\n## Device Emulation\n\n### Use Built-in Devices\n\n```typescript\nimport {test, devices} from '@playwright/test'\n\n// Configure in playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'Desktop Chrome', use: {...devices['Desktop Chrome']}},\n    {name: 'Mobile Safari', use: {...devices['iPhone 14']}},\n    {name: 'Mobile Chrome', use: {...devices['Pixel 7']}},\n    {name: 'Tablet', use: {...devices['iPad Pro 11']}},\n  ],\n})\n```\n\n### Custom Device Configuration\n\n```typescript\ntest.use({\n  viewport: {width: 390, height: 844},\n  deviceScaleFactor: 3,\n  isMobile: true,\n  hasTouch: true,\n  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',\n})\n\ntest('custom mobile device', async ({page}) => {\n  await page.goto('/')\n  // Test runs with custom device settings\n})\n```\n\n### Test Across Multiple Devices\n\n```typescript\nconst mobileDevices = ['iPhone 14', 'Pixel 7', 'Galaxy S21']\n\nfor (const deviceName of mobileDevices) {\n  test(`checkout on ${deviceName}`, async ({browser}) => {\n    const device = devices[deviceName]\n    const context = await browser.newContext({...device})\n    const page = await context.newPage()\n\n    await page.goto('/checkout')\n    await expect(page.getByRole('button', {name: 'Pay'})).toBeVisible()\n\n    await context.close()\n  })\n}\n```\n\n## Touch Gestures\n\n### Tap\n\n```typescript\ntest.use({hasTouch: true})\n\ntest('tap to interact', async ({page}) => {\n  await page.goto('/gallery')\n\n  // Tap is like click but for touch devices\n  await page.getByRole('img', {name: 'Photo 1'}).tap()\n\n  await expect(page.getByRole('dialog')).toBeVisible()\n})\n```\n\n### Swipe\n\n```typescript\ntest('swipe carousel', async ({page}) => {\n  await page.goto('/carousel')\n\n  const carousel = page.getByTestId('carousel')\n  const box = await carousel.boundingBox()\n\n  if (box) {\n    // Swipe left\n    await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2)\n    await page.mouse.move(box.x + 50, box.y + box.height / 2)\n\n    // Or use drag\n    await carousel.dragTo(carousel, {\n      sourcePosition: {x: box.width - 50, y: box.height / 2},\n      targetPosition: {x: 50, y: box.height / 2},\n    })\n  }\n\n  await expect(page.getByText('Slide 2')).toBeVisible()\n})\n```\n\n### Swipe Fixture\n\n```typescript\n// fixtures/touch.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype TouchFixtures = {\n  swipe: (element: Locator, direction: 'left' | 'right' | 'up' | 'down') => Promise<void>\n}\n\nexport const test = base.extend<TouchFixtures>({\n  swipe: async ({page}, use) => {\n    await use(async (element, direction) => {\n      const box = await element.boundingBox()\n      if (!box) throw new Error('Element not visible')\n\n      const centerX = box.x + box.width / 2\n      const centerY = box.y + box.height / 2\n      const distance = 100\n\n      const moves = {\n        left: {\n          startX: centerX + distance,\n          endX: centerX - distance,\n          y: centerY,\n        },\n        right: {\n          startX: centerX - distance,\n          endX: centerX + distance,\n          y: centerY,\n        },\n        up: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY + distance,\n          endY: centerY - distance,\n        },\n        down: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY - distance,\n          endY: centerY + distance,\n        },\n      }\n\n      const move = moves[direction]\n      await page.touchscreen.tap(move.startX, move.startY ?? move.y)\n      await page.mouse.move(move.endX, move.endY ?? move.y, {steps: 10})\n      await page.mouse.up()\n    })\n  },\n})\n\n// Usage\ntest('swipe to delete', async ({page, swipe}) => {\n  await page.goto('/inbox')\n\n  const message = page.getByTestId('message-1')\n  await swipe(message, 'left')\n\n  await expect(page.getByRole('button', {name: 'Delete'})).toBeVisible()\n})\n```\n\n### Long Press\n\n```typescript\ntest('long press for context menu', async ({page}) => {\n  await page.goto('/files')\n\n  const file = page.getByText('document.pdf')\n  const box = await file.boundingBox()\n\n  if (box) {\n    // Touch down\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2)\n\n    // Hold for 500ms\n    await page.waitForTimeout(500)\n\n    // Context menu should appear\n    await expect(page.getByRole('menu')).toBeVisible()\n  }\n})\n```\n\n### Pinch Zoom\n\n```typescript\ntest('pinch to zoom image', async ({page}) => {\n  await page.goto('/map')\n\n  // Pinch zoom requires two touch points\n  // Playwright doesn't have native pinch support, so we simulate via evaluate\n  await page.evaluate(() => {\n    const element = document.querySelector('#map')\n    if (element) {\n      // Simulate wheel event as fallback for zoom\n      element.dispatchEvent(\n        new WheelEvent('wheel', {\n          deltaY: -100, // Negative = zoom in\n          ctrlKey: true, // Ctrl+wheel = pinch on many apps\n        }),\n      )\n    }\n  })\n\n  // Or trigger the app's zoom function directly\n  await page.evaluate(() => {\n    ;(window as any).mapInstance?.setZoom(15)\n  })\n})\n```\n\n## Viewport Testing\n\n### Test Different Sizes\n\n```typescript\nconst viewports = [\n  {name: 'mobile', width: 375, height: 667},\n  {name: 'tablet', width: 768, height: 1024},\n  {name: 'desktop', width: 1920, height: 1080},\n]\n\nfor (const {name, width, height} of viewports) {\n  test(`navigation on ${name}`, async ({page}) => {\n    await page.setViewportSize({width, height})\n    await page.goto('/')\n\n    if (width < 768) {\n      // Mobile: should have hamburger menu\n      await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n    } else {\n      // Desktop: should have visible nav links\n      await expect(page.getByRole('link', {name: 'Products'})).toBeVisible()\n    }\n  })\n}\n```\n\n### Dynamic Viewport Changes\n\n```typescript\ntest('responsive layout change', async ({page}) => {\n  await page.setViewportSize({width: 1200, height: 800})\n  await page.goto('/dashboard')\n\n  // Desktop: sidebar visible\n  await expect(page.getByRole('complementary')).toBeVisible()\n\n  // Resize to mobile\n  await page.setViewportSize({width: 375, height: 667})\n\n  // Mobile: sidebar hidden, hamburger visible\n  await expect(page.getByRole('complementary')).toBeHidden()\n  await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n})\n```\n\n## Mobile-Specific UI\n\n### Hamburger Menu\n\n```typescript\ntest('mobile navigation', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/')\n\n  // Open hamburger menu\n  await page.getByRole('button', {name: 'Menu'}).click()\n\n  // Navigation drawer should appear\n  const nav = page.getByRole('navigation')\n  await expect(nav).toBeVisible()\n\n  // Navigate via mobile menu\n  await nav.getByRole('link', {name: 'Products'}).click()\n\n  await expect(page).toHaveURL('/products')\n  // Menu should close after navigation\n  await expect(nav).toBeHidden()\n})\n```\n\n### Bottom Sheet\n\n```typescript\ntest('bottom sheet interaction', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/product/123')\n\n  await page.getByRole('button', {name: 'Add to Cart'}).click()\n\n  // Bottom sheet appears\n  const sheet = page.getByRole('dialog')\n  await expect(sheet).toBeVisible()\n\n  // Select options\n  await sheet.getByRole('combobox', {name: 'Size'}).selectOption('Large')\n  await sheet.getByRole('button', {name: 'Confirm'}).click()\n\n  await expect(page.getByText('Added to cart')).toBeVisible()\n})\n```\n\n### Pull to Refresh\n\n```typescript\ntest('pull to refresh', async ({page}) => {\n  await page.goto('/feed')\n\n  const feed = page.getByTestId('feed')\n  const initialFirstItem = await feed.locator('> *').first().textContent()\n\n  // Simulate pull down\n  const box = await feed.boundingBox()\n  if (box) {\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + 50)\n    await page.mouse.move(box.x + box.width / 2, box.y + 200, {steps: 20})\n    await page.mouse.up()\n  }\n\n  // Wait for refresh\n  await expect(page.getByTestId('loading')).toBeVisible()\n  await expect(page.getByTestId('loading')).toBeHidden()\n\n  // Content should be updated (in a real app)\n})\n```\n\n## Responsive Breakpoints\n\n### Test All Breakpoints\n\n```typescript\nconst breakpoints = {\n  'xs': 320,\n  'sm': 640,\n  'md': 768,\n  'lg': 1024,\n  'xl': 1280,\n  '2xl': 1536,\n}\n\ntest.describe('responsive header', () => {\n  for (const [name, width] of Object.entries(breakpoints)) {\n    test(`header at ${name} (${width}px)`, async ({page}) => {\n      await page.setViewportSize({width, height: 800})\n      await page.goto('/')\n\n      if (width < 768) {\n        await expect(page.getByTestId('mobile-menu-button')).toBeVisible()\n        await expect(page.getByTestId('desktop-nav')).toBeHidden()\n      } else {\n        await expect(page.getByTestId('mobile-menu-button')).toBeHidden()\n        await expect(page.getByTestId('desktop-nav')).toBeVisible()\n      }\n    })\n  }\n})\n```\n\n### Visual Regression at Breakpoints\n\n```typescript\ntest.describe('visual regression', () => {\n  const sizes = [\n    {width: 375, height: 667, name: 'mobile'},\n    {width: 768, height: 1024, name: 'tablet'},\n    {width: 1440, height: 900, name: 'desktop'},\n  ]\n\n  for (const {width, height, name} of sizes) {\n    test(`homepage at ${name}`, async ({page}) => {\n      await page.setViewportSize({width, height})\n      await page.goto('/')\n\n      await expect(page).toHaveScreenshot(`homepage-${name}.png`)\n    })\n  }\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                   | Solution                         |\n| --------------------------- | ------------------------- | -------------------------------- |\n| Only testing one viewport   | Misses responsive bugs    | Test multiple breakpoints        |\n| Ignoring touch events       | Features broken on mobile | Test tap, swipe, long press      |\n| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()`     |\n| Not testing orientation     | Landscape bugs missed     | Test both portrait and landscape |\n\n## Related References\n\n- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing\n- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors\n- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)\n- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements\n",".agents/skills/playwright-best-practices/advanced/multi-context.md":"# Multi-Tab, Window & Popup Testing\n\nThis file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).\n\n## Table of Contents\n\n1. [Popup Handling](#popup-handling)\n2. [New Tab Navigation](#new-tab-navigation)\n3. [OAuth Flows](#oauth-flows)\n4. [Multiple Windows](#multiple-windows)\n5. [Tab Coordination](#tab-coordination)\n\n## Popup Handling\n\n### Basic Popup\n\n```typescript\ntest('handle popup window', async ({page}) => {\n  await page.goto('/')\n\n  // Start waiting for popup before triggering it\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Open Support Chat'}).click()\n  const popup = await popupPromise\n\n  // Wait for popup to load\n  await popup.waitForLoadState()\n\n  // Interact with popup\n  await popup.getByLabel('Message').fill('Need help')\n  await popup.getByRole('button', {name: 'Send'}).click()\n\n  await expect(popup.getByText('Message sent')).toBeVisible()\n\n  // Close popup\n  await popup.close()\n})\n```\n\n### Popup with Authentication\n\n```typescript\ntest('popup login flow', async ({page}) => {\n  await page.goto('/dashboard')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Connect Account'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Complete login in popup\n  await popup.getByLabel('Email').fill('user@example.com')\n  await popup.getByLabel('Password').fill('password123')\n  await popup.getByRole('button', {name: 'Log In'}).click()\n\n  // Popup should close automatically after auth\n  await popup.waitForEvent('close')\n\n  // Main page should reflect connected state\n  await expect(page.getByText('Account connected')).toBeVisible()\n})\n```\n\n### Handle Blocked Popups\n\n```typescript\ntest('handle popup blocker', async ({page}) => {\n  await page.goto('/share')\n\n  // Listen for console messages about blocked popup\n  page.on('console', (msg) => {\n    if (msg.text().includes('popup blocked')) {\n      console.log('Popup was blocked')\n    }\n  })\n\n  const popupPromise = page.waitForEvent('popup').catch(() => null)\n  await page.getByRole('button', {name: 'Share to Twitter'}).click()\n  const popup = await popupPromise\n\n  if (!popup) {\n    // Popup blocked - app should show fallback\n    await expect(page.getByText('Copy share link instead')).toBeVisible()\n  }\n})\n```\n\n## New Tab Navigation\n\n### Link Opens in New Tab\n\n```typescript\ntest('external link opens in new tab', async ({page, context}) => {\n  await page.goto('/resources')\n\n  // Wait for new page in context\n  const pagePromise = context.waitForEvent('page')\n  await page.getByRole('link', {name: 'Documentation'}).click()\n  const newPage = await pagePromise\n\n  await newPage.waitForLoadState()\n\n  expect(newPage.url()).toContain('docs.example.com')\n  await expect(newPage.getByRole('heading', {level: 1})).toBeVisible()\n\n  // Original page still there\n  expect(page.url()).toContain('/resources')\n\n  await newPage.close()\n})\n```\n\n### Intercept New Tab\n\n```typescript\ntest('prevent new tab for testing', async ({page}) => {\n  await page.goto('/links')\n\n  // Remove target=\"_blank\" to keep navigation in same tab\n  await page.evaluate(() => {\n    document.querySelectorAll('a[target=\"_blank\"]').forEach((a) => {\n      a.removeAttribute('target')\n    })\n  })\n\n  // Now link opens in same tab\n  await page.getByRole('link', {name: 'External Site'}).click()\n\n  // Can test the destination page\n  await expect(page).toHaveURL(/external-site\\.com/)\n})\n```\n\n## OAuth Flows\n\n### Google OAuth Popup\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  await page.goto('/login')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Handle Google's OAuth flow\n  await popup.getByLabel('Email or phone').fill('test@gmail.com')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  await popup.getByLabel('Enter your password').fill('password')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for redirect back and popup close\n  await popup.waitForEvent('close')\n\n  // Verify logged in on main page\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### Mock OAuth (Recommended)\n\n```typescript\ntest('mock OAuth flow', async ({page, context}) => {\n  // Mock the OAuth callback instead of real flow\n  await page.route('**/auth/callback**', async (route) => {\n    // Simulate successful OAuth\n    const url = new URL(route.request().url())\n    url.searchParams.set('code', 'mock-auth-code')\n    await route.fulfill({\n      status: 302,\n      headers: {Location: '/dashboard'},\n    })\n  })\n\n  // Mock token exchange\n  await page.route('**/api/auth/token', (route) =>\n    route.fulfill({\n      json: {\n        access_token: 'mock-token',\n        user: {name: 'Test User', email: 'test@example.com'},\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // Should redirect to dashboard without actual OAuth\n  await expect(page).toHaveURL('/dashboard')\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.\n\n## Multiple Windows\n\n### Test Across Multiple Windows\n\n```typescript\ntest('sync between windows', async ({context}) => {\n  // Open two pages\n  const page1 = await context.newPage()\n  const page2 = await context.newPage()\n\n  await page1.goto('/dashboard')\n  await page2.goto('/dashboard')\n\n  // Make change in first window\n  await page1.getByRole('button', {name: 'Add Item'}).click()\n  await page1.getByLabel('Name').fill('New Item')\n  await page1.getByRole('button', {name: 'Save'}).click()\n\n  // Should sync to second window (if app supports real-time sync)\n  await expect(page2.getByText('New Item')).toBeVisible({timeout: 10000})\n})\n```\n\n### Different Users in Different Windows\n\n> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.\n\n## Tab Coordination\n\n### Switch Between Tabs\n\n```typescript\ntest('manage multiple tabs', async ({context}) => {\n  const page1 = await context.newPage()\n  await page1.goto('/editor')\n\n  const page2 = await context.newPage()\n  await page2.goto('/preview')\n\n  // Edit in first tab\n  await page1.bringToFront()\n  await page1.getByLabel('Content').fill('Hello World')\n\n  // Check preview in second tab\n  await page2.bringToFront()\n  await page2.reload() // If preview needs refresh\n  await expect(page2.getByText('Hello World')).toBeVisible()\n})\n```\n\n### Close All Tabs Except One\n\n```typescript\ntest('cleanup tabs after test', async ({context}) => {\n  const mainPage = await context.newPage()\n  await mainPage.goto('/')\n\n  // Open several popups during test\n  for (let i = 0; i < 3; i++) {\n    const popup = await context.newPage()\n    await popup.goto(`/popup/${i}`)\n  }\n\n  // Close all except main page\n  for (const page of context.pages()) {\n    if (page !== mainPage) {\n      await page.close()\n    }\n  }\n\n  expect(context.pages()).toHaveLength(1)\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern            | Problem                        | Solution                                   |\n| ----------------------- | ------------------------------ | ------------------------------------------ |\n| Not waiting for popup   | Race condition                 | Use `waitForEvent(\"popup\")` before trigger |\n| Testing real OAuth      | Slow, flaky, needs credentials | Mock OAuth endpoints                       |\n| Assuming popup opens    | May be blocked                 | Handle both open and blocked cases         |\n| Not closing extra pages | Resource leak                  | Close pages in cleanup                     |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth\n",".agents/skills/playwright-best-practices/advanced/multi-user.md":"# Multi-User & Collaboration Testing\n\n## Table of Contents\n\n1. [Multiple Browser Contexts](#multiple-browser-contexts)\n2. [Real-Time Collaboration](#real-time-collaboration)\n3. [Role-Based Testing](#role-based-testing)\n4. [Concurrent Actions](#concurrent-actions)\n5. [Chat & Messaging](#chat--messaging)\n\n## Multiple Browser Contexts\n\n### Two Users in Same Test\n\n```typescript\ntest(\"two users see each other's changes\", async ({browser}) => {\n  // Create two isolated contexts (like two browsers)\n  const userAContext = await browser.newContext()\n  const userBContext = await browser.newContext()\n\n  const userAPage = await userAContext.newPage()\n  const userBPage = await userBContext.newPage()\n\n  // Both users go to the same document\n  await userAPage.goto('/doc/shared-123')\n  await userBPage.goto('/doc/shared-123')\n\n  // User A types\n  await userAPage.getByLabel('Content').fill('Hello from User A')\n\n  // User B should see the change\n  await expect(userBPage.getByText('Hello from User A')).toBeVisible()\n\n  // Cleanup\n  await userAContext.close()\n  await userBContext.close()\n})\n```\n\n### Multiple Users with Auth States\n\n```typescript\ntest('admin and user interaction', async ({browser}) => {\n  // Load different auth states\n  const adminContext = await browser.newContext({\n    storageState: '.auth/admin.json',\n  })\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n\n  const adminPage = await adminContext.newPage()\n  const userPage = await userContext.newPage()\n\n  // User submits request\n  await userPage.goto('/support')\n  await userPage.getByLabel('Message').fill('Need help!')\n  await userPage.getByRole('button', {name: 'Submit'}).click()\n\n  // Admin sees and responds\n  await adminPage.goto('/admin/tickets')\n  await expect(adminPage.getByText('Need help!')).toBeVisible()\n  await adminPage.getByRole('button', {name: 'Reply'}).click()\n  await adminPage.getByLabel('Response').fill('How can I help?')\n  await adminPage.getByRole('button', {name: 'Send'}).click()\n\n  // User sees response\n  await expect(userPage.getByText('How can I help?')).toBeVisible()\n\n  await adminContext.close()\n  await userContext.close()\n})\n```\n\n### Multi-User Fixture\n\n```typescript\n// fixtures/multi-user.fixture.ts\nimport {test as base, Browser, BrowserContext, Page} from '@playwright/test'\n\ntype UserSession = {\n  context: BrowserContext\n  page: Page\n}\n\ntype MultiUserFixtures = {\n  createUser: (authState?: string) => Promise<UserSession>\n}\n\nexport const test = base.extend<MultiUserFixtures>({\n  createUser: async ({browser}, use) => {\n    const sessions: UserSession[] = []\n\n    await use(async (authState) => {\n      const context = await browser.newContext({\n        storageState: authState,\n      })\n      const page = await context.newPage()\n      sessions.push({context, page})\n      return {context, page}\n    })\n\n    // Cleanup all sessions\n    for (const session of sessions) {\n      await session.context.close()\n    }\n  },\n})\n\n// Usage\ntest('3 users collaborate', async ({createUser}) => {\n  const alice = await createUser('.auth/alice.json')\n  const bob = await createUser('.auth/bob.json')\n  const charlie = await createUser('.auth/charlie.json')\n\n  // All navigate to same room\n  await alice.page.goto('/room/123')\n  await bob.page.goto('/room/123')\n  await charlie.page.goto('/room/123')\n\n  // Test interactions...\n})\n```\n\n## Real-Time Collaboration\n\n### Collaborative Document\n\n```typescript\ntest('real-time collaborative editing', async ({browser}) => {\n  const user1 = await browser.newContext()\n  const user2 = await browser.newContext()\n\n  const page1 = await user1.newPage()\n  const page2 = await user2.newPage()\n\n  await page1.goto('/docs/shared')\n  await page2.goto('/docs/shared')\n\n  // User 1 types at the beginning\n  const editor1 = page1.getByRole('textbox')\n  await editor1.click()\n  await editor1.press('Home')\n  await editor1.type('User 1: ')\n\n  // User 2 types at the end\n  const editor2 = page2.getByRole('textbox')\n  await editor2.click()\n  await editor2.press('End')\n  await editor2.type(' - User 2')\n\n  // Both should see combined result\n  await expect(page1.getByRole('textbox')).toContainText('User 1:')\n  await expect(page1.getByRole('textbox')).toContainText('- User 2')\n  await expect(page2.getByRole('textbox')).toContainText('User 1:')\n  await expect(page2.getByRole('textbox')).toContainText('- User 2')\n\n  await user1.close()\n  await user2.close()\n})\n```\n\n### Cursor Presence\n\n```typescript\ntest('shows other user cursors', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Mock to identify users\n  await page1.route('**/api/me', (route) => route.fulfill({json: {id: 'user-1', name: 'Alice'}}))\n  await page2.route('**/api/me', (route) => route.fulfill({json: {id: 'user-2', name: 'Bob'}}))\n\n  await page1.goto('/whiteboard/123')\n  await page2.goto('/whiteboard/123')\n\n  // Move cursor on page1\n  await page1.mouse.move(200, 200)\n\n  // Page2 should see Alice's cursor\n  await expect(page2.getByTestId('cursor-user-1')).toBeVisible()\n  await expect(page2.getByText('Alice')).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Role-Based Testing\n\n### Test RBAC\n\n```typescript\nconst roles = [\n  {role: 'admin', canDelete: true, canEdit: true, canView: true},\n  {role: 'editor', canDelete: false, canEdit: true, canView: true},\n  {role: 'viewer', canDelete: false, canEdit: false, canView: true},\n]\n\nfor (const {role, canDelete, canEdit, canView} of roles) {\n  test(`${role} permissions`, async ({browser}) => {\n    const context = await browser.newContext({\n      storageState: `.auth/${role}.json`,\n    })\n    const page = await context.newPage()\n\n    await page.goto('/document/123')\n\n    // Check view permission\n    if (canView) {\n      await expect(page.getByTestId('content')).toBeVisible()\n    } else {\n      await expect(page.getByText('Access denied')).toBeVisible()\n    }\n\n    // Check edit permission\n    const editButton = page.getByRole('button', {name: 'Edit'})\n    if (canEdit) {\n      await expect(editButton).toBeEnabled()\n    } else {\n      await expect(editButton).toBeDisabled()\n    }\n\n    // Check delete permission\n    const deleteButton = page.getByRole('button', {name: 'Delete'})\n    if (canDelete) {\n      await expect(deleteButton).toBeVisible()\n    } else {\n      await expect(deleteButton).toBeHidden()\n    }\n\n    await context.close()\n  })\n}\n```\n\n### Permission Escalation Test\n\n```typescript\ntest('cannot access admin routes as user', async ({browser}) => {\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n  const page = await userContext.newPage()\n\n  // Try to access admin page directly\n  await page.goto('/admin/users')\n\n  // Should redirect or show error\n  await expect(page).not.toHaveURL('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n\n  await userContext.close()\n})\n```\n\n## Concurrent Actions\n\n### Race Condition Testing\n\n```typescript\ntest('handles concurrent edits', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  await page1.goto('/item/123')\n  await page2.goto('/item/123')\n\n  // Both click edit at the same time\n  await Promise.all([\n    page1.getByRole('button', {name: 'Edit'}).click(),\n    page2.getByRole('button', {name: 'Edit'}).click(),\n  ])\n\n  // Both try to save different values\n  await page1.getByLabel('Name').fill('Value from User 1')\n  await page2.getByLabel('Name').fill('Value from User 2')\n\n  await Promise.all([\n    page1.getByRole('button', {name: 'Save'}).click(),\n    page2.getByRole('button', {name: 'Save'}).click(),\n  ])\n\n  // One should succeed, one should get conflict error\n  const page1HasConflict = await page1.getByText('Conflict').isVisible()\n  const page2HasConflict = await page2.getByText('Conflict').isVisible()\n\n  // Exactly one should have conflict\n  expect(page1HasConflict || page2HasConflict).toBe(true)\n  expect(page1HasConflict && page2HasConflict).toBe(false)\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n### Optimistic Locking Test\n\n```typescript\ntest('optimistic locking prevents overwrites', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Both load the same version\n  await page1.goto('/record/123')\n  await page2.goto('/record/123')\n\n  // User 1 edits and saves first\n  await page1.getByRole('button', {name: 'Edit'}).click()\n  await page1.getByLabel('Value').fill('Updated by User 1')\n  await page1.getByRole('button', {name: 'Save'}).click()\n  await expect(page1.getByText('Saved')).toBeVisible()\n\n  // User 2 tries to save with stale version\n  await page2.getByRole('button', {name: 'Edit'}).click()\n  await page2.getByLabel('Value').fill('Updated by User 2')\n  await page2.getByRole('button', {name: 'Save'}).click()\n\n  // Should fail with version conflict\n  await expect(page2.getByText('Someone else modified this')).toBeVisible()\n  await expect(page2.getByRole('button', {name: 'Reload'})).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Chat & Messaging\n\n### Real-Time Chat\n\n```typescript\ntest('chat messages sync between users', async ({browser}) => {\n  const aliceCtx = await browser.newContext()\n  const bobCtx = await browser.newContext()\n\n  const alicePage = await aliceCtx.newPage()\n  const bobPage = await bobCtx.newPage()\n\n  // Setup user identities\n  await alicePage.route('**/api/me', (r) => r.fulfill({json: {name: 'Alice'}}))\n  await bobPage.route('**/api/me', (r) => r.fulfill({json: {name: 'Bob'}}))\n\n  await alicePage.goto('/chat/room-1')\n  await bobPage.goto('/chat/room-1')\n\n  // Alice sends message\n  await alicePage.getByLabel('Message').fill('Hi Bob!')\n  await alicePage.getByRole('button', {name: 'Send'}).click()\n\n  // Bob sees it\n  await expect(bobPage.getByText('Alice: Hi Bob!')).toBeVisible()\n\n  // Bob replies\n  await bobPage.getByLabel('Message').fill('Hey Alice!')\n  await bobPage.getByRole('button', {name: 'Send'}).click()\n\n  // Alice sees it\n  await expect(alicePage.getByText('Bob: Hey Alice!')).toBeVisible()\n\n  await aliceCtx.close()\n  await bobCtx.close()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                       | Solution                     |\n| ----------------------------- | ----------------------------- | ---------------------------- |\n| Sharing context between users | State leaks, not isolated     | Create separate contexts     |\n| Not closing contexts          | Memory leak, browser overload | Always close in cleanup      |\n| Hardcoded timing for sync     | Flaky tests                   | Use `expect().toBeVisible()` |\n| Testing only single user      | Misses collaboration bugs     | Test multi-user scenarios    |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n",".agents/skills/playwright-best-practices/advanced/network-advanced.md":"# Advanced Network Interception\n\n## Table of Contents\n\n1. [Request Modification](#request-modification)\n2. [GraphQL Mocking](#graphql-mocking)\n3. [HAR Recording & Playback](#har-recording--playback)\n4. [Conditional Mocking](#conditional-mocking)\n5. [Network Throttling](#network-throttling)\n\n## Request Modification\n\n### Modify Request Headers\n\n```typescript\ntest('add auth header to requests', async ({page}) => {\n  await page.route('**/api/**', (route) => {\n    const headers = {\n      ...route.request().headers(),\n      'Authorization': 'Bearer test-token',\n      'X-Test-Header': 'test-value',\n    }\n    route.continue({headers})\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### Modify Request Body\n\n```typescript\ntest('modify POST body', async ({page}) => {\n  await page.route('**/api/orders', async (route) => {\n    if (route.request().method() === 'POST') {\n      const postData = route.request().postDataJSON()\n\n      // Add test metadata\n      const modifiedData = {\n        ...postData,\n        testMode: true,\n        testTimestamp: Date.now(),\n      }\n\n      await route.continue({\n        postData: JSON.stringify(modifiedData),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n})\n```\n\n### Transform Response\n\n```typescript\ntest('modify API response', async ({page}) => {\n  await page.route('**/api/products', async (route) => {\n    // Fetch real response\n    const response = await route.fetch()\n    const json = await response.json()\n\n    // Modify response\n    const modified = json.map((product: any) => ({\n      ...product,\n      price: product.price * 0.9, // 10% discount\n      testMode: true,\n    }))\n\n    await route.fulfill({\n      response,\n      json: modified,\n    })\n  })\n\n  await page.goto('/products')\n})\n```\n\n## GraphQL Mocking\n\n### Mock by Operation Name\n\n```typescript\ntest('mock GraphQL query', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'GetUser') {\n      return route.fulfill({\n        json: {\n          data: {\n            user: {\n              id: '1',\n              name: 'Test User',\n              email: 'test@example.com',\n            },\n          },\n        },\n      })\n    }\n\n    if (postData.operationName === 'GetProducts') {\n      return route.fulfill({\n        json: {\n          data: {\n            products: [\n              {id: '1', name: 'Product A', price: 29.99},\n              {id: '2', name: 'Product B', price: 49.99},\n            ],\n          },\n        },\n      })\n    }\n\n    // Pass through unmocked operations\n    return route.continue()\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### GraphQL Mock Fixture\n\n```typescript\n// fixtures/graphql.fixture.ts\ntype GraphQLMock = {\n  operation: string\n  variables?: Record<string, any>\n  response: {data?: any; errors?: any[]}\n}\n\ntype GraphQLFixtures = {\n  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>\n}\n\nexport const test = base.extend<GraphQLFixtures>({\n  mockGraphQL: async ({page}, use) => {\n    await use(async (mocks) => {\n      await page.route('**/graphql', async (route) => {\n        const postData = route.request().postDataJSON()\n\n        const mock = mocks.find((m) => {\n          if (m.operation !== postData.operationName) return false\n\n          // Optionally match variables\n          if (m.variables) {\n            return JSON.stringify(m.variables) === JSON.stringify(postData.variables)\n          }\n          return true\n        })\n\n        if (mock) {\n          return route.fulfill({json: mock.response})\n        }\n\n        return route.continue()\n      })\n    })\n  },\n})\n\n// Usage\ntest('dashboard with mocked GraphQL', async ({page, mockGraphQL}) => {\n  await mockGraphQL([\n    {\n      operation: 'GetDashboardStats',\n      response: {\n        data: {stats: {users: 100, revenue: 50000}},\n      },\n    },\n    {\n      operation: 'GetUser',\n      variables: {id: '1'},\n      response: {\n        data: {user: {id: '1', name: 'John'}},\n      },\n    },\n  ])\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('100 users')).toBeVisible()\n})\n```\n\n### Mock GraphQL Mutations\n\n```typescript\ntest('mock GraphQL mutation', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'CreateOrder') {\n      const {input} = postData.variables\n\n      return route.fulfill({\n        json: {\n          data: {\n            createOrder: {\n              id: 'order-123',\n              status: 'PENDING',\n              items: input.items,\n              total: input.items.reduce(\n                (sum: number, item: any) => sum + item.price * item.quantity,\n                0,\n              ),\n            },\n          },\n        },\n      })\n    }\n\n    return route.continue()\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  await expect(page.getByText('Order #order-123')).toBeVisible()\n})\n```\n\n## HAR Recording & Playback\n\n### Record HAR File\n\n```typescript\n// Record network traffic\ntest('record HAR', async ({page, context}) => {\n  // Start recording\n  await context.routeFromHAR('./recordings/checkout.har', {\n    update: true, // Create/update HAR file\n    url: '**/api/**',\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  // HAR file is saved automatically\n})\n```\n\n### Playback HAR File\n\n```typescript\n// Use recorded HAR for offline testing\ntest('playback HAR', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/checkout.har', {\n    url: '**/api/**',\n    update: false, // Don't update, just playback\n  })\n\n  await page.goto('/checkout')\n\n  // All API calls served from HAR file\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### HAR with Fallback\n\n```typescript\ntest('HAR with live fallback', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/api.har', {\n    url: '**/api/**',\n    update: false,\n    notFound: 'fallback', // Use real network if not in HAR\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n## Conditional Mocking\n\n### Mock Based on Request Body\n\n```typescript\ntest('conditional mock by body', async ({page}) => {\n  await page.route('**/api/search', async (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.query === 'error') {\n      return route.fulfill({\n        status: 500,\n        json: {error: 'Search failed'},\n      })\n    }\n\n    if (body.query === 'empty') {\n      return route.fulfill({\n        json: {results: []},\n      })\n    }\n\n    // Default response\n    return route.fulfill({\n      json: {\n        results: [{id: 1, title: `Result for: ${body.query}`}],\n      },\n    })\n  })\n\n  await page.goto('/search')\n\n  // Test different scenarios\n  await page.getByLabel('Search').fill('error')\n  await page.getByLabel('Search').press('Enter')\n  await expect(page.getByText('Search failed')).toBeVisible()\n})\n```\n\n### Mock Nth Request\n\n```typescript\ntest('different response on retry', async ({page}) => {\n  let callCount = 0\n\n  await page.route('**/api/status', (route) => {\n    callCount++\n\n    if (callCount < 3) {\n      return route.fulfill({\n        status: 503,\n        json: {error: 'Service unavailable'},\n      })\n    }\n\n    // Succeed on 3rd attempt\n    return route.fulfill({\n      json: {status: 'ok'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // App should retry and eventually succeed\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n### Mock with Delay\n\n```typescript\ntest('slow network simulation', async ({page}) => {\n  await page.route('**/api/data', async (route) => {\n    // Simulate 2 second delay\n    await new Promise((resolve) => setTimeout(resolve, 2000))\n\n    return route.fulfill({\n      json: {data: 'loaded'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Loading state should appear\n  await expect(page.getByText('Loading...')).toBeVisible()\n\n  // Then data appears\n  await expect(page.getByText('loaded')).toBeVisible()\n})\n```\n\n## Network Throttling\n\n### Slow 3G Simulation\n\n```typescript\ntest('slow network experience', async ({page, context}) => {\n  // Create CDP session for network throttling\n  const client = await context.newCDPSession(page)\n\n  await client.send('Network.emulateNetworkConditions', {\n    offline: false,\n    downloadThroughput: (500 * 1024) / 8, // 500 Kbps\n    uploadThroughput: (500 * 1024) / 8,\n    latency: 400, // 400ms\n  })\n\n  await page.goto('/')\n\n  // Test loading states appear\n  await expect(page.getByTestId('skeleton-loader')).toBeVisible()\n})\n```\n\n### Offline Mode\n\nUse `context.setOffline(true/false)` to simulate network connectivity changes.\n\n> **For comprehensive offline testing patterns:**\n>\n> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)\n> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)\n\n### Network Throttling Fixture\n\n```typescript\n// fixtures/network.fixture.ts\ntype NetworkCondition = 'slow3g' | 'fast3g' | 'offline'\n\nconst conditions = {\n  slow3g: {downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000},\n  fast3g: {downloadThroughput: 180000, uploadThroughput: 75000, latency: 150},\n}\n\ntype NetworkFixtures = {\n  setNetworkCondition: (condition: NetworkCondition) => Promise<void>\n}\n\nexport const test = base.extend<NetworkFixtures>({\n  setNetworkCondition: async ({page, context}, use) => {\n    const client = await context.newCDPSession(page)\n\n    await use(async (condition) => {\n      if (condition === 'offline') {\n        await context.setOffline(true)\n      } else {\n        await client.send('Network.emulateNetworkConditions', {\n          offline: false,\n          ...conditions[condition],\n        })\n      }\n    })\n\n    // Reset\n    await context.setOffline(false)\n  },\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                         |\n| ------------------------ | ------------------------------ | -------------------------------- |\n| Mocking all requests     | Tests don't reflect reality    | Mock only what's necessary       |\n| No cleanup of routes     | Routes persist across tests    | Use fixtures with cleanup        |\n| Ignoring request method  | Mock applies to wrong requests | Check `route.request().method()` |\n| Hardcoded mock responses | Brittle, hard to maintain      | Use factories for mock data      |\n\n## Related References\n\n- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n",".agents/skills/playwright-best-practices/advanced/third-party.md":"# Third-Party Service Mocking\n\n## Table of Contents\n\n1. [OAuth/SSO Mocking](#oauthsso-mocking)\n2. [Payment Gateway Mocking](#payment-gateway-mocking)\n3. [Email Verification](#email-verification)\n4. [SMS Verification](#sms-verification)\n5. [Analytics & Tracking](#analytics--tracking)\n\n## OAuth/SSO Mocking\n\n### Mock Google OAuth\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  // Mock the OAuth callback\n  await page.route('**/auth/google/callback**', (route) => {\n    const url = new URL(route.request().url())\n    // Simulate successful OAuth by redirecting with token\n    route.fulfill({\n      status: 302,\n      headers: {\n        Location: '/dashboard?token=mock-jwt-token',\n      },\n    })\n  })\n\n  // Mock the token verification endpoint\n  await page.route('**/api/auth/verify', (route) =>\n    route.fulfill({\n      json: {\n        valid: true,\n        user: {\n          id: '123',\n          email: 'test@gmail.com',\n          name: 'Test User',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n```typescript\n// fixtures/oauth.fixture.ts\ntype OAuthProvider = 'google' | 'github' | 'microsoft'\n\ntype OAuthUser = {\n  id: string\n  email: string\n  name: string\n  avatar?: string\n}\n\ntype OAuthFixtures = {\n  mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>\n}\n\nexport const test = base.extend<OAuthFixtures>({\n  mockOAuth: async ({page}, use) => {\n    await use(async (provider, user) => {\n      // Mock callback redirect\n      await page.route(`**/auth/${provider}/callback**`, (route) =>\n        route.fulfill({\n          status: 302,\n          headers: {Location: `/auth/success?provider=${provider}`},\n        }),\n      )\n\n      // Mock session/user endpoint\n      await page.route('**/api/auth/session', (route) =>\n        route.fulfill({\n          json: {user, provider, authenticated: true},\n        }),\n      )\n\n      // Mock user info endpoint\n      await page.route('**/api/me', (route) => route.fulfill({json: user}))\n    })\n  },\n})\n\n// Usage\ntest('login with GitHub', async ({page, mockOAuth}) => {\n  await mockOAuth('github', {\n    id: 'gh-123',\n    email: 'dev@github.com',\n    name: 'GitHub User',\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with GitHub'}).click()\n\n  await expect(page.getByText('Welcome, GitHub User')).toBeVisible()\n})\n```\n\n### Mock SAML SSO\n\n```typescript\ntest('SAML SSO login', async ({page}) => {\n  // Mock SAML assertion consumer service\n  await page.route('**/saml/acs', async (route) => {\n    route.fulfill({\n      status: 302,\n      headers: {\n        'Location': '/dashboard',\n        'Set-Cookie': 'session=mock-saml-session; Path=/; HttpOnly',\n      },\n    })\n  })\n\n  // Mock session validation\n  await page.route('**/api/session', (route) =>\n    route.fulfill({\n      json: {\n        user: {email: 'user@company.com', name: 'SSO User'},\n        provider: 'saml',\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'SSO Login'}).click()\n\n  await expect(page).toHaveURL('/dashboard')\n})\n```\n\n## Payment Gateway Mocking\n\n### Mock Stripe\n\n```typescript\ntest('Stripe checkout', async ({page}) => {\n  // Mock Stripe.js\n  await page.addInitScript(() => {\n    ;(window as any).Stripe = () => ({\n      elements: () => ({\n        create: () => ({\n          mount: () => {},\n          on: () => {},\n          destroy: () => {},\n        }),\n      }),\n      confirmCardPayment: async () => ({\n        paymentIntent: {status: 'succeeded', id: 'pi_mock_123'},\n      }),\n      createPaymentMethod: async () => ({\n        paymentMethod: {id: 'pm_mock_123'},\n      }),\n    })\n  })\n\n  // Mock backend payment endpoint\n  await page.route('**/api/create-payment-intent', (route) =>\n    route.fulfill({\n      json: {clientSecret: 'pi_mock_123_secret_mock'},\n    }),\n  )\n\n  await page.route('**/api/confirm-payment', (route) =>\n    route.fulfill({\n      json: {success: true, orderId: 'order-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay $99.99'}).click()\n\n  await expect(page.getByText('Payment successful')).toBeVisible()\n})\n```\n\n### Mock PayPal\n\n```typescript\ntest('PayPal checkout', async ({page}) => {\n  // Mock PayPal SDK\n  await page.addInitScript(() => {\n    ;(window as any).paypal = {\n      Buttons: () => ({\n        render: () => Promise.resolve(),\n        isEligible: () => true,\n      }),\n      FUNDING: {PAYPAL: 'paypal', CARD: 'card'},\n    }\n  })\n\n  // Mock PayPal order creation\n  await page.route('**/api/paypal/create-order', (route) =>\n    route.fulfill({\n      json: {orderId: 'PAYPAL-ORDER-123'},\n    }),\n  )\n\n  // Mock PayPal capture\n  await page.route('**/api/paypal/capture', (route) =>\n    route.fulfill({\n      json: {success: true, transactionId: 'TXN-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n\n  // Simulate PayPal approval callback\n  await page.evaluate(() => {\n    ;(window as any).onPayPalApprove?.({orderID: 'PAYPAL-ORDER-123'})\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Payment Fixture\n\n```typescript\n// fixtures/payment.fixture.ts\ntype PaymentFixtures = {\n  mockStripe: (options?: {failPayment?: boolean}) => Promise<void>\n}\n\nexport const test = base.extend<PaymentFixtures>({\n  mockStripe: async ({page}, use) => {\n    await use(async (options = {}) => {\n      await page.addInitScript(\n        ([shouldFail]) => {\n          ;(window as any).Stripe = () => ({\n            elements: () => ({\n              create: () => ({\n                mount: () => {},\n                on: (event: string, handler: Function) => {\n                  if (event === 'ready') setTimeout(handler, 100)\n                },\n                destroy: () => {},\n              }),\n            }),\n            confirmCardPayment: async () => {\n              if (shouldFail) {\n                return {error: {message: 'Card declined'}}\n              }\n              return {paymentIntent: {status: 'succeeded'}}\n            },\n          })\n        },\n        [options.failPayment],\n      )\n    })\n  },\n})\n\n// Usage\ntest('handles declined card', async ({page, mockStripe}) => {\n  await mockStripe({failPayment: true})\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay'}).click()\n\n  await expect(page.getByText('Card declined')).toBeVisible()\n})\n```\n\n## Email Verification\n\n### Mock Email API\n\n```typescript\ntest('email verification flow', async ({page, request}) => {\n  let verificationToken: string\n\n  // Capture the verification email\n  await page.route('**/api/send-verification', async (route) => {\n    const body = route.request().postDataJSON()\n    verificationToken = `mock-token-${Date.now()}`\n\n    // Don't actually send email, just store token\n    route.fulfill({\n      json: {sent: true, messageId: 'msg-123'},\n    })\n  })\n\n  // Mock token verification\n  await page.route('**/api/verify-email**', (route) => {\n    const url = new URL(route.request().url())\n    const token = url.searchParams.get('token')\n\n    if (token === verificationToken) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid token'}})\n    }\n  })\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  await expect(page.getByText('Check your email')).toBeVisible()\n\n  // Simulate clicking email link\n  await page.goto(`/verify?token=${verificationToken}`)\n\n  await expect(page.getByText('Email verified')).toBeVisible()\n})\n```\n\n### Use Mailinator/Temp Mail\n\n```typescript\n// fixtures/email.fixture.ts\ntype EmailFixtures = {\n  getVerificationEmail: (inbox: string) => Promise<{link: string}>\n}\n\nexport const test = base.extend<EmailFixtures>({\n  getVerificationEmail: async ({request}, use) => {\n    await use(async (inbox) => {\n      // Poll Mailinator API for new email\n      const response = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const messages = await response.json()\n      const latest = messages.msgs[0]\n\n      // Get full message\n      const msgResponse = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const message = await msgResponse.json()\n\n      // Extract verification link from HTML\n      const linkMatch = message.parts[0].body.match(/href=\"([^\"]*verify[^\"]*)\"/)\n      return {link: linkMatch?.[1] || ''}\n    })\n  },\n})\n```\n\n## SMS Verification\n\n### Mock SMS API\n\n```typescript\ntest('SMS verification', async ({page}) => {\n  let smsCode: string\n\n  // Capture SMS send\n  await page.route('**/api/send-sms', (route) => {\n    smsCode = Math.random().toString().slice(2, 8) // 6-digit code\n\n    route.fulfill({\n      json: {sent: true, messageId: 'sms-123'},\n    })\n  })\n\n  // Mock code verification\n  await page.route('**/api/verify-sms', (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.code === smsCode) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid code'}})\n    }\n  })\n\n  await page.goto('/verify-phone')\n  await page.getByLabel('Phone').fill('+1234567890')\n  await page.getByRole('button', {name: 'Send Code'}).click()\n\n  // Enter the code\n  await page.getByLabel('Verification Code').fill(smsCode)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await expect(page.getByText('Phone verified')).toBeVisible()\n})\n```\n\n## Analytics & Tracking\n\n### Block Analytics in Tests\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Block all analytics/tracking\n  await page.route(\n    /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,\n    (route) => route.abort(),\n  )\n})\n```\n\n### Mock Analytics for Verification\n\n```typescript\ntest('tracks purchase event', async ({page}) => {\n  const analyticsEvents: any[] = []\n\n  // Capture analytics calls\n  await page.route('**/api/analytics/**', (route) => {\n    analyticsEvents.push(route.request().postDataJSON())\n    route.fulfill({status: 200})\n  })\n\n  // Mock analytics SDK\n  await page.addInitScript(() => {\n    ;(window as any).analytics = {\n      track: (event: string, props: any) => {\n        fetch('/api/analytics/track', {\n          method: 'POST',\n          body: JSON.stringify({event, props}),\n        })\n      },\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n\n  // Verify analytics event was sent\n  expect(analyticsEvents).toContainEqual(\n    expect.objectContaining({\n      event: 'Purchase Completed',\n      props: expect.objectContaining({amount: expect.any(Number)}),\n    }),\n  )\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern              | Problem                        | Solution                |\n| ------------------------- | ------------------------------ | ----------------------- |\n| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints    |\n| Real payment processing   | Charges real money, slow       | Use test mode or mock   |\n| Waiting for real emails   | Very slow, unreliable          | Mock email API          |\n| Not mocking analytics     | Pollutes analytics data        | Block or mock analytics |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n",".agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md":"# Organizing Reusable Test Code\n\n## Table of Contents\n\n1. [Pattern Comparison](#pattern-comparison)\n2. [Selection Flowchart](#selection-flowchart)\n3. [Page Objects](#page-objects)\n4. [Custom Fixtures](#custom-fixtures)\n5. [Helper Functions](#helper-functions)\n6. [Combined Project Structure](#combined-project-structure)\n7. [Anti-Patterns](#anti-patterns)\n\nUse all three patterns together. Most projects benefit from a hybrid approach:\n\n- **Page objects** for UI interaction (pages/components with 5+ interactions)\n- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)\n- **Helper functions** for stateless utilities (generate data, format values, simple waits)\n\nIf only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.\n\n## Pattern Comparison\n\n| Aspect            | Page Objects                            | Custom Fixtures                            | Helper Functions                  |\n| ----------------- | --------------------------------------- | ------------------------------------------ | --------------------------------- |\n| **Purpose**       | Encapsulate UI interactions             | Provide resources with setup/teardown      | Stateless utilities               |\n| **Lifecycle**     | Manual (constructor/methods)            | Built-in (`use()` with automatic teardown) | None                              |\n| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures                   | Call other functions              |\n| **Best for**      | Pages with many reused interactions     | Resources needing setup AND teardown       | Simple logic with no side effects |\n\n## Selection Flowchart\n\n```text\nWhat kind of reusable code?\n|\n+-- Interacts with browser page/component?\n|   |\n|   +-- Has 5+ interactions (fill, click, navigate, assert)?\n|   |   +-- YES: Used in 3+ test files?\n|   |   |   +-- YES --> PAGE OBJECT\n|   |   |   +-- NO --> Inline or small helper\n|   |   +-- NO --> HELPER FUNCTION\n|   |\n|   +-- Needs setup before AND cleanup after test?\n|       +-- YES --> CUSTOM FIXTURE\n|       +-- NO --> PAGE OBJECT method or HELPER\n|\n+-- Manages resource with lifecycle (create/destroy)?\n|   +-- Examples: auth state, DB connection, API client, test user\n|   +-- YES --> CUSTOM FIXTURE (always)\n|\n+-- Stateless utility? (no browser, no side effects)\n|   +-- Examples: random email, format date, build URL, parse response\n|   +-- YES --> HELPER FUNCTION\n|\n+-- Not sure?\n    +-- Start with HELPER FUNCTION\n    +-- Promote to PAGE OBJECT when interactions grow\n    +-- Promote to FIXTURE when lifecycle needed\n```\n\n## Page Objects\n\nBest for pages/components with 5+ interactions appearing in 3+ test files.\n\n```typescript\n// page-objects/booking.page.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class BookingPage {\n  readonly page: Page\n  readonly dateField: Locator\n  readonly guestCount: Locator\n  readonly roomType: Locator\n  readonly reserveBtn: Locator\n  readonly totalPrice: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.dateField = page.getByLabel('Check-in date')\n    this.guestCount = page.getByLabel('Guests')\n    this.roomType = page.getByLabel('Room type')\n    this.reserveBtn = page.getByRole('button', {name: 'Reserve'})\n    this.totalPrice = page.getByTestId('total-price')\n  }\n\n  async goto() {\n    await this.page.goto('/booking')\n  }\n\n  async fillDetails(opts: {date: string; guests: number; room: string}) {\n    await this.dateField.fill(opts.date)\n    await this.guestCount.fill(String(opts.guests))\n    await this.roomType.selectOption(opts.room)\n  }\n\n  async reserve() {\n    await this.reserveBtn.click()\n    await this.page.waitForURL('**/confirmation')\n  }\n\n  async expectPrice(amount: string) {\n    await expect(this.totalPrice).toHaveText(amount)\n  }\n}\n```\n\n```typescript\n// tests/booking/reservation.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {BookingPage} from '../page-objects/booking.page'\n\ntest('complete reservation with standard room', async ({page}) => {\n  const booking = new BookingPage(page)\n  await booking.goto()\n  await booking.fillDetails({date: '2026-03-15', guests: 2, room: 'standard'})\n  await booking.reserve()\n  await expect(page.getByText('Reservation confirmed')).toBeVisible()\n})\n```\n\n**Page object principles:**\n\n- One class per logical page/component, not per URL\n- Constructor takes `Page`\n- Locators as `readonly` properties in constructor\n- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks\n- Navigation methods (`goto`) belong on the page object\n\n## Custom Fixtures\n\nBest for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.\n\n```typescript\n// fixtures/base.fixture.ts\nimport {test as base, expect} from '@playwright/test'\n\nimport {generateMember} from '../helpers/data'\nimport {BookingPage} from '../page-objects/booking.page'\n\ntype Fixtures = {\n  bookingPage: BookingPage\n  member: {email: string; password: string; id: string}\n  loggedInPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<Fixtures>({\n  bookingPage: async ({page}, use) => {\n    await use(new BookingPage(page))\n  },\n\n  member: async ({request}, use) => {\n    const data = generateMember()\n    const res = await request.post('/api/test/members', {data})\n    const member = await res.json()\n    await use(member)\n    await request.delete(`/api/test/members/${member.id}`)\n  },\n\n  loggedInPage: async ({page, member}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(member.email)\n    await page.getByLabel('Password').fill(member.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await expect(page).toHaveURL('/dashboard')\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/dashboard/overview.spec.ts\nimport {test, expect} from '../../fixtures/base.fixture'\n\ntest('member sees dashboard widgets', async ({loggedInPage}) => {\n  await expect(loggedInPage.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible()\n})\n\ntest('new member sees welcome prompt', async ({loggedInPage, member}) => {\n  await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible()\n})\n```\n\n**Fixture principles:**\n\n- Use `test.extend()` — never module-level variables\n- `use()` callback separates setup from teardown\n- Teardown runs even if test fails\n- Fixtures compose: one can depend on another\n- Fixtures are lazy: created only when requested\n- Wrap page objects in fixtures for lifecycle management\n\n## Helper Functions\n\nBest for stateless utilities — generating test data, formatting values, building URLs, parsing responses.\n\n```typescript\n// helpers/data.ts\nimport {randomUUID} from 'node:crypto'\n\nexport function generateEmail(prefix = 'user'): string {\n  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`\n}\n\nexport function generateMember(overrides: Partial<Member> = {}): Member {\n  return {\n    email: generateEmail(),\n    password: 'SecurePass456!',\n    name: 'Test Member',\n    ...overrides,\n  }\n}\n\ninterface Member {\n  email: string\n  password: string\n  name: string\n}\n\nexport function formatPrice(cents: number): string {\n  return `$${(cents / 100).toFixed(2)}`\n}\n```\n\n```typescript\n// helpers/assertions.ts\nimport {type Page, expect} from '@playwright/test'\n\nexport async function expectNotification(page: Page, message: string): Promise<void> {\n  const notification = page.getByRole('alert').filter({hasText: message})\n  await expect(notification).toBeVisible()\n  await expect(notification).toBeHidden({timeout: 10000})\n}\n```\n\n```typescript\n// tests/settings/account.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {expectNotification} from '../../helpers/assertions'\nimport {generateEmail} from '../../helpers/data'\n\ntest('update account email', async ({page}) => {\n  const newEmail = generateEmail('updated')\n  await page.goto('/settings/account')\n  await page.getByLabel('Email').fill(newEmail)\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expectNotification(page, 'Account updated')\n  await expect(page.getByLabel('Email')).toHaveValue(newEmail)\n})\n```\n\n**Helper principles:**\n\n- Pure functions with no side effects\n- No browser state — take `page` as parameter if needed\n- Promote to fixture if setup/teardown needed\n- Promote to page object if many page interactions grow\n- Keep small and focused\n\n## Combined Project Structure\n\n```text\ntests/\n+-- fixtures/\n|   +-- auth.fixture.ts\n|   +-- db.fixture.ts\n|   +-- base.fixture.ts\n+-- page-objects/\n|   +-- login.page.ts\n|   +-- booking.page.ts\n|   +-- components/\n|       +-- data-table.component.ts\n+-- helpers/\n|   +-- data.ts\n|   +-- assertions.ts\n+-- e2e/\n|   +-- auth/\n|   |   +-- login.spec.ts\n|   +-- booking/\n|       +-- reservation.spec.ts\nplaywright.config.ts\n```\n\n**Layer responsibilities:**\n\n| Layer            | Pattern         | Responsibility                                      |\n| ---------------- | --------------- | --------------------------------------------------- |\n| **Test file**    | `test()`        | Describes behavior, orchestrates layers             |\n| **Fixtures**     | `test.extend()` | Resource lifecycle — setup, provide, teardown       |\n| **Page objects** | Classes         | UI interaction — navigation, actions, locators      |\n| **Helpers**      | Functions       | Utilities — data generation, formatting, assertions |\n\n## Anti-Patterns\n\n### Page object managing resources\n\n```typescript\n// BAD: page object handling API calls and database\nclass LoginPage {\n  async createUser() {\n    /* API call */\n  }\n  async deleteUser() {\n    /* API call */\n  }\n  async signIn(email: string, password: string) {\n    /* UI */\n  }\n}\n```\n\nResource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.\n\n### Locator-only page objects\n\n```typescript\n// BAD: no methods, just locators\nclass LoginPage {\n  emailInput = this.page.getByLabel('Email')\n  passwordInput = this.page.getByLabel('Password')\n  submitBtn = this.page.getByRole('button', {name: 'Sign in'})\n  constructor(private page: Page) {}\n}\n```\n\nAdd intent-revealing methods or skip the page object entirely.\n\n### Monolithic fixtures\n\n```typescript\n// BAD: one fixture doing everything\ntest.extend({\n  everything: async ({page, request}, use) => {\n    const user = await createUser(request)\n    const products = await seedProducts(request, 50)\n    await setupPayment(request, user.id)\n    await page.goto('/dashboard')\n    await use({user, products, page})\n    // massive teardown...\n  },\n})\n```\n\nBreak into small, composable fixtures. Each fixture does one thing.\n\n### Helpers with side effects\n\n```typescript\n// BAD: module-level state\nlet createdUserId: string\n\nexport async function createTestUser(request: APIRequestContext) {\n  const res = await request.post('/api/users', {data: {email: 'test@example.com'}})\n  const user = await res.json()\n  createdUserId = user.id // shared across tests!\n  return user\n}\n```\n\nModule-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.\n\n### Over-abstracting simple operations\n\n```typescript\n// BAD: helper for one-liner\nexport async function clickButton(page: Page, name: string) {\n  await page.getByRole('button', {name}).click()\n}\n```\n\nOnly abstract when there is real duplication (3+ usages) or complexity (5+ interactions).\n",".agents/skills/playwright-best-practices/architecture/test-architecture.md":"# Choosing Test Types: E2E, Component, or API\n\n## Table of Contents\n\n1. [Decision Matrix](#decision-matrix)\n2. [API Tests](#api-tests)\n3. [Component Tests](#component-tests)\n4. [E2E Tests](#e2e-tests)\n5. [Layering Test Types](#layering-test-types)\n6. [Common Mistakes](#common-mistakes)\n7. [Related](#related)\n\n> **When to use**: Deciding which test type to write for a feature. Ask: \"What's the cheapest test that gives confidence this works?\"\n\n## Decision Matrix\n\n| Scenario                    | Recommended Type | Rationale                                     |\n| --------------------------- | ---------------- | --------------------------------------------- |\n| Login / auth flow           | E2E              | Cross-page, cookies, redirects, session state |\n| Form submission             | Component        | Isolated validation logic, error states       |\n| CRUD operations             | API              | Data integrity matters more than UI           |\n| Search with results UI      | Component + API  | API for query logic; component for rendering  |\n| Cross-page navigation       | E2E              | Routing, history, deep linking                |\n| API error handling          | API              | Status codes, error shapes, edge cases        |\n| UI error feedback           | Component        | Toast, banner, inline error rendering         |\n| Accessibility               | Component        | ARIA roles, keyboard nav per-component        |\n| Responsive layout           | Component        | Viewport-specific rendering without full app  |\n| API contract validation     | API              | Response shapes, headers, auth                |\n| WebSocket/real-time         | E2E              | Requires full browser environment             |\n| Payment / checkout          | E2E              | Multi-step, third-party iframes               |\n| Onboarding wizard           | E2E              | Multi-step, state persists across pages       |\n| Widget behavior             | Component        | Toggle, accordion, date picker, modal         |\n| Permissions / authorization | API              | Role-based access is backend logic            |\n\n## API Tests\n\n**Ideal for**:\n\n- CRUD operations (create, read, update, delete)\n- Input validation and error responses (400, 422)\n- Permission and authorization checks\n- Data integrity and business rules\n- API contract verification\n- Edge cases expensive to reproduce through UI\n- Test data setup/teardown for E2E tests\n\n**Avoid for**:\n\n- Testing how errors display to users\n- Browser-specific behavior (cookies, redirects)\n- Visual layout or responsive design\n- Flows requiring JavaScript execution or DOM interaction\n- Third-party iframe interactions\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Products API', () => {\n  let token: string\n\n  test.beforeAll(async ({request}) => {\n    const res = await request.post('/api/auth/token', {\n      data: {email: 'manager@shop.io', password: 'mgr-secret'},\n    })\n    token = (await res.json()).accessToken\n  })\n\n  test('creates product with valid payload', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Widget Pro', sku: 'WGT-100', price: 29.99},\n    })\n\n    expect(res.status()).toBe(201)\n    const product = await res.json()\n    expect(product).toMatchObject({name: 'Widget Pro', sku: 'WGT-100'})\n    expect(product).toHaveProperty('id')\n  })\n\n  test('rejects duplicate SKU with 409', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Duplicate', sku: 'WGT-100', price: 19.99},\n    })\n\n    expect(res.status()).toBe(409)\n    expect((await res.json()).message).toContain('already exists')\n  })\n\n  test('returns 422 for missing required fields', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Incomplete'},\n    })\n\n    expect(res.status()).toBe(422)\n    const err = await res.json()\n    expect(err.errors).toContainEqual(expect.objectContaining({field: 'sku'}))\n  })\n\n  test('staff role cannot delete products', async ({request}) => {\n    const staffLogin = await request.post('/api/auth/token', {\n      data: {email: 'staff@shop.io', password: 'staff-pass'},\n    })\n    const staffToken = (await staffLogin.json()).accessToken\n\n    const res = await request.delete('/api/products/123', {\n      headers: {Authorization: `Bearer ${staffToken}`},\n    })\n\n    expect(res.status()).toBe(403)\n  })\n\n  test('lists products with pagination', async ({request}) => {\n    const res = await request.get('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      params: {page: '1', limit: '20'},\n    })\n\n    expect(res.status()).toBe(200)\n    const body = await res.json()\n    expect(body.items).toBeInstanceOf(Array)\n    expect(body.items.length).toBeLessThanOrEqual(20)\n    expect(body).toHaveProperty('totalCount')\n  })\n})\n```\n\n## Component Tests\n\n**Ideal for**:\n\n- Form validation (required fields, format rules, error messages)\n- Interactive widgets (modals, dropdowns, accordions, date pickers)\n- Conditional rendering (show/hide, loading states, empty states)\n- Accessibility per-component (ARIA attributes, keyboard navigation)\n- Responsive layout at different viewports\n- Visual states (hover, focus, disabled, selected)\n\n**Avoid for**:\n\n- Testing routing or navigation between pages\n- Flows requiring real cookies, sessions, or server-side state\n- Data persistence or API contract validation\n- Third-party iframe interactions\n- Anything requiring multiple pages or browser contexts\n\n```typescript\nimport { test, expect } from \"@playwright/experimental-ct-react\";\nimport { ContactForm } from \"../src/components/ContactForm\";\n\ntest.describe(\"ContactForm component\", () => {\n  test(\"displays validation errors on empty submit\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Name is required\")).toBeVisible();\n    await expect(component.getByText(\"Email is required\")).toBeVisible();\n  });\n\n  test(\"rejects malformed email\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"invalid-email\");\n    await component.getByLabel(\"Message\").fill(\"Hello\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Enter a valid email\")).toBeVisible();\n  });\n\n  test(\"invokes onSubmit with form data\", async ({ mount }) => {\n    const submissions: Array<{ name: string; email: string; message: string }> =\n      [];\n    const component = await mount(\n      <ContactForm onSubmit={(data) => submissions.push(data)} />\n    );\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"alex@company.org\");\n    await component.getByLabel(\"Message\").fill(\"Inquiry about pricing\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    expect(submissions).toHaveLength(1);\n    expect(submissions[0]).toEqual({\n      name: \"Alex\",\n      email: \"alex@company.org\",\n      message: \"Inquiry about pricing\",\n    });\n  });\n\n  test(\"disables button during submission\", async ({ mount }) => {\n    const component = await mount(\n      <ContactForm onSubmit={() => {}} submitting={true} />\n    );\n\n    await expect(\n      component.getByRole(\"button\", { name: \"Sending...\" })\n    ).toBeDisabled();\n  });\n\n  test(\"associates labels with inputs for accessibility\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Name\" })\n    ).toBeVisible();\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Email\" })\n    ).toBeVisible();\n  });\n});\n```\n\n## E2E Tests\n\n**Ideal for**:\n\n- Critical user flows that generate revenue (checkout, signup)\n- Authentication flows (login, SSO, MFA, password reset)\n- Multi-page workflows where state carries across navigation\n- Flows involving third-party iframes (payment widgets)\n- Smoke tests validating the entire stack\n- Real-time collaboration requiring multiple browser contexts\n\n**Avoid for**:\n\n- Testing every form validation permutation\n- CRUD operations where UI is a thin wrapper\n- Verifying individual component states\n- Testing API response shapes or error codes\n- Responsive layout at every breakpoint\n- Edge cases that only affect the backend\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('subscription flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.request.post('/api/test/seed-account', {\n      data: {plan: 'free', email: 'subscriber@demo.io'},\n    })\n    await page.goto('/account/upgrade')\n  })\n\n  test('upgrades to premium plan', async ({page}) => {\n    await test.step('select plan', async () => {\n      await expect(page.getByRole('heading', {name: 'Choose Your Plan'})).toBeVisible()\n      await page.getByRole('button', {name: 'Select Premium'}).click()\n    })\n\n    await test.step('enter billing details', async () => {\n      await page.getByLabel('Cardholder name').fill('Sam Johnson')\n      await page.getByLabel('Billing address').fill('456 Oak Ave')\n      await page.getByLabel('City').fill('Seattle')\n      await page.getByRole('combobox', {name: 'State'}).selectOption('WA')\n      await page.getByLabel('Postal code').fill('98101')\n      await page.getByRole('button', {name: 'Continue'}).click()\n    })\n\n    await test.step('complete payment', async () => {\n      const paymentFrame = page.frameLocator('iframe[title=\"Secure Payment\"]')\n      await paymentFrame.getByLabel('Card number').fill('5555555555554444')\n      await paymentFrame.getByLabel('Expiry').fill('09/29')\n      await paymentFrame.getByLabel('CVV').fill('456')\n      await page.getByRole('button', {name: 'Subscribe now'}).click()\n    })\n\n    await test.step('verify success', async () => {\n      await page.waitForURL('**/account/subscription/success**')\n      await expect(page.getByRole('heading', {name: 'Welcome to Premium'})).toBeVisible()\n      await expect(page.getByText(/Subscription #\\d+/)).toBeVisible()\n    })\n  })\n})\n```\n\n## Layering Test Types\n\nEffective test suites combine all three types. Example for an \"inventory management\" feature:\n\n### API Layer (60% of tests)\n\nCover every backend logic permutation. Cheap to run and maintain.\n\n```\ntests/api/inventory.spec.ts\n  - creates item with valid data (201)\n  - rejects duplicate SKU (409)\n  - rejects invalid quantity format (422)\n  - rejects missing required fields (422)\n  - warehouse-staff cannot delete items (403)\n  - unauthenticated request returns 401\n  - lists items with pagination\n  - filters items by category\n  - updates item stock level\n  - archives an item\n  - prevents archiving items with pending orders\n```\n\n### Component Layer (30% of tests)\n\nCover every visual state and interaction.\n\n```\ntests/components/InventoryForm.spec.tsx\n  - shows validation errors on empty submit\n  - shows inline error for invalid SKU format\n  - disables submit while saving\n  - calls onSubmit with form data\n  - resets form after successful save\n\ntests/components/InventoryTable.spec.tsx\n  - renders item rows from props\n  - shows empty state when no items\n  - handles archive confirmation modal\n  - sorts by column header click\n  - shows stock level badges with correct colors\n```\n\n### E2E Layer (10% of tests)\n\nCover only critical paths proving full stack works.\n\n```\ntests/e2e/inventory.spec.ts\n  - manager creates item and sees it in list\n  - manager updates item stock level\n  - warehouse-staff cannot access admin settings\n```\n\n### Execution Profile\n\nFor this feature:\n\n- **11 API tests** — ~2 seconds total, no browser\n- **10 component tests** — ~5 seconds total, real browser but no server\n- **3 E2E tests** — ~15 seconds total, full stack\n\nTotal: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).\n\n## Common Mistakes\n\n| Anti-Pattern                              | Problem                                                  | Better Approach                                                |\n| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |\n| E2E for every validation rule             | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display  |\n| No API tests, all E2E                     | Slow suite, flaky from UI timing, hard to diagnose       | API tests for data/logic, E2E for critical paths only          |\n| Component tests mocking everything        | Tests pass but app broken because mocks drift            | Mock only external boundaries; API tests verify real contracts |\n| Same assertion in API, component, AND E2E | Triple maintenance cost                                  | Each layer tests what it uniquely verifies                     |\n| E2E creating test data via UI             | 2-minute test where 90 seconds is setup                  | Seed via API in `beforeEach`, test actual flow                 |\n| Testing third-party behavior              | Testing that Stripe validates cards (Stripe's job)       | Mock Stripe; trust their contract                              |\n| Skipping API layer                        | Can't tell if bug is frontend or backend                 | API tests isolate backend; component tests isolate frontend    |\n| One giant E2E for entire feature          | 5-minute test failing somewhere with no clear cause      | Focused E2E per critical path; use `test.step()`               |\n\n## Related\n\n- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming\n- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing\n- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests\n- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`\n- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services\n- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic\n",".agents/skills/playwright-best-practices/architecture/when-to-mock.md":"# Mocking Strategy: Real vs Mock Services\n\n## Table of Contents\n\n1. [Core Principle](#core-principle)\n2. [Decision Matrix](#decision-matrix)\n3. [Decision Flowchart](#decision-flowchart)\n4. [Mocking Techniques](#mocking-techniques)\n5. [Real Service Strategies](#real-service-strategies)\n6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)\n7. [Validating Mock Accuracy](#validating-mock-accuracy)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.\n\n## Core Principle\n\n**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.\n\n## Decision Matrix\n\n| Scenario                          | Mock?                    | Strategy                                        |\n| --------------------------------- | ------------------------ | ----------------------------------------------- |\n| Your own REST/GraphQL API         | Never                    | Hit real API against staging or local dev       |\n| Your database (through your API)  | Never                    | Seed via API or fixtures                        |\n| Authentication (your auth system) | Mostly no                | Use `storageState` to skip login in most tests  |\n| Stripe / payment gateway          | Always                   | `route.fulfill()` with expected responses       |\n| SendGrid / email service          | Always                   | Mock the API call, verify request payload       |\n| OAuth providers (Google, GitHub)  | Always                   | Mock token exchange, test your callback handler |\n| Analytics (Segment, Mixpanel)     | Always                   | `route.abort()` or `route.fulfill()`            |\n| Maps / geocoding APIs             | Always                   | Mock with static responses                      |\n| Feature flags (LaunchDarkly)      | Usually                  | Mock to force specific flag states              |\n| CDN / static assets               | Never                    | Let them load normally                          |\n| Flaky external dependency         | CI: mock, local: real    | Conditional mocking based on environment        |\n| Slow external dependency          | Dev: mock, nightly: real | Separate test projects in config                |\n\n## Decision Flowchart\n\n```text\nIs this service part of YOUR codebase?\n├── YES → Do NOT mock. Test the real integration.\n│   ├── Is it slow? → Optimize the service, not the test.\n│   └── Is it flaky? → Fix the service. Flaky infra is a bug.\n└── NO → It's a third-party service.\n    ├── Is it paid per call? → ALWAYS mock.\n    ├── Is it rate-limited? → ALWAYS mock.\n    ├── Is it slow or unreliable? → ALWAYS mock.\n    └── Is it a complex multi-step flow? → Mock with HAR recording.\n```\n\n## Mocking Techniques\n\n### Blocking Unwanted Requests\n\nBlock third-party scripts that slow tests and add no coverage:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {\n    route.abort()\n  })\n})\n\ntest('dashboard renders without tracking scripts', async ({page}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n})\n```\n\n### Full Mock (route.fulfill)\n\nCompletely replace a third-party API response:\n\n```typescript\ntest('order flow with mocked payment service', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        transactionId: 'txn_mock_abc',\n        status: 'completed',\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n\ntest('display error on payment decline', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 402,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        error: {code: 'insufficient_funds', message: 'Card declined.'},\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByRole('alert')).toContainText('Card declined')\n})\n```\n\n### Partial Mock (Modify Responses)\n\nLet the real API call happen but tweak the response:\n\n```typescript\ntest('display low inventory warning', async ({page}) => {\n  await page.route('**/api/inventory/*', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.quantity = 1\n    data.lowStock = true\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/products/widget-pro')\n  await expect(page.getByText('Only 1 remaining')).toBeVisible()\n})\n\ntest('inject test notification into real response', async ({page}) => {\n  await page.route('**/api/alerts', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.items.push({\n      id: 'test-alert',\n      text: 'Report generated',\n      category: 'info',\n    })\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/home')\n  await expect(page.getByText('Report generated')).toBeVisible()\n})\n```\n\n### Record and Replay (HAR Files)\n\nFor complex API sequences (OAuth flows, multi-step wizards):\n\n**Recording:**\n\n```typescript\ntest('capture API traffic for admin panel', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: true,\n  })\n\n  await page.goto('/admin')\n  await page.getByRole('tab', {name: 'Reports'}).click()\n  await page.getByRole('tab', {name: 'Settings'}).click()\n})\n```\n\n**Replaying:**\n\n```typescript\ntest('admin panel loads with recorded data', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: false,\n  })\n\n  await page.goto('/admin')\n  await expect(page.getByRole('heading', {name: 'Reports'})).toBeVisible()\n})\n```\n\n**HAR maintenance:**\n\n- Record against a known-good staging environment\n- Commit `.har` files to version control\n- Re-record when APIs change\n- Scope HAR to specific URL patterns\n\n## Real Service Strategies\n\n### Local Dev Server\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 30_000,\n  },\n  use: {\n    baseURL: 'http://localhost:3000',\n  },\n})\n```\n\n### Staging Environment\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    baseURL: process.env.CI ? 'https://staging.example.com' : 'http://localhost:3000',\n  },\n})\n```\n\n### Test Containers\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'docker compose -f docker-compose.test.yml up --wait',\n    url: 'http://localhost:3000/health',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n  globalTeardown: './tests/global-teardown.ts',\n})\n```\n\n```typescript\n// tests/global-teardown.ts\nimport {execSync} from 'child_process'\n\nexport default function globalTeardown() {\n  if (process.env.CI) {\n    execSync('docker compose -f docker-compose.test.yml down -v')\n  }\n}\n```\n\n## Hybrid Approach: Fixture-Based Mock Control\n\nCreate fixtures that let individual tests opt into mocking specific services:\n\n```typescript\n// tests/fixtures/service-mocks.ts\nimport {test as base} from '@playwright/test'\n\ntype MockConfig = {\n  mockPayments: boolean\n  mockNotifications: boolean\n  mockAnalytics: boolean\n}\n\nexport const test = base.extend<MockConfig>({\n  mockPayments: [true, {option: true}],\n  mockNotifications: [true, {option: true}],\n  mockAnalytics: [true, {option: true}],\n\n  page: async ({page, mockPayments, mockNotifications, mockAnalytics}, use) => {\n    if (mockPayments) {\n      await page.route('**/api/billing/**', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({status: 'paid', id: 'inv_mock_789'}),\n        })\n      })\n    }\n\n    if (mockNotifications) {\n      await page.route('**/api/notify', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({delivered: true}),\n        })\n      })\n    }\n\n    if (mockAnalytics) {\n      await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {\n        route.abort()\n      })\n    }\n\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/billing.spec.ts\nimport {test, expect} from './fixtures/service-mocks'\n\ntest('subscription renewal sends notification', async ({page}) => {\n  await page.goto('/account/billing')\n  await page.getByRole('button', {name: 'Renew Now'}).click()\n  await expect(page.getByText('Subscription renewed')).toBeVisible()\n})\n\ntest.describe('integration suite', () => {\n  test.use({mockPayments: false})\n\n  test('real billing flow against test gateway', async ({page}) => {\n    await page.goto('/account/billing')\n    await page.getByRole('button', {name: 'Renew Now'}).click()\n    await expect(page.getByText('Subscription renewed')).toBeVisible()\n  })\n})\n```\n\n### Environment-Based Test Projects\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'ci-fast',\n      testMatch: '**/*.spec.ts',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'nightly-full',\n      testMatch: '**/*.integration.spec.ts',\n      use: {baseURL: 'https://staging.example.com'},\n      timeout: 120_000,\n    },\n  ],\n})\n```\n\n## Validating Mock Accuracy\n\nGuard against mock drift from real APIs:\n\n```typescript\ntest.describe('contract validation', () => {\n  test('billing mock matches real API shape', async ({request}) => {\n    const realResponse = await request.post('/api/billing/charge', {\n      data: {amount: 5000, currency: 'usd'},\n    })\n    const realBody = await realResponse.json()\n\n    const mockBody = {\n      status: 'paid',\n      id: 'inv_mock_789',\n    }\n\n    expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())\n\n    for (const key of Object.keys(mockBody)) {\n      expect(typeof mockBody[key]).toBe(typeof realBody[key])\n    }\n  })\n})\n```\n\n## Anti-Patterns\n\n| Don't Do This                             | Problem                                                       | Do This Instead                                               |\n| ----------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |\n| Mock your own API                         | Tests pass, app breaks. Zero integration coverage.            | Hit your real API. Mock only third-party services.            |\n| Mock everything for speed                 | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries.                                |\n| Never mock anything                       | Tests are slow, flaky, fail when third parties have outages.  | Mock third-party services.                                    |\n| Use outdated mocks                        | Mock returns different shape than real API.                   | Run contract validation tests. Re-record HAR files regularly. |\n| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation.                          | Use `page.route()` which intercepts at network layer.         |\n| Copy-paste mocks across files             | One API change requires updating many files.                  | Centralize mocks in fixtures.                                 |\n| Block all network and whitelist           | Extremely brittle. Every new endpoint requires update.        | Allow all by default. Selectively mock third-party services.  |\n",".agents/skills/playwright-best-practices/browser-apis/browser-apis.md":"# Browser APIs: Geolocation, Permissions & More\n\n## Table of Contents\n\n1. [Geolocation](#geolocation)\n2. [Permissions](#permissions)\n3. [Clipboard](#clipboard)\n4. [Notifications](#notifications)\n5. [Camera & Microphone](#camera--microphone)\n\n## Geolocation\n\n### Mock Location\n\n```typescript\ntest('shows nearby stores', async ({context}) => {\n  // Grant permission and set location\n  await context.grantPermissions(['geolocation'])\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194}) // San Francisco\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('San Francisco')).toBeVisible()\n})\n```\n\n### Geolocation Fixture\n\n```typescript\n// fixtures/geolocation.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype Coordinates = {latitude: number; longitude: number; accuracy?: number}\n\ntype GeoFixtures = {\n  setLocation: (coords: Coordinates) => Promise<void>\n}\n\nexport const test = base.extend<GeoFixtures>({\n  setLocation: async ({context}, use) => {\n    await context.grantPermissions(['geolocation'])\n\n    await use(async (coords) => {\n      await context.setGeolocation({\n        latitude: coords.latitude,\n        longitude: coords.longitude,\n        accuracy: coords.accuracy ?? 100,\n      })\n    })\n  },\n})\n\n// Usage\ntest('delivery zone check', async ({page, setLocation}) => {\n  await setLocation({latitude: 40.7128, longitude: -74.006}) // NYC\n\n  await page.goto('/delivery')\n\n  await expect(page.getByText('Delivery available')).toBeVisible()\n})\n```\n\n### Test Location Changes\n\n```typescript\ntest('tracks location updates', async ({context}) => {\n  await context.grantPermissions(['geolocation'])\n\n  const page = await context.newPage()\n  await page.goto('/tracking')\n\n  // Initial location\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194})\n  await page.getByRole('button', {name: 'Start Tracking'}).click()\n\n  await expect(page.getByTestId('location')).toContainText('37.7749')\n\n  // Move to new location\n  await context.setGeolocation({latitude: 37.8044, longitude: -122.2712})\n\n  // Trigger location update\n  await page.evaluate(() => {\n    navigator.geolocation.getCurrentPosition(() => {})\n  })\n\n  await expect(page.getByTestId('location')).toContainText('37.8044')\n})\n```\n\n### Test Geolocation Denial\n\n```typescript\ntest('handles location denied', async ({browser}) => {\n  // Create context without geolocation permission\n  const context = await browser.newContext({\n    permissions: [], // No permissions\n  })\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('Location access denied')).toBeVisible()\n  await expect(page.getByLabel('Enter ZIP code')).toBeVisible()\n\n  await context.close()\n})\n```\n\n## Permissions\n\n### Grant Permissions\n\n```typescript\ntest('notifications with permission', async ({context}) => {\n  await context.grantPermissions(['notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/alerts')\n\n  // Notification API should work\n  const permission = await page.evaluate(() => Notification.permission)\n  expect(permission).toBe('granted')\n})\n```\n\n### Test Permission Denied\n\n```typescript\ntest('handles notification permission denied', async ({browser}) => {\n  const context = await browser.newContext({\n    permissions: [], // Deny all\n  })\n\n  const page = await context.newPage()\n  await page.goto('/notifications')\n\n  await page.getByRole('button', {name: 'Enable Notifications'}).click()\n\n  await expect(page.getByText('Please enable notifications')).toBeVisible()\n\n  await context.close()\n})\n```\n\n### Multiple Permissions\n\n```typescript\ntest('video call with permissions', async ({context}) => {\n  await context.grantPermissions(['camera', 'microphone', 'notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/video-call')\n\n  // All permissions should be granted\n  const permissions = await page.evaluate(async () => ({\n    camera: await navigator.permissions.query({\n      name: 'camera' as PermissionName,\n    }),\n    microphone: await navigator.permissions.query({\n      name: 'microphone' as PermissionName,\n    }),\n  }))\n\n  expect(permissions.camera.state).toBe('granted')\n  expect(permissions.microphone.state).toBe('granted')\n})\n```\n\n## Clipboard\n\n### Test Copy to Clipboard\n\n```typescript\ntest('copy button works', async ({page, context}) => {\n  // Grant clipboard permissions\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/share')\n\n  await page.getByRole('button', {name: 'Copy Link'}).click()\n\n  // Read clipboard content\n  const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())\n\n  expect(clipboardContent).toContain('https://example.com/share/')\n})\n```\n\n### Test Paste from Clipboard\n\n```typescript\ntest('paste from clipboard', async ({page, context}) => {\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/editor')\n\n  // Write to clipboard\n  await page.evaluate(() => navigator.clipboard.writeText('Pasted content'))\n\n  // Trigger paste\n  await page.getByLabel('Content').focus()\n  await page.keyboard.press('Control+V')\n\n  await expect(page.getByLabel('Content')).toHaveValue('Pasted content')\n})\n```\n\n### Clipboard Fixture\n\n```typescript\n// fixtures/clipboard.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClipboardFixtures = {\n  clipboard: {\n    write: (text: string) => Promise<void>\n    read: () => Promise<string>\n  }\n}\n\nexport const test = base.extend<ClipboardFixtures>({\n  clipboard: async ({page, context}, use) => {\n    await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n    await use({\n      write: async (text) => {\n        await page.evaluate((t) => navigator.clipboard.writeText(t), text)\n      },\n      read: async () => {\n        return page.evaluate(() => navigator.clipboard.readText())\n      },\n    })\n  },\n})\n```\n\n## Notifications\n\n### Mock Notification API\n\n```typescript\ntest('shows browser notification', async ({page}) => {\n  const notifications: any[] = []\n\n  // Mock Notification constructor\n  await page.addInitScript(() => {\n    ;(window as any).__notifications = []\n    ;(window as any).Notification = class {\n      constructor(title: string, options?: NotificationOptions) {\n        ;(window as any).__notifications.push({title, ...options})\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/alerts')\n  await page.getByRole('button', {name: 'Notify Me'}).click()\n\n  // Check notification was created\n  const created = await page.evaluate(() => (window as any).__notifications)\n  expect(created).toHaveLength(1)\n  expect(created[0].title).toBe('New Alert')\n})\n```\n\n### Test Notification Click\n\n```typescript\ntest('notification click handler', async ({page}) => {\n  await page.addInitScript(() => {\n    ;(window as any).Notification = class {\n      onclick: (() => void) | null = null\n      constructor(title: string) {\n        // Simulate click after creation\n        setTimeout(() => this.onclick?.(), 100)\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/messages')\n  await page.evaluate(() => {\n    new Notification('New Message')\n  })\n\n  // Should navigate to messages when notification clicked\n  await expect(page).toHaveURL(/\\/messages/)\n})\n```\n\n## Camera & Microphone\n\n### Mock Media Devices\n\n```typescript\ntest('video preview works', async ({page, context}) => {\n  await context.grantPermissions(['camera'])\n\n  // Mock getUserMedia\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      canvas.width = 640\n      canvas.height = 480\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/video-settings')\n  await page.getByRole('button', {name: 'Start Camera'}).click()\n\n  await expect(page.getByTestId('video-preview')).toBeVisible()\n})\n```\n\n### Test Media Device Selection\n\n```typescript\ntest('switch camera', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.enumerateDevices = async () =>\n      [\n        {\n          deviceId: 'cam1',\n          kind: 'videoinput',\n          label: 'Front Camera',\n          groupId: '1',\n        },\n        {\n          deviceId: 'cam2',\n          kind: 'videoinput',\n          label: 'Back Camera',\n          groupId: '2',\n        },\n      ] as MediaDeviceInfo[]\n\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/camera')\n\n  // Should show camera options\n  await expect(page.getByRole('combobox', {name: 'Camera'})).toBeVisible()\n  await expect(page.getByText('Front Camera')).toBeVisible()\n  await expect(page.getByText('Back Camera')).toBeVisible()\n})\n```\n\n### Test Media Errors\n\n```typescript\ntest('handles camera access error', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      throw new DOMException('Permission denied', 'NotAllowedError')\n    }\n  })\n\n  await page.goto('/video-call')\n  await page.getByRole('button', {name: 'Join Call'}).click()\n\n  await expect(page.getByText('Camera access denied')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Join Audio Only'})).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                           | Solution                            |\n| ----------------------------- | --------------------------------- | ----------------------------------- |\n| Not granting permissions      | Tests fail with permission errors | Use `context.grantPermissions()`    |\n| Testing real geolocation      | Flaky, environment-dependent      | Mock with `setGeolocation()`        |\n| Not testing permission denial | Misses error handling             | Test both granted and denied states |\n| Using real camera/mic         | CI has no devices                 | Mock `getUserMedia`                 |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures\n- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation\n",".agents/skills/playwright-best-practices/browser-apis/iframes.md":"# iFrame Testing\n\n## Table of Contents\n\n1. [Basic iFrame Access](#basic-iframe-access)\n2. [Cross-Origin iFrames](#cross-origin-iframes)\n3. [Nested iFrames](#nested-iframes)\n4. [Dynamic iFrames](#dynamic-iframes)\n5. [iFrame Navigation](#iframe-navigation)\n6. [Common Patterns](#common-patterns)\n\n## Basic iFrame Access\n\n### Using frameLocator\n\n```typescript\n// Access iframe by selector\nconst frame = page.frameLocator('iframe#payment')\nawait frame.getByRole('button', {name: 'Pay'}).click()\n\n// Access by name attribute\nconst namedFrame = page.frameLocator('iframe[name=\"checkout\"]')\nawait namedFrame.getByLabel('Card number').fill('4242424242424242')\n\n// Access by title\nconst titledFrame = page.frameLocator('iframe[title=\"Payment Form\"]')\n\n// Access by src (partial match)\nconst srcFrame = page.frameLocator('iframe[src*=\"stripe.com\"]')\n```\n\n### Frame vs FrameLocator\n\n```typescript\n// frameLocator - for locator-based operations (recommended)\nconst frameLocator = page.frameLocator('#my-iframe')\nawait frameLocator.getByRole('button').click()\n\n// frame() - for Frame object operations (navigation, evaluation)\nconst frame = page.frame({name: 'my-frame'})\nif (frame) {\n  await frame.goto('https://example.com')\n  const title = await frame.title()\n}\n\n// Get all frames\nconst frames = page.frames()\nfor (const f of frames) {\n  console.log('Frame URL:', f.url())\n}\n```\n\n### Waiting for iFrame Content\n\n```typescript\n// Wait for iframe to load\nconst frame = page.frameLocator('#dynamic-iframe')\n\n// Wait for element inside iframe\nawait expect(frame.getByRole('heading')).toBeVisible({timeout: 10000})\n\n// Wait for iframe src to change\nawait page.waitForFunction(() => {\n  const iframe = document.querySelector('iframe#my-frame') as HTMLIFrameElement\n  return iframe?.src.includes('loaded')\n})\n```\n\n## Cross-Origin iFrames\n\n### Accessing Cross-Origin Content\n\n```typescript\n// Cross-origin iframes work seamlessly with frameLocator\nconst thirdPartyFrame = page.frameLocator('iframe[src*=\"third-party.com\"]')\n\n// Interact with elements inside cross-origin iframe\nawait thirdPartyFrame.getByRole('textbox').fill('test@example.com')\nawait thirdPartyFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Wait for cross-origin iframe to be ready\nawait expect(thirdPartyFrame.locator('body')).toBeVisible()\n```\n\n### Payment Provider iFrames (Stripe, PayPal)\n\n```typescript\ntest('Stripe payment iframe', async ({page}) => {\n  await page.goto('/checkout')\n\n  // Stripe uses multiple iframes for each field\n  const cardFrame = page.frameLocator('iframe[name*=\"__privateStripeFrame\"]').first()\n\n  // Wait for Stripe to initialize\n  await expect(cardFrame.locator('[placeholder=\"Card number\"]')).toBeVisible({\n    timeout: 15000,\n  })\n\n  // Fill card details\n  await cardFrame.locator('[placeholder=\"Card number\"]').fill('4242424242424242')\n  await cardFrame.locator('[placeholder=\"MM / YY\"]').fill('12/30')\n  await cardFrame.locator('[placeholder=\"CVC\"]').fill('123')\n})\n```\n\n### Handling OAuth in iFrames\n\n```typescript\ntest('OAuth iframe flow', async ({page}) => {\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // If OAuth opens in iframe instead of popup\n  const oauthFrame = page.frameLocator('iframe[src*=\"accounts.google.com\"]')\n\n  // Wait for OAuth form\n  await expect(oauthFrame.getByLabel('Email')).toBeVisible({timeout: 10000})\n  await oauthFrame.getByLabel('Email').fill('test@gmail.com')\n})\n```\n\n## Nested iFrames\n\n### Accessing Nested Frames\n\n```typescript\n// Parent iframe contains child iframe\nconst parentFrame = page.frameLocator('#outer-frame')\nconst childFrame = parentFrame.frameLocator('#inner-frame')\n\n// Interact with deeply nested content\nawait childFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Multiple levels of nesting\nconst level1 = page.frameLocator('#level1')\nconst level2 = level1.frameLocator('#level2')\nconst level3 = level2.frameLocator('#level3')\nawait level3.getByText('Deep content').click()\n```\n\n### Finding Elements Across Frame Hierarchy\n\n```typescript\n// Helper to search all frames for an element\nasync function findInAnyFrame(page: Page, selector: string): Promise<Locator | null> {\n  // Check main page first\n  const mainCount = await page.locator(selector).count()\n  if (mainCount > 0) return page.locator(selector)\n\n  // Check all frames\n  for (const frame of page.frames()) {\n    const count = await frame.locator(selector).count()\n    if (count > 0) {\n      return frame.locator(selector)\n    }\n  }\n  return null\n}\n\ntest('find element in any frame', async ({page}) => {\n  await page.goto('/complex-page')\n  const element = await findInAnyFrame(page, '[data-testid=\"submit-btn\"]')\n  if (element) await element.click()\n})\n```\n\n## Dynamic iFrames\n\n### iFrames Created at Runtime\n\n```typescript\ntest('handle dynamically created iframe', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Click button that creates iframe\n  await page.getByRole('button', {name: 'Open Widget'}).click()\n\n  // Wait for iframe to appear in DOM\n  await page.waitForSelector('iframe#widget-frame')\n\n  // Now access the frame\n  const widgetFrame = page.frameLocator('#widget-frame')\n  await expect(widgetFrame.getByText('Widget Loaded')).toBeVisible()\n})\n```\n\n### iFrames with Changing src\n\n```typescript\ntest('iframe src changes', async ({page}) => {\n  await page.goto('/multi-step')\n\n  const frame = page.frameLocator('#step-frame')\n\n  // Step 1\n  await expect(frame.getByText('Step 1')).toBeVisible()\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for iframe to reload with new content\n  await expect(frame.getByText('Step 2')).toBeVisible({timeout: 10000})\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Step 3\n  await expect(frame.getByText('Step 3')).toBeVisible({timeout: 10000})\n})\n```\n\n### Lazy-Loaded iFrames\n\n```typescript\ntest('lazy loaded iframe', async ({page}) => {\n  await page.goto('/page-with-lazy-iframe')\n\n  // Scroll to trigger lazy load\n  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))\n\n  // Wait for iframe to load\n  const lazyFrame = page.frameLocator('#lazy-iframe')\n  await expect(lazyFrame.locator('body')).not.toBeEmpty({timeout: 15000})\n\n  // Interact with content\n  await lazyFrame.getByRole('button').click()\n})\n```\n\n## iFrame Navigation\n\n### Navigating Within iFrame\n\n```typescript\ntest('iframe internal navigation', async ({page}) => {\n  await page.goto('/app')\n\n  // Get frame object for navigation control\n  const frame = page.frame({name: 'content-frame'})\n  if (!frame) throw new Error('Frame not found')\n\n  // Navigate within iframe\n  await frame.goto('https://embedded-app.com/page2')\n\n  // Wait for navigation\n  await frame.waitForURL('**/page2')\n\n  // Verify content\n  await expect(frame.getByRole('heading')).toHaveText('Page 2')\n})\n```\n\n### Handling Frame Navigation Events\n\n```typescript\ntest('track iframe navigation', async ({page}) => {\n  const navigations: string[] = []\n\n  // Listen to frame navigation\n  page.on('framenavigated', (frame) => {\n    if (frame.parentFrame()) {\n      // This is an iframe navigation\n      navigations.push(frame.url())\n    }\n  })\n\n  await page.goto('/with-iframe')\n  await page.frameLocator('#nav-frame').getByRole('link', {name: 'Page 2'}).click()\n\n  // Verify navigation occurred\n  expect(navigations.some((url) => url.includes('page2'))).toBe(true)\n})\n```\n\n## Common Patterns\n\n### iFrame Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base, FrameLocator} from '@playwright/test'\n\nexport const test = base.extend<{paymentFrame: FrameLocator}>({\n  paymentFrame: async ({page}, use) => {\n    await page.goto('/checkout')\n\n    // Wait for payment iframe to be ready\n    const frame = page.frameLocator('iframe[src*=\"payment\"]')\n    await expect(frame.locator('body')).toBeVisible({timeout: 15000})\n\n    await use(frame)\n  },\n})\n\n// test file\ntest('complete payment', async ({paymentFrame}) => {\n  await paymentFrame.getByLabel('Card').fill('4242424242424242')\n  await paymentFrame.getByRole('button', {name: 'Pay'}).click()\n})\n```\n\n### Debugging iFrame Issues\n\n```typescript\ntest('debug iframe content', async ({page}) => {\n  await page.goto('/page-with-iframes')\n\n  // List all frames\n  console.log('All frames:')\n  for (const frame of page.frames()) {\n    console.log(`  - ${frame.name() || '(unnamed)'}: ${frame.url()}`)\n  }\n\n  // Screenshot specific iframe content\n  const frame = page.frame({name: 'target-frame'})\n  if (frame) {\n    const body = frame.locator('body')\n    await body.screenshot({path: 'iframe-content.png'})\n  }\n\n  // Get iframe HTML for debugging\n  const frameContent = page.frameLocator('#my-frame')\n  const html = await frameContent.locator('body').innerHTML()\n  console.log('iFrame HTML:', html.substring(0, 500))\n})\n```\n\n### Handling iFrame Load Failures\n\n```typescript\ntest('handle iframe load failure', async ({page}) => {\n  await page.goto('/page-with-unreliable-iframe')\n\n  const frame = page.frameLocator('#unreliable-frame')\n\n  try {\n    // Try to interact with iframe content\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 5000})\n    await frame.getByRole('button').click()\n  } catch (error) {\n    // Fallback: refresh iframe\n    await page.evaluate(() => {\n      const iframe = document.querySelector('#unreliable-frame') as HTMLIFrameElement\n      if (iframe) iframe.src = iframe.src\n    })\n\n    // Retry\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 10000})\n    await frame.getByRole('button').click()\n  }\n})\n```\n\n### Mocking iFrame Content\n\n```typescript\ntest('mock iframe response', async ({page}) => {\n  // Intercept iframe src request\n  await page.route('**/embedded-widget**', (route) => {\n    route.fulfill({\n      contentType: 'text/html',\n      body: `\n        <!DOCTYPE html>\n        <html>\n          <body>\n            <h1>Mocked Widget</h1>\n            <button>Mocked Button</button>\n          </body>\n        </html>\n      `,\n    })\n  })\n\n  await page.goto('/page-with-widget')\n\n  const frame = page.frameLocator('#widget-frame')\n  await expect(frame.getByRole('heading')).toHaveText('Mocked Widget')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                           | Solution                                           |\n| ------------------------------------- | --------------------------------- | -------------------------------------------------- |\n| Using `page.frame()` for interactions | Less reliable than frameLocator   | Use `page.frameLocator()` for element interactions |\n| Hardcoding iframe index               | Fragile if DOM order changes      | Use name, id, or src attribute selectors           |\n| Not waiting for iframe load           | Race conditions                   | Wait for element inside iframe to be visible       |\n| Assuming same-origin                  | Cross-origin has different timing | Always wait for iframe content explicitly          |\n| Ignoring nested iframes               | Element not found                 | Chain frameLocator calls for nested frames         |\n\n## Related References\n\n- **Locators**: See [locators.md](../core/locators.md) for selector strategies\n- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues\n",".agents/skills/playwright-best-practices/browser-apis/service-workers.md":"# Service Worker Testing\n\n## Table of Contents\n\n1. [Service Worker Basics](#service-worker-basics)\n2. [Registration & Lifecycle](#registration--lifecycle)\n3. [Cache Testing](#cache-testing)\n4. [Offline Testing](#offline-testing)\n5. [Push Notifications](#push-notifications)\n6. [Background Sync](#background-sync)\n\n## Service Worker Basics\n\n### Waiting for Service Worker Registration\n\n```typescript\ntest('service worker registers', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to register\n  const swRegistered = await page.evaluate(async () => {\n    if (!('serviceWorker' in navigator)) return false\n\n    const registration = await navigator.serviceWorker.ready\n    return !!registration.active\n  })\n\n  expect(swRegistered).toBe(true)\n})\n```\n\n### Getting Service Worker State\n\n```typescript\ntest('check SW state', async ({page}) => {\n  await page.goto('/')\n\n  const swState = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.getRegistration()\n    if (!registration) return null\n\n    return {\n      installing: !!registration.installing,\n      waiting: !!registration.waiting,\n      active: !!registration.active,\n      scope: registration.scope,\n    }\n  })\n\n  expect(swState?.active).toBe(true)\n  expect(swState?.scope).toContain(page.url())\n})\n```\n\n### Service Worker Context\n\n```typescript\ntest('access service worker', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Get all service workers in context\n  const workers = context.serviceWorkers()\n\n  // Wait for service worker if not yet available\n  if (workers.length === 0) {\n    await context.waitForEvent('serviceworker')\n  }\n\n  const sw = context.serviceWorkers()[0]\n  expect(sw.url()).toContain('sw.js')\n})\n```\n\n## Registration & Lifecycle\n\n### Testing SW Update Flow\n\n```typescript\ntest('service worker updates', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Check for update\n  const hasUpdate = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.ready\n    await registration.update()\n\n    return new Promise<boolean>((resolve) => {\n      if (registration.waiting) {\n        resolve(true)\n      } else {\n        registration.addEventListener('updatefound', () => {\n          resolve(true)\n        })\n        // Timeout if no update\n        setTimeout(() => resolve(false), 5000)\n      }\n    })\n  })\n\n  // If update found, test skip waiting flow\n  if (hasUpdate) {\n    await page.evaluate(async () => {\n      const registration = await navigator.serviceWorker.ready\n      registration.waiting?.postMessage({type: 'SKIP_WAITING'})\n    })\n\n    // Wait for controller change\n    await page.evaluate(() => {\n      return new Promise<void>((resolve) => {\n        navigator.serviceWorker.addEventListener('controllerchange', () => {\n          resolve()\n        })\n      })\n    })\n  }\n})\n```\n\n### Testing SW Installation\n\n```typescript\ntest('verify SW install event', async ({context, page}) => {\n  // Listen for service worker before navigating\n  const swPromise = context.waitForEvent('serviceworker')\n\n  await page.goto('/pwa-app')\n\n  const sw = await swPromise\n\n  // Evaluate in SW context\n  const swVersion = await sw.evaluate(() => {\n    // Access SW globals\n    return (self as any).SW_VERSION || 'unknown'\n  })\n\n  expect(swVersion).toBe('1.0.0')\n})\n```\n\n### Unregistering Service Workers\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.goto('/')\n\n  // Unregister all service workers for clean state\n  await page.evaluate(async () => {\n    const registrations = await navigator.serviceWorker.getRegistrations()\n    await Promise.all(registrations.map((r) => r.unregister()))\n  })\n\n  // Clear caches\n  await page.evaluate(async () => {\n    const cacheNames = await caches.keys()\n    await Promise.all(cacheNames.map((name) => caches.delete(name)))\n  })\n})\n```\n\n## Cache Testing\n\n### Verifying Cached Resources\n\n```typescript\ntest('assets are cached', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to cache assets\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n\n  // Check cache contents\n  const cachedUrls = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const requests = await cache.keys()\n    return requests.map((r) => r.url)\n  })\n\n  expect(cachedUrls).toContain(expect.stringContaining('/styles.css'))\n  expect(cachedUrls).toContain(expect.stringContaining('/app.js'))\n})\n```\n\n### Testing Cache Strategies\n\n```typescript\ntest('cache-first strategy', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for initial cache\n  await page.waitForFunction(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.length > 0\n  })\n\n  // Block network for cached resources\n  await page.route('**/styles.css', (route) => route.abort())\n\n  // Reload - should work from cache\n  await page.reload()\n\n  // Verify page still styled (CSS loaded from cache)\n  const hasStyles = await page.evaluate(() => {\n    const body = document.body\n    const styles = window.getComputedStyle(body)\n    return styles.fontFamily !== '' // Has custom font from CSS\n  })\n\n  expect(hasStyles).toBe(true)\n})\n```\n\n### Testing Cache Updates\n\n```typescript\ntest('cache updates on new version', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Get initial cache\n  const initialCacheKeys = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.map((r) => r.url)\n  })\n\n  // Simulate app update by mocking SW response\n  await page.route('**/sw.js', (route) => {\n    route.fulfill({\n      contentType: 'application/javascript',\n      body: `\n        const VERSION = 'v2';\n        self.addEventListener('install', (e) => {\n          e.waitUntil(caches.open('app-cache-v2'));\n          self.skipWaiting();\n        });\n      `,\n    })\n  })\n\n  // Trigger update\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.update()\n  })\n\n  // Verify new cache exists\n  await page.waitForFunction(async () => {\n    return await caches.has('app-cache-v2')\n  })\n})\n```\n\n## Offline Testing\n\nThis section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).\n\n### Simulating Offline Mode\n\n```typescript\ntest('app works offline', async ({page, context}) => {\n  await page.goto('/pwa-app')\n\n  // Ensure SW is active and content cached\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n  await page.waitForTimeout(1000) // Allow caching to complete\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to cached page\n  await page.reload()\n\n  // Verify content loads\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  // Verify offline indicator\n  await expect(page.locator('.offline-badge')).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n  await expect(page.locator('.offline-badge')).not.toBeVisible()\n})\n```\n\n### Testing Offline Fallback\n\n```typescript\ntest('shows offline page for uncached routes', async ({page, context}) => {\n  await page.goto('/pwa-app')\n  await page.evaluate(() => navigator.serviceWorker.ready)\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to uncached page\n  await page.goto('/uncached-page')\n\n  // Should show offline fallback\n  await expect(page.getByText('You are offline')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Testing Offline Form Submission\n\n```typescript\ntest('queues form submission offline', async ({page, context}) => {\n  await page.goto('/pwa-app/form')\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Submit form\n  await page.getByLabel('Message').fill('Offline message')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Should show queued status\n  await expect(page.getByText('Queued for sync')).toBeVisible()\n\n  // Go online\n  await context.setOffline(false)\n\n  // Trigger sync (or wait for automatic)\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    // Manually trigger sync for testing\n    await (reg as any).sync?.register('form-sync')\n  })\n\n  // Verify submission completed\n  await expect(page.getByText('Message sent')).toBeVisible({timeout: 10000})\n})\n```\n\n## Push Notifications\n\n### Mocking Push Subscription\n\n```typescript\ntest('handles push subscription', async ({page, context}) => {\n  // Grant notification permission\n  await context.grantPermissions(['notifications'])\n\n  await page.goto('/pwa-app')\n\n  // Subscribe to push\n  const subscription = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    const sub = await reg.pushManager.subscribe({\n      userVisibleOnly: true,\n      applicationServerKey: 'test-key',\n    })\n    return sub.toJSON()\n  })\n\n  expect(subscription.endpoint).toBeDefined()\n})\n```\n\n### Testing Push Message Handling\n\n```typescript\ntest('handles push notification', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Wait for SW\n  const swPromise = context.waitForEvent('serviceworker')\n  const sw = await swPromise\n\n  // Simulate push message to service worker\n  await sw.evaluate(async () => {\n    // Dispatch push event\n    const pushEvent = new PushEvent('push', {\n      data: new PushMessageData(JSON.stringify({title: 'Test', body: 'Push message'})),\n    })\n    self.dispatchEvent(pushEvent)\n  })\n\n  // Note: Actual notification display testing is limited in Playwright\n  // Focus on verifying the SW handles the push correctly\n})\n```\n\n### Testing Notification Click\n\n```typescript\ntest('notification click opens page', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Store notification URL target\n  let notificationUrl = ''\n\n  // Listen for new pages (notification click opens new page)\n  context.on('page', (newPage) => {\n    notificationUrl = newPage.url()\n  })\n\n  // Trigger notification via SW\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.showNotification('Test', {\n      body: 'Click me',\n      data: {url: '/notification-target'},\n    })\n  })\n\n  // Simulate clicking notification (via SW)\n  const sw = context.serviceWorkers()[0]\n  await sw.evaluate(() => {\n    self.dispatchEvent(\n      new NotificationEvent('notificationclick', {\n        notification: {data: {url: '/notification-target'}} as any,\n      }),\n    )\n  })\n\n  // Verify navigation occurred\n  await page.waitForTimeout(1000)\n  // Check if new page opened or current page navigated\n})\n```\n\n## Background Sync\n\n### Testing Background Sync Registration\n\n```typescript\ntest('registers background sync', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Register sync\n  const syncRegistered = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    if (!('sync' in reg)) return false\n\n    await (reg as any).sync.register('my-sync')\n    return true\n  })\n\n  expect(syncRegistered).toBe(true)\n})\n```\n\n### Testing Sync Event\n\n```typescript\ntest('sync event fires when online', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Queue data while offline\n  await context.setOffline(true)\n\n  await page.evaluate(async () => {\n    // Store data in IndexedDB for sync\n    const db = await openDB()\n    await db.put('sync-queue', {id: 1, data: 'test'})\n\n    // Register sync\n    const reg = await navigator.serviceWorker.ready\n    await (reg as any).sync.register('data-sync')\n  })\n\n  // Track sync completion\n  await page.evaluate(() => {\n    window.syncCompleted = false\n    navigator.serviceWorker.addEventListener('message', (e) => {\n      if (e.data.type === 'SYNC_COMPLETE') {\n        window.syncCompleted = true\n      }\n    })\n  })\n\n  // Go online\n  await context.setOffline(false)\n\n  // Wait for sync to complete\n  await page.waitForFunction(() => window.syncCompleted, {timeout: 10000})\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                 | Solution                                     |\n| ------------------------------ | ----------------------- | -------------------------------------------- |\n| Not clearing SW between tests  | Tests affect each other | Unregister SW in beforeEach                  |\n| Not waiting for SW ready       | Race conditions         | Always await `navigator.serviceWorker.ready` |\n| Testing in isolation only      | Misses real SW behavior | Test with actual caching                     |\n| Hardcoded timeouts for caching | Flaky tests             | Wait for cache to populate                   |\n| Ignoring SW update cycle       | Missing update bugs     | Test install, activate, update flows         |\n\n## Related References\n\n- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns\n- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception\n- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns\n",".agents/skills/playwright-best-practices/browser-apis/websockets.md":"# WebSocket & Real-Time Testing\n\n## Table of Contents\n\n1. [WebSocket Basics](#websocket-basics)\n2. [Mocking WebSocket Messages](#mocking-websocket-messages)\n3. [Testing Real-Time Features](#testing-real-time-features)\n4. [Server-Sent Events](#server-sent-events)\n5. [Reconnection Testing](#reconnection-testing)\n\n## WebSocket Basics\n\n### Wait for WebSocket Connection\n\n```typescript\ntest('chat connects via websocket', async ({page}) => {\n  // Listen for WebSocket connection\n  const wsPromise = page.waitForEvent('websocket')\n\n  await page.goto('/chat')\n\n  const ws = await wsPromise\n  expect(ws.url()).toContain('/ws/chat')\n\n  // Wait for connection to be established\n  await ws.waitForEvent('framesent')\n})\n```\n\n### Monitor WebSocket Messages\n\n```typescript\ntest('receives real-time updates', async ({page}) => {\n  const messages: string[] = []\n\n  // Set up listener before navigation\n  page.on('websocket', (ws) => {\n    ws.on('framereceived', (frame) => {\n      messages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Wait for some messages\n  await expect.poll(() => messages.length).toBeGreaterThan(0)\n\n  // Verify message format\n  const data = JSON.parse(messages[0])\n  expect(data).toHaveProperty('type')\n})\n```\n\n### Capture Sent Messages\n\n```typescript\ntest('sends correct message format', async ({page}) => {\n  const sentMessages: string[] = []\n\n  page.on('websocket', (ws) => {\n    ws.on('framesent', (frame) => {\n      sentMessages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/chat')\n  await page.getByLabel('Message').fill('Hello!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  await expect.poll(() => sentMessages.length).toBeGreaterThan(0)\n\n  const sent = JSON.parse(sentMessages[sentMessages.length - 1])\n  expect(sent).toEqual({\n    type: 'message',\n    content: 'Hello!',\n  })\n})\n```\n\n## Mocking WebSocket Messages\n\n### Inject Messages via Page Evaluate\n\n```typescript\ntest('displays incoming chat message', async ({page}) => {\n  await page.goto('/chat')\n\n  // Wait for WebSocket to be ready\n  await page.waitForFunction(() => (window as any).chatSocket?.readyState === 1)\n\n  // Simulate incoming message\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'message',\n        from: 'Alice',\n        content: 'Hello there!',\n      }),\n    })\n    ;(window as any).chatSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByText('Alice: Hello there!')).toBeVisible()\n})\n```\n\n### Mock WebSocket with Route Handler\n\n```typescript\ntest('mock websocket entirely', async ({page, context}) => {\n  // Intercept the WebSocket upgrade\n  await context.route('**/ws/**', async (route) => {\n    // For WebSocket routes, we can't fulfill directly\n    // Instead, use page.evaluate to mock the client-side\n  })\n\n  // Alternative: Mock at application level\n  await page.addInitScript(() => {\n    const OriginalWebSocket = window.WebSocket\n    ;(window as any).WebSocket = function (url: string) {\n      const ws = {\n        readyState: 1,\n        send: (data: string) => {\n          console.log('WS Send:', data)\n        },\n        close: () => {},\n        addEventListener: () => {},\n        removeEventListener: () => {},\n      }\n      setTimeout(() => ws.onopen?.(), 100)\n      return ws\n    }\n  })\n\n  await page.goto('/chat')\n})\n```\n\n### WebSocket Mock Fixture\n\n```typescript\n// fixtures/websocket.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype WsMessage = {type: string; [key: string]: any}\n\ntype WebSocketFixtures = {\n  mockWebSocket: {\n    injectMessage: (message: WsMessage) => Promise<void>\n    getSentMessages: () => Promise<WsMessage[]>\n  }\n}\n\nexport const test = base.extend<WebSocketFixtures>({\n  mockWebSocket: async ({page}, use) => {\n    const sentMessages: WsMessage[] = []\n\n    // Capture sent messages\n    await page.addInitScript(() => {\n      ;(window as any).__wsSent = []\n      const OriginalWebSocket = window.WebSocket\n      window.WebSocket = function (url: string) {\n        const ws = new OriginalWebSocket(url)\n        const originalSend = ws.send.bind(ws)\n        ws.send = (data: string) => {\n          ;(window as any).__wsSent.push(JSON.parse(data))\n          originalSend(data)\n        }\n        ;(window as any).__ws = ws\n        return ws\n      } as any\n    })\n\n    await use({\n      injectMessage: async (message) => {\n        await page.evaluate((msg) => {\n          const event = new MessageEvent('message', {\n            data: JSON.stringify(msg),\n          })\n          ;(window as any).__ws?.dispatchEvent(event)\n        }, message)\n      },\n      getSentMessages: async () => {\n        return page.evaluate(() => (window as any).__wsSent || [])\n      },\n    })\n  },\n})\n\n// Usage\ntest('chat with mocked websocket', async ({page, mockWebSocket}) => {\n  await page.goto('/chat')\n\n  // Inject incoming message\n  await mockWebSocket.injectMessage({\n    type: 'message',\n    from: 'Bob',\n    content: 'Hi!',\n  })\n\n  await expect(page.getByText('Bob: Hi!')).toBeVisible()\n\n  // Send a reply\n  await page.getByLabel('Message').fill('Hello Bob!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  const sent = await mockWebSocket.getSentMessages()\n  expect(sent).toContainEqual(expect.objectContaining({content: 'Hello Bob!'}))\n})\n```\n\n## Testing Real-Time Features\n\n### Live Notifications\n\n```typescript\ntest('displays live notification', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Simulate notification via WebSocket\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'notification',\n        title: 'New Order',\n        message: 'Order #123 received',\n      }),\n    })\n    ;(window as any).notificationSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByRole('alert')).toContainText('Order #123 received')\n})\n```\n\n### Live Data Updates\n\n```typescript\ntest('updates stock price in real-time', async ({page}) => {\n  await page.goto('/stocks/AAPL')\n\n  const priceElement = page.getByTestId('stock-price')\n  const initialPrice = await priceElement.textContent()\n\n  // Simulate price update\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'price_update',\n        symbol: 'AAPL',\n        price: 150.25,\n      }),\n    })\n    ;(window as any).stockSocket.dispatchEvent(event)\n  })\n\n  await expect(priceElement).not.toHaveText(initialPrice!)\n  await expect(priceElement).toContainText('150.25')\n})\n```\n\n### Collaborative Editing\n\n```typescript\ntest('shows collaborator cursor', async ({page}) => {\n  await page.goto('/document/123')\n\n  // Simulate another user's cursor position\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'cursor',\n        userId: 'user-456',\n        userName: 'Alice',\n        position: {x: 100, y: 200},\n      }),\n    })\n    ;(window as any).docSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByTestId('cursor-user-456')).toBeVisible()\n  await expect(page.getByText('Alice')).toBeVisible()\n})\n```\n\n## Server-Sent Events\n\n### Test SSE Updates\n\n```typescript\ntest('receives SSE updates', async ({page}) => {\n  // Mock SSE endpoint\n  await page.route('**/api/events', (route) => {\n    route.fulfill({\n      status: 200,\n      headers: {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n        'Connection': 'keep-alive',\n      },\n      body: `data: {\"type\":\"update\",\"value\":42}\\n\\n`,\n    })\n  })\n\n  await page.goto('/live-data')\n\n  await expect(page.getByTestId('value')).toHaveText('42')\n})\n```\n\n### Simulate Multiple SSE Events\n\n```typescript\ntest('handles multiple SSE events', async ({page}) => {\n  await page.route('**/api/events', async (route) => {\n    const encoder = new TextEncoder()\n    const events = [`data: {\"count\":1}\\n\\n`, `data: {\"count\":2}\\n\\n`, `data: {\"count\":3}\\n\\n`]\n\n    route.fulfill({\n      status: 200,\n      headers: {'Content-Type': 'text/event-stream'},\n      body: events.join(''),\n    })\n  })\n\n  await page.goto('/counter')\n\n  // Should receive all events\n  await expect(page.getByTestId('count')).toHaveText('3')\n})\n```\n\n## Reconnection Testing\n\n### Test Connection Loss\n\n```typescript\ntest('handles connection loss gracefully', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate connection close\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  // Should show disconnected state\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n})\n```\n\n### Test Reconnection\n\n```typescript\ntest('reconnects after connection loss', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate disconnect\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n\n  // Simulate reconnection\n  await page.evaluate(() => {\n    const event = new Event('open')\n    ;(window as any).chatSocket = {readyState: 1}\n    ;(window as any).chatSocket.dispatchEvent?.(event)\n  })\n\n  // Force component to re-check connection\n  await page.evaluate(() => {\n    window.dispatchEvent(new Event('online'))\n  })\n\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                       | Solution                           |\n| ------------------------------------- | ----------------------------- | ---------------------------------- |\n| Not waiting for WebSocket ready       | Messages sent too early       | Wait for `readyState === 1`        |\n| Testing against real WebSocket server | Flaky, timing-dependent       | Mock WebSocket messages            |\n| Ignoring connection state             | Tests pass but feature broken | Test connected/disconnected states |\n| No cleanup of listeners               | Memory leaks in tests         | Clean up event listeners           |\n\n## Related References\n\n- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns\n- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users\n",".agents/skills/playwright-best-practices/core/annotations.md":"# Test Annotations & Organization\n\n## Table of Contents\n\n1. [Skip Annotations](#skip-annotations)\n2. [Fixme & Fail Annotations](#fixme--fail-annotations)\n3. [Slow Tests](#slow-tests)\n4. [Test Steps](#test-steps)\n5. [Custom Annotations](#custom-annotations)\n6. [Conditional Annotations](#conditional-annotations)\n\n## Skip Annotations\n\n### Basic Skip\n\n```typescript\n// Skip unconditionally\ntest.skip('feature not implemented', async ({page}) => {\n  // This test won't run\n})\n\n// Skip with reason\ntest('payment flow', async ({page}) => {\n  test.skip(true, 'Payment gateway in maintenance')\n  // Test body won't execute\n})\n```\n\n### Conditional Skip\n\n```typescript\ntest('webkit-specific feature', async ({page, browserName}) => {\n  test.skip(browserName !== 'webkit', 'This feature only works in WebKit')\n\n  await page.goto('/webkit-feature')\n})\n\ntest('production only', async ({page}) => {\n  test.skip(process.env.ENV !== 'production', 'Only runs against production')\n\n  await page.goto('/prod-feature')\n})\n```\n\n### Skip by Platform\n\n```typescript\ntest('windows-specific', async ({page}) => {\n  test.skip(process.platform !== 'win32', 'Windows only')\n})\n\ntest('not on CI', async ({page}) => {\n  test.skip(!!process.env.CI, 'Skipped in CI environment')\n})\n```\n\n### Skip Describe Block\n\n```typescript\ntest.describe('Admin features', () => {\n  test.skip(({browserName}) => browserName === 'firefox', 'Firefox admin bug')\n\n  test('admin dashboard', async ({page}) => {\n    // Skipped in Firefox\n  })\n\n  test('admin settings', async ({page}) => {\n    // Skipped in Firefox\n  })\n})\n```\n\n## Fixme & Fail Annotations\n\n### Fixme - Known Issues\n\n```typescript\n// Mark test as needing fix (skips the test)\ntest.fixme('broken after refactor', async ({page}) => {\n  // Test won't run but is tracked\n})\n\n// Conditional fixme\ntest('flaky on CI', async ({page}) => {\n  test.fixme(!!process.env.CI, 'Investigate CI flakiness - ticket #123')\n\n  await page.goto('/flaky-feature')\n})\n```\n\n### Fail - Expected Failures\n\n```typescript\n// Test is expected to fail (runs but expects failure)\ntest('known bug', async ({page}) => {\n  test.fail()\n\n  await page.goto('/buggy-page')\n  // If this passes, the test fails (bug was fixed!)\n  await expect(page.getByText('Working')).toBeVisible()\n})\n\n// Conditional fail\ntest('fails on webkit', async ({page, browserName}) => {\n  test.fail(browserName === 'webkit', 'WebKit rendering bug #456')\n\n  await page.goto('/render-test')\n  await expect(page.getByTestId('element')).toHaveCSS('width', '100px')\n})\n```\n\n### Difference Between Skip, Fixme, Fail\n\n| Annotation     | Runs? | Use Case                         |\n| -------------- | ----- | -------------------------------- |\n| `test.skip()`  | No    | Feature not applicable           |\n| `test.fixme()` | No    | Known bug, needs investigation   |\n| `test.fail()`  | Yes   | Expected to fail, tracking a bug |\n\n## Slow Tests\n\n### Mark Slow Tests\n\n```typescript\n// Triple the default timeout\ntest('large data import', async ({page}) => {\n  test.slow()\n\n  await page.goto('/import')\n  await page.setInputFiles('#file', 'large-file.csv')\n  await page.getByRole('button', {name: 'Import'}).click()\n\n  await expect(page.getByText('Import complete')).toBeVisible()\n})\n\n// Conditional slow\ntest('video processing', async ({page, browserName}) => {\n  test.slow(browserName === 'webkit', 'WebKit video processing is slow')\n\n  await page.goto('/video-editor')\n})\n```\n\n### Custom Timeout\n\n```typescript\ntest('very long operation', async ({page}) => {\n  // Set specific timeout (in milliseconds)\n  test.setTimeout(120000) // 2 minutes\n\n  await page.goto('/long-operation')\n})\n\n// Timeout for describe block\ntest.describe('Integration tests', () => {\n  test.describe.configure({timeout: 60000})\n\n  test('test 1', async ({page}) => {\n    // Has 60 second timeout\n  })\n})\n```\n\n## Test Steps\n\n### Basic Steps\n\n```typescript\ntest('checkout flow', async ({page}) => {\n  await test.step('Add item to cart', async () => {\n    await page.goto('/products')\n    await page.getByRole('button', {name: 'Add to Cart'}).click()\n  })\n\n  await test.step('Go to checkout', async () => {\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n  })\n\n  await test.step('Fill shipping info', async () => {\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByLabel('City').fill('Test City')\n  })\n\n  await test.step('Complete payment', async () => {\n    await page.getByLabel('Card').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay'}).click()\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Nested Steps\n\n```typescript\ntest('user registration', async ({page}) => {\n  await test.step('Fill registration form', async () => {\n    await page.goto('/register')\n\n    await test.step('Personal info', async () => {\n      await page.getByLabel('Name').fill('John Doe')\n      await page.getByLabel('Email').fill('john@example.com')\n    })\n\n    await test.step('Security', async () => {\n      await page.getByLabel('Password').fill('SecurePass123')\n      await page.getByLabel('Confirm Password').fill('SecurePass123')\n    })\n  })\n\n  await test.step('Submit and verify', async () => {\n    await page.getByRole('button', {name: 'Register'}).click()\n    await expect(page.getByText('Welcome')).toBeVisible()\n  })\n})\n```\n\n### Steps with Return Values\n\n```typescript\ntest('verify order', async ({page}) => {\n  const orderId = await test.step('Create order', async () => {\n    await page.goto('/checkout')\n    await page.getByRole('button', {name: 'Place Order'}).click()\n\n    // Return value from step\n    return await page.getByTestId('order-id').textContent()\n  })\n\n  await test.step('Verify order details', async () => {\n    await page.goto(`/orders/${orderId}`)\n    await expect(page.getByText(`Order #${orderId}`)).toBeVisible()\n  })\n})\n```\n\n### Step in Page Object\n\n```typescript\n// pages/checkout.page.ts\nexport class CheckoutPage {\n  async fillShippingInfo(address: string, city: string) {\n    await test.step('Fill shipping information', async () => {\n      await this.page.getByLabel('Address').fill(address)\n      await this.page.getByLabel('City').fill(city)\n    })\n  }\n\n  async completePayment(cardNumber: string) {\n    await test.step('Complete payment', async () => {\n      await this.page.getByLabel('Card').fill(cardNumber)\n      await this.page.getByRole('button', {name: 'Pay'}).click()\n    })\n  }\n}\n```\n\n## Custom Annotations\n\n### Add Annotations\n\n```typescript\ntest('important feature', async ({page}, testInfo) => {\n  // Add custom annotation\n  testInfo.annotations.push({\n    type: 'priority',\n    description: 'high',\n  })\n\n  testInfo.annotations.push({\n    type: 'ticket',\n    description: 'JIRA-123',\n  })\n\n  await page.goto('/feature')\n})\n```\n\n### Annotation Fixture\n\n```typescript\n// fixtures/annotations.fixture.ts\nimport {test as base, TestInfo} from '@playwright/test'\n\ntype AnnotationFixtures = {\n  annotate: {\n    ticket: (id: string) => void\n    priority: (level: 'low' | 'medium' | 'high') => void\n    owner: (name: string) => void\n  }\n}\n\nexport const test = base.extend<AnnotationFixtures>({\n  annotate: async ({}, use, testInfo) => {\n    await use({\n      ticket: (id) => {\n        testInfo.annotations.push({type: 'ticket', description: id})\n      },\n      priority: (level) => {\n        testInfo.annotations.push({type: 'priority', description: level})\n      },\n      owner: (name) => {\n        testInfo.annotations.push({type: 'owner', description: name})\n      },\n    })\n  },\n})\n\n// Usage\ntest('critical feature', async ({page, annotate}) => {\n  annotate.ticket('JIRA-456')\n  annotate.priority('high')\n  annotate.owner('Alice')\n\n  await page.goto('/critical')\n})\n```\n\n### Read Annotations in Reporter\n\n```typescript\n// reporters/annotation-reporter.ts\nimport {Reporter, TestCase, TestResult} from '@playwright/test/reporter'\n\nclass AnnotationReporter implements Reporter {\n  onTestEnd(test: TestCase, result: TestResult) {\n    const ticket = test.annotations.find((a) => a.type === 'ticket')\n    const priority = test.annotations.find((a) => a.type === 'priority')\n\n    if (ticket) {\n      console.log(`Test linked to: ${ticket.description}`)\n    }\n\n    if (priority?.description === 'high' && result.status === 'failed') {\n      console.log(`HIGH PRIORITY FAILURE: ${test.title}`)\n    }\n  }\n}\n\nexport default AnnotationReporter\n```\n\n## Conditional Annotations\n\n### Annotation Helper\n\n```typescript\n// helpers/test-annotations.ts\nimport {test} from '@playwright/test'\n\nexport function skipInCI(reason = 'Skipped in CI') {\n  test.skip(!!process.env.CI, reason)\n}\n\nexport function skipInBrowser(browser: string, reason: string) {\n  test.beforeEach(({browserName}) => {\n    test.skip(browserName === browser, reason)\n  })\n}\n\nexport function onlyInEnv(env: string) {\n  test.skip(process.env.ENV !== env, `Only runs in ${env}`)\n}\n```\n\n```typescript\n// tests/feature.spec.ts\nimport {skipInCI, onlyInEnv} from '../helpers/test-annotations'\n\ntest('local only feature', async ({page}) => {\n  skipInCI('Uses local resources')\n\n  await page.goto('/local-feature')\n})\n\ntest('production check', async ({page}) => {\n  onlyInEnv('production')\n\n  await page.goto('/prod-only')\n})\n```\n\n### Describe-Level Conditions\n\n```typescript\ntest.describe('Mobile features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(!isMobile, 'Mobile only tests')\n  })\n\n  test('touch gestures', async ({page}) => {\n    // Only runs on mobile\n  })\n})\n\ntest.describe('Desktop features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(isMobile, 'Desktop only tests')\n  })\n\n  test('hover interactions', async ({page}) => {\n    // Only runs on desktop\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                | Solution                         |\n| --------------------------- | ---------------------- | -------------------------------- |\n| Skipping without reason     | Hard to track why      | Always provide description       |\n| Too many skipped tests      | Test debt accumulates  | Review and clean up regularly    |\n| Using skip instead of fixme | Loses intent           | Use fixme for bugs, skip for N/A |\n| Not using steps             | Hard to debug failures | Group logical actions in steps   |\n\n## Related References\n\n- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n",".agents/skills/playwright-best-practices/core/assertions-waiting.md":"# Assertions & Waiting\n\n## Table of Contents\n\n1. [Web-First Assertions](#web-first-assertions)\n2. [Generic Assertions](#generic-assertions)\n3. [Soft Assertions](#soft-assertions)\n4. [Waiting Strategies](#waiting-strategies)\n5. [Polling & Retrying](#polling--retrying)\n6. [Custom Matchers](#custom-matchers)\n\n## Web-First Assertions\n\nAuto-retry until condition is met or timeout. Always prefer these over generic assertions.\n\n### Locator Assertions\n\n```typescript\nimport {expect} from '@playwright/test'\n\n// Visibility\nawait expect(page.getByRole('button')).toBeVisible()\nawait expect(page.getByRole('button')).toBeHidden()\nawait expect(page.getByRole('button')).not.toBeVisible()\n\n// Enabled/Disabled\nawait expect(page.getByRole('button')).toBeEnabled()\nawait expect(page.getByRole('button')).toBeDisabled()\n\n// Text content\nawait expect(page.getByRole('heading')).toHaveText('Welcome')\nawait expect(page.getByRole('heading')).toHaveText(/welcome/i)\nawait expect(page.getByRole('heading')).toContainText('Welcome')\n\n// Count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Attributes\nawait expect(page.getByRole('link')).toHaveAttribute('href', '/home')\nawait expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)\n\n// CSS\nawait expect(page.getByRole('button')).toHaveClass(/primary/)\nawait expect(page.getByRole('button')).toHaveCSS('color', 'rgb(0, 0, 255)')\n\n// Input values\nawait expect(page.getByLabel('Email')).toHaveValue('user@example.com')\nawait expect(page.getByLabel('Email')).toBeEmpty()\n\n// Focus\nawait expect(page.getByLabel('Email')).toBeFocused()\n\n// Checked state\nawait expect(page.getByRole('checkbox')).toBeChecked()\nawait expect(page.getByRole('checkbox')).not.toBeChecked()\n\n// Editable state\nawait expect(page.getByLabel('Name')).toBeEditable()\n```\n\n### Page Assertions\n\n```typescript\n// URL\nawait expect(page).toHaveURL('/dashboard')\nawait expect(page).toHaveURL(/\\/dashboard/)\n\n// Title\nawait expect(page).toHaveTitle('Dashboard - MyApp')\nawait expect(page).toHaveTitle(/dashboard/i)\n```\n\n### Response Assertions\n\n```typescript\nconst response = await page.request.get('/api/users')\nawait expect(response).toBeOK()\nawait expect(response).not.toBeOK()\n```\n\n## Generic Assertions\n\nUse for non-UI values. Do NOT retry - execute immediately.\n\n```typescript\n// Equality\nexpect(value).toBe(5)\nexpect(object).toEqual({name: 'Test'})\nexpect(array).toContain('item')\n\n// Truthiness\nexpect(value).toBeTruthy()\nexpect(value).toBeFalsy()\nexpect(value).toBeNull()\nexpect(value).toBeUndefined()\nexpect(value).toBeDefined()\n\n// Numbers\nexpect(value).toBeGreaterThan(5)\nexpect(value).toBeLessThanOrEqual(10)\nexpect(value).toBeCloseTo(5.5, 1)\n\n// Strings\nexpect(string).toMatch(/pattern/)\nexpect(string).toContain('substring')\n\n// Arrays/Objects\nexpect(array).toHaveLength(3)\nexpect(object).toHaveProperty('key', 'value')\n\n// Exceptions\nexpect(() => fn()).toThrow()\nexpect(() => fn()).toThrow('error message')\nawait expect(asyncFn()).rejects.toThrow()\n```\n\n## Soft Assertions\n\nContinue test execution after failure, report all failures at end.\n\n```typescript\ntest('check multiple elements', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Won't stop on first failure\n  await expect.soft(page.getByRole('heading')).toHaveText('Dashboard')\n  await expect.soft(page.getByRole('button', {name: 'Save'})).toBeEnabled()\n  await expect.soft(page.getByText('Welcome')).toBeVisible()\n\n  // Test continues; all failures reported at end\n})\n```\n\n### Soft Assertions with Early Exit\n\n```typescript\ntest('check form', async ({page}) => {\n  await expect.soft(page.getByRole('form')).toBeVisible()\n\n  // Exit early if form not visible (pointless to check fields)\n  if (expect.soft.hasFailures()) {\n    return\n  }\n\n  await expect.soft(page.getByLabel('Name')).toBeVisible()\n  await expect.soft(page.getByLabel('Email')).toBeVisible()\n})\n```\n\n## Waiting Strategies\n\n### Auto-Waiting (Default)\n\nActions automatically wait for:\n\n- Element to be attached to DOM\n- Element to be visible\n- Element to be stable (no animations)\n- Element to be enabled\n- Element to receive events\n\n```typescript\n// These auto-wait\nawait page.click('button')\nawait page.fill('input', 'text')\nawait page.getByRole('button').click()\n```\n\n### Wait for Navigation\n\n```typescript\n// Wait for URL change\nawait page.waitForURL('/dashboard')\nawait page.waitForURL(/\\/dashboard/)\n\n// Wait for navigation after action\nawait Promise.all([page.waitForURL('**/dashboard'), page.click('a[href=\"/dashboard\"]')])\n\n// Or without Promise.all\nconst urlPromise = page.waitForURL('**/dashboard')\nawait page.click('a')\nawait urlPromise\n```\n\n### Wait for Network\n\n```typescript\n// Wait for specific response\nconst responsePromise = page.waitForResponse('**/api/users')\nawait page.click('button')\nconst response = await responsePromise\nexpect(response.status()).toBe(200)\n\n// Wait for request\nconst requestPromise = page.waitForRequest('**/api/submit')\nawait page.click('button')\nconst request = await requestPromise\n\n// Wait for no network activity\nawait page.waitForLoadState('networkidle')\n```\n\n### Wait for Element State\n\n```typescript\n// Wait for element to appear\nawait page.getByRole('dialog').waitFor({state: 'visible'})\n\n// Wait for element to disappear\nawait page.getByText('Loading...').waitFor({state: 'hidden'})\n\n// Wait for element to be attached\nawait page.getByTestId('result').waitFor({state: 'attached'})\n\n// Wait for element to be detached\nawait page.getByTestId('modal').waitFor({state: 'detached'})\n```\n\n### Wait for Function\n\n```typescript\n// Wait for arbitrary condition\nawait page.waitForFunction(() => {\n  return document.querySelector('.loaded') !== null\n})\n\n// With arguments\nawait page.waitForFunction(\n  (selector) => document.querySelector(selector)?.textContent === 'Ready',\n  '.status',\n)\n```\n\n## Polling & Retrying\n\n### toPass() for Polling\n\nRetry until block passes or times out:\n\n```typescript\nawait expect(async () => {\n  const response = await page.request.get('/api/status')\n  expect(response.status()).toBe(200)\n\n  const data = await response.json()\n  expect(data.ready).toBe(true)\n}).toPass({\n  intervals: [1000, 2000, 5000], // Retry intervals\n  timeout: 30000,\n})\n```\n\n### expect.poll()\n\nPoll a function until assertion passes:\n\n```typescript\n// Poll API until condition met\nawait expect\n  .poll(\n    async () => {\n      const response = await page.request.get('/api/job/123')\n      return (await response.json()).status\n    },\n    {\n      intervals: [1000, 2000, 5000],\n      timeout: 30000,\n    },\n  )\n  .toBe('completed')\n\n// Poll DOM value\nawait expect.poll(() => page.getByTestId('counter').textContent()).toBe('10')\n```\n\n## Custom Matchers\n\n```typescript\n// playwright.config.ts or fixtures\nimport {expect} from '@playwright/test'\n\nexpect.extend({\n  async toHaveDataLoaded(page: Page) {\n    const locator = page.getByTestId('data-container')\n    let pass = false\n    let message = ''\n\n    try {\n      await expect(locator).toBeVisible()\n      await expect(locator).not.toContainText('Loading')\n      pass = true\n    } catch (e) {\n      message = `Expected data to be loaded but found loading state`\n    }\n\n    return {pass, message: () => message}\n  },\n})\n\n// Extend TypeScript types\ndeclare global {\n  namespace PlaywrightTest {\n    interface Matchers<R> {\n      toHaveDataLoaded(): Promise<R>\n    }\n  }\n}\n\n// Usage\nawait expect(page).toHaveDataLoaded()\n```\n\n## Timeouts\n\n### Configure Timeouts\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  timeout: 30000, // Test timeout\n  expect: {\n    timeout: 5000, // Assertion timeout\n  },\n})\n\n// Per-test timeout\ntest('long test', async ({page}) => {\n  test.setTimeout(60000)\n  // ...\n})\n\n// Per-assertion timeout\nawait expect(page.getByRole('button')).toBeVisible({timeout: 10000})\n```\n\n## Best Practices\n\n| Do                             | Don't                          |\n| ------------------------------ | ------------------------------ |\n| Use web-first assertions       | Use generic assertions for DOM |\n| Let auto-waiting work          | Add unnecessary explicit waits |\n| Use `toPass()` for polling     | Write manual retry loops       |\n| Configure appropriate timeouts | Use `waitForTimeout()`         |\n| Check specific conditions      | Wait for arbitrary time        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                                              | Problem                       | Solution                                     |\n| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |\n| `await page.waitForTimeout(5000)`                         | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse`        |\n| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above                 | Use `waitForResponse` or element state waits |\n| Generic assertions on DOM elements                        | No auto-retry, flaky          | Use web-first assertions with `expect()`     |\n\n## Related References\n\n- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions\n- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n",".agents/skills/playwright-best-practices/core/configuration.md":"# Playwright Configuration\n\n## Table of Contents\n\n1. [CLI Quick Reference](#cli-quick-reference)\n2. [Decision Guide](#decision-guide)\n3. [Production-Ready Config](#production-ready-config)\n4. [Patterns](#patterns)\n5. [Anti-Patterns](#anti-patterns)\n6. [Troubleshooting](#troubleshooting)\n7. [Related](#related)\n\n> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.\n\n## CLI Quick Reference\n\n```bash\nnpx playwright init                           # scaffold config + first test\nnpx playwright test --config=custom.config.ts # use alternate config\nnpx playwright test --project=chromium        # run single project\nnpx playwright test --reporter=html           # override reporter\nnpx playwright test --grep @smoke             # run tests tagged @smoke\nnpx playwright test --grep-invert @slow       # exclude @slow tests\nnpx playwright show-report                    # open last HTML report\nDEBUG=pw:api npx playwright test              # verbose logging\n```\n\n## Decision Guide\n\n### Timeout Selection\n\n| Symptom                                   | Setting             | Default       | Recommended       |\n| ----------------------------------------- | ------------------- | ------------- | ----------------- |\n| Test takes too long overall               | `timeout`           | 30s           | 30-60s (max 120s) |\n| Assertion retries too long/short          | `expect.timeout`    | 5s            | 5-10s             |\n| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s           | 10-30s            |\n| `click()`, `fill()` time out              | `actionTimeout`     | 0 (unlimited) | 10-15s            |\n| Dev server slow to start                  | `webServer.timeout` | 60s           | 60-180s           |\n\n### Server Management\n\n| Scenario                     | Approach                                                |\n| ---------------------------- | ------------------------------------------------------- |\n| App in same repo             | `webServer` with `reuseExistingServer: !process.env.CI` |\n| Separate repos               | Manual start or Docker Compose                          |\n| Testing deployed environment | No `webServer`; set `baseURL` via env                   |\n| Multiple services            | Array of `webServer` entries                            |\n\n### Single vs Multi-Project\n\n| Scenario               | Approach                                   |\n| ---------------------- | ------------------------------------------ |\n| Early development      | Single project (chromium only)             |\n| Pre-release validation | Multi-project: chromium + firefox + webkit |\n| Mobile-responsive app  | Add mobile projects alongside desktop      |\n| Auth + non-auth tests  | Setup project with dependencies            |\n| Tight CI budget        | Chromium on PRs; all browsers on main      |\n\n### globalSetup vs Setup Projects vs Fixtures\n\n| Need                    | Use                                |\n| ----------------------- | ---------------------------------- |\n| One-time DB seed        | `globalSetup`                      |\n| Shared browser auth     | Setup project with `dependencies`  |\n| Per-test isolated state | Custom fixture via `test.extend()` |\n| Cleanup after all tests | `globalTeardown`                   |\n\n## Production-Ready Config\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\nimport dotenv from 'dotenv'\n\ndotenv.config({path: path.resolve(__dirname, '.env')})\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n\n  timeout: 30_000,\n  expect: {timeout: 5_000},\n\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:4000',\n    actionTimeout: 10_000,\n    navigationTimeout: 15_000,\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'retain-on-failure',\n    locale: 'en-US',\n    timezoneId: 'America/Los_Angeles',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'webkit', use: {...devices['Desktop Safari']}},\n    {name: 'mobile-chrome', use: {...devices['Pixel 7']}},\n    {name: 'mobile-safari', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: 'npm run start',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    stdout: 'pipe',\n    stderr: 'pipe',\n  },\n})\n```\n\n## Patterns\n\n### Environment-Specific Configuration\n\n**Use when**: Tests run against dev, staging, and production environments.\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\nconst ENV = process.env.TEST_ENV || 'local'\ndotenv.config({path: path.resolve(__dirname, `.env.${ENV}`)})\n\nconst envConfig: Record<string, {baseURL: string; retries: number}> = {\n  local: {baseURL: 'http://localhost:4000', retries: 0},\n  staging: {baseURL: 'https://staging.myapp.com', retries: 2},\n  prod: {baseURL: 'https://myapp.com', retries: 2},\n}\n\nexport default defineConfig({\n  testDir: './e2e',\n  retries: envConfig[ENV].retries,\n  use: {baseURL: envConfig[ENV].baseURL},\n})\n```\n\n```bash\nTEST_ENV=staging npx playwright test\nTEST_ENV=prod npx playwright test --grep @smoke\n```\n\n### Setup Project with Dependencies\n\n**Use when**: Tests need shared authentication state before running.\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /auth\\.setup\\.ts/,\n    },\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```ts\n// e2e/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/session.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n### webServer with Build Step\n\n**Use when**: Tests need a running application server managed by Playwright.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {baseURL: 'http://localhost:4000'},\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run preview' : 'npm run dev',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: 'test',\n      DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',\n    },\n  },\n})\n```\n\n### globalSetup / globalTeardown\n\n**Use when**: One-time non-browser work like seeding a database. Runs once per test run.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  globalSetup: './e2e/setup.ts',\n  globalTeardown: './e2e/teardown.ts',\n})\n```\n\n```ts\n// e2e/setup.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalSetup(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  process.env.TEST_RUN_ID = `run-${Date.now()}`\n}\n```\n\n```ts\n// e2e/teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalTeardown(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db push --force-reset', {stdio: 'inherit'})\n}\n```\n\n### Environment Variables with .env\n\n**Use when**: Managing secrets, URLs, or feature flags without hardcoding.\n\n```bash\n# .env.example (commit this)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=\nAPI_KEY=\n\n# .env.local (gitignored)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=secret123\nAPI_KEY=dev-key-abc\n\n# .env.staging (gitignored)\nBASE_URL=https://staging.myapp.com\nTEST_PASSWORD=staging-pass\nAPI_KEY=staging-key-xyz\n```\n\n```bash\n# .gitignore\n.env\n.env.local\n.env.staging\n.env.production\nplaywright/.auth/\n```\n\nInstall dotenv:\n\n```bash\nnpm install -D dotenv\n```\n\n### Tag-Based Test Filtering\n\n**Use when**: Running subsets of tests in different CI stages (PR vs nightly).\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n\n  // Filter by tags in CI\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n**Project-specific filtering:**\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n```bash\n# Run specific project\nnpx playwright test --project=smoke\nnpx playwright test --project=regression\n```\n\n### Artifact Collection Strategy\n\n| Setting      | Local   | CI                    | Reason                                    |\n| ------------ | ------- | --------------------- | ----------------------------------------- |\n| `trace`      | `'off'` | `'on-first-retry'`    | Traces are large; collect on failure only |\n| `screenshot` | `'off'` | `'only-on-failure'`   | Useful for CI debugging                   |\n| `video`      | `'off'` | `'retain-on-failure'` | Recording slows tests                     |\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n  },\n})\n```\n\n## Anti-Patterns\n\n| Don't                                                            | Problem                                | Do Instead                          |\n| ---------------------------------------------------------------- | -------------------------------------- | ----------------------------------- |\n| `timeout: 300_000` globally                                      | Masks flaky tests; slow CI             | Fix root cause; keep 30s default    |\n| Hardcoded URLs: `page.goto('http://localhost:4000/login')`       | Breaks in other environments           | Use `baseURL` + relative paths      |\n| All browsers on every PR                                         | 3x CI time                             | Chromium on PRs; all on main        |\n| `trace: 'on'` always                                             | Huge artifacts, slow uploads           | `trace: 'on-first-retry'`           |\n| `video: 'on'` always                                             | Massive storage; slow tests            | `video: 'retain-on-failure'`        |\n| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent                | Define once in project config       |\n| `retries: 3` locally                                             | Hides flakiness                        | `retries: 0` local, `retries: 2` CI |\n| No `forbidOnly` in CI                                            | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI`      |\n| `globalSetup` for browser auth                                   | No browser context available           | Use setup project with dependencies |\n| Committing `.env` with credentials                               | Security risk                          | Commit `.env.example` only          |\n\n## Troubleshooting\n\n### baseURL Not Working\n\n**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.\n\n```ts\n// Wrong - ignores baseURL\nawait page.goto('http://localhost:4000/dashboard')\n\n// Correct - uses baseURL\nawait page.goto('/dashboard')\n```\n\n### webServer Starts But Tests Get Connection Refused\n\n**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.\n\n```ts\nwebServer: {\n  command: 'npm run dev',\n  url: 'http://localhost:4000/api/health',  // use real endpoint\n  reuseExistingServer: !process.env.CI,\n  timeout: 120_000,\n},\n```\n\n### Tests Pass Locally But Timeout in CI\n\n**Cause**: CI machines are slower. Increase timeouts and reduce workers:\n\n```ts\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n  },\n})\n```\n\n### \"Target page, context or browser has been closed\"\n\n**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.\n\n**Fix**: Don't increase global timeout. Find slow step using trace:\n\n```bash\nnpx playwright test --trace on\nnpx playwright show-report\n```\n\n## Related\n\n- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`\n- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state\n- [test-suite-structure.md](test-suite-structure.md) - file structure and naming\n- [authentication.md](../advanced/authentication.md) - setup projects for shared auth\n- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns\n",".agents/skills/playwright-best-practices/core/fixtures-hooks.md":"# Fixtures & Hooks\n\n## Table of Contents\n\n1. [Built-in Fixtures](#built-in-fixtures)\n2. [Custom Fixtures](#custom-fixtures)\n3. [Fixture Scopes](#fixture-scopes)\n4. [Hooks](#hooks)\n5. [Authentication Patterns](#authentication-patterns)\n6. [Database Fixtures](#database-fixtures)\n\n## Built-in Fixtures\n\n### Core Fixtures\n\n```typescript\ntest('example', async ({\n  page, // Isolated page instance\n  context, // Browser context (cookies, localStorage)\n  browser, // Browser instance\n  browserName, // 'chromium', 'firefox', or 'webkit'\n  request, // API request context\n}) => {\n  // Each test gets fresh instances\n})\n```\n\n### Request Fixture\n\n```typescript\ntest('API call', async ({request}) => {\n  const response = await request.get('/api/users')\n  await expect(response).toBeOK()\n\n  const users = await response.json()\n  expect(users).toHaveLength(5)\n})\n```\n\n## Custom Fixtures\n\n### Basic Custom Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base} from '@playwright/test'\n\n// Declare fixture types\ntype MyFixtures = {\n  todoPage: TodoPage\n  apiClient: ApiClient\n}\n\nexport const test = base.extend<MyFixtures>({\n  // Fixture with setup and teardown\n  todoPage: async ({page}, use) => {\n    const todoPage = new TodoPage(page)\n    await todoPage.goto()\n\n    await use(todoPage) // Test runs here\n\n    // Teardown (optional)\n    await todoPage.clearTodos()\n  },\n\n  // Simple fixture\n  apiClient: async ({request}, use) => {\n    await use(new ApiClient(request))\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Fixture with Options\n\n```typescript\ntype Options = {\n  defaultUser: {email: string; password: string}\n}\n\ntype Fixtures = {\n  authenticatedPage: Page\n}\n\nexport const test = base.extend<Options & Fixtures>({\n  // Define option with default\n  defaultUser: [{email: 'test@example.com', password: 'pass123'}, {option: true}],\n\n  // Use option in fixture\n  authenticatedPage: async ({page, defaultUser}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(defaultUser.email)\n    await page.getByLabel('Password').fill(defaultUser.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await use(page)\n  },\n})\n\n// Override in config\nexport default defineConfig({\n  use: {\n    defaultUser: {email: 'admin@example.com', password: 'admin123'},\n  },\n})\n```\n\n### Automatic Fixtures\n\n```typescript\nexport const test = base.extend<{}, {setupDb: void}>({\n  // Auto-fixture runs for every test without explicit usage\n  setupDb: [\n    async ({}, use) => {\n      await seedDatabase()\n      await use()\n      await cleanDatabase()\n    },\n    {auto: true},\n  ],\n})\n```\n\n## Fixture Scopes\n\n### Test Scope (Default)\n\nCreated fresh for each test:\n\n```typescript\ntest.extend({\n  page: async ({browser}, use) => {\n    const page = await browser.newPage()\n    await use(page)\n    await page.close()\n  },\n})\n```\n\n### Worker Scope\n\nShared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):\n\n```typescript\ntype WorkerFixtures = {\n  sharedAccount: Account\n}\n\nexport const test = base.extend<{}, WorkerFixtures>({\n  sharedAccount: [\n    async ({browser}, use) => {\n      // Expensive setup - runs once per worker\n      const account = await createTestAccount()\n      await use(account)\n      await deleteTestAccount(account)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n### Isolate test data between parallel workers\n\nWhen tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:\n\n```typescript\nimport {test as baseTest} from '@playwright/test'\n\ntype WorkerFixtures = {\n  dbUserName: string\n}\n\nexport const test = baseTest.extend<{}, WorkerFixtures>({\n  dbUserName: [\n    async ({}, use, testInfo) => {\n      const userName = `user-${testInfo.workerIndex}`\n      await createUserInTestDatabase(userName)\n      await use(userName)\n      await deleteUserFromTestDatabase(userName)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\nThen each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.\n\n## Hooks\n\n### beforeEach / afterEach\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Runs before each test in file\n  await page.goto('/')\n})\n\ntest.afterEach(async ({page}, testInfo) => {\n  // Runs after each test\n  if (testInfo.status !== 'passed') {\n    await page.screenshot({path: `failed-${testInfo.title}.png`})\n  }\n})\n```\n\n### beforeAll / afterAll\n\n```typescript\ntest.beforeAll(async ({browser}) => {\n  // Runs once before all tests in file\n  // Note: Cannot use page fixture here\n})\n\ntest.afterAll(async () => {\n  // Runs once after all tests in file\n})\n```\n\n### Describe-Level Hooks\n\n```typescript\ntest.describe('User Management', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/users')\n  })\n\n  test('can list users', async ({page}) => {\n    // Starts at /users\n  })\n\n  test('can add user', async ({page}) => {\n    // Starts at /users\n  })\n})\n```\n\n## Authentication Patterns\n\n### Global Setup with Storage State\n\n```typescript\n// auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = '.auth/user.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\n// auth.setup.ts\nsetup('admin auth', async ({page}) => {\n  await login(page, 'admin@example.com', 'adminpass')\n  await page.context().storageState({path: '.auth/admin.json'})\n})\n\nsetup('user auth', async ({page}) => {\n  await login(page, 'user@example.com', 'userpass')\n  await page.context().storageState({path: '.auth/user.json'})\n})\n```\n\n```typescript\n// playwright.config.ts\nprojects: [\n  {\n    name: 'admin tests',\n    testMatch: /.*admin.*\\.spec\\.ts/,\n    use: {storageState: '.auth/admin.json'},\n    dependencies: ['setup'],\n  },\n  {\n    name: 'user tests',\n    testMatch: /.*user.*\\.spec\\.ts/,\n    use: {storageState: '.auth/user.json'},\n    dependencies: ['setup'],\n  },\n]\n```\n\n### Auth Fixture\n\n```typescript\n// fixtures/auth.fixture.ts\nexport const test = base.extend<{adminPage: Page; userPage: Page}>({\n  adminPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/admin.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n\n  userPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/user.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n})\n```\n\n## Database Fixtures\n\nThis section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:\n\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n### Transaction Rollback Pattern\n\n```typescript\nimport {test as base} from '@playwright/test'\n\nimport {db} from '../db'\n\nexport const test = base.extend<{dbTransaction: Transaction}>({\n  dbTransaction: async ({}, use) => {\n    const transaction = await db.beginTransaction()\n\n    await use(transaction)\n\n    await transaction.rollback() // Clean slate for next test\n  },\n})\n```\n\n### Seed Data Fixture\n\n```typescript\ntype TestData = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<TestData>({\n  testUser: async ({}, use) => {\n    const user = await db.users.create({\n      email: `test-${Date.now()}@example.com`,\n      name: 'Test User',\n    })\n\n    await use(user)\n\n    await db.users.delete(user.id)\n  },\n\n  testProducts: async ({testUser}, use) => {\n    const products = await db.products.createMany([\n      {name: 'Product A', ownerId: testUser.id},\n      {name: 'Product B', ownerId: testUser.id},\n    ])\n\n    await use(products)\n\n    await db.products.deleteMany(products.map((p) => p.id))\n  },\n})\n```\n\n## Fixture Tips\n\n| Tip                | Explanation                                 |\n| ------------------ | ------------------------------------------- |\n| Fixtures are lazy  | Only created when used                      |\n| Compose fixtures   | Use other fixtures as dependencies          |\n| Keep setup minimal | Do heavy lifting in worker-scoped fixtures  |\n| Clean up resources | Use teardown in fixtures, not afterEach     |\n| Avoid shared state | Each fixture instance should be independent |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                                                    | Solution                                                                                                                                                                              |\n| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Shared mutable state between tests        | Race conditions, order dependencies                        | Use fixtures for isolation                                                                                                                                                            |\n| Global variables in tests                 | Tests depend on execution order                            | Use fixtures or beforeEach for setup                                                                                                                                                  |\n| Not cleaning up test data                 | Tests interfere with each other                            | Use fixtures with teardown or database transactions                                                                                                                                   |\n| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |\n| Backend/DB state shared across workers    | Tests in different workers collide on same data            | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker                                                                                                |\n\n## Related References\n\n- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure\n- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n",".agents/skills/playwright-best-practices/core/global-setup.md":"# Global Setup & Teardown\n\n## Table of Contents\n\n1. [Global Setup](#global-setup)\n2. [Global Teardown](#global-teardown)\n3. [Database Patterns](#database-patterns)\n4. [Environment Provisioning](#environment-provisioning)\n5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)\n6. [Parallel Execution Caveats](#parallel-execution-caveats)\n\n## Global Setup\n\n### Basic Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  console.log('Running global setup...')\n  // Perform one-time setup: start services, run migrations, etc.\n}\n\nexport default globalSetup\n```\n\n### Configure Global Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n})\n```\n\n> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.\n\n### Global Setup with Return Value\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig): Promise<() => Promise<void>> {\n  const server = await startTestServer()\n\n  // Return cleanup function (alternative to globalTeardown)\n  return async () => {\n    await server.stop()\n  }\n}\n\nexport default globalSetup\n```\n\n### Access Config in Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  console.log(`Setting up for ${baseURL}`)\n\n  // Access custom config\n  const workers = config.workers\n  const timeout = config.timeout\n\n  // Access environment\n  const isCI = !!process.env.CI\n}\n\nexport default globalSetup\n```\n\n## Global Teardown\n\n### Basic Global Teardown\n\n```typescript\nimport fs from 'fs'\n\n// global-teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalTeardown(config: FullConfig) {\n  console.log('Running global teardown...')\n\n  // Clean up auth files\n  if (fs.existsSync('.auth')) {\n    fs.rmSync('.auth', {recursive: true})\n  }\n\n  // Clean up test data\n  await cleanupTestDatabase()\n\n  // Stop services\n  await stopTestServices()\n}\n\nexport default globalTeardown\n```\n\n### Conditional Teardown\n\n```typescript\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  // Skip cleanup in CI (containers are discarded anyway)\n  if (process.env.CI) {\n    console.log('Skipping teardown in CI')\n    return\n  }\n\n  // Local cleanup\n  await cleanupLocalTestData()\n}\n\nexport default globalTeardown\n```\n\n## Database Patterns\n\nThis section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n\n### Database Migration in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Running database migrations...')\n\n  // Run migrations\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n\n  // Seed test data\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Database Snapshot Pattern\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\nimport fs from 'fs'\n\nconst SNAPSHOT_PATH = './test-db-snapshot.sql'\n\nasync function globalSetup() {\n  // Check if snapshot exists\n  if (fs.existsSync(SNAPSHOT_PATH)) {\n    console.log('Restoring database from snapshot...')\n    execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n    return\n  }\n\n  // First run: migrate and create snapshot\n  console.log('Creating database snapshot...')\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Test Database per Worker\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  // Create a database for each worker\n  for (let i = 0; i < workerCount; i++) {\n    const dbName = `test_db_worker_${i}`\n    await createDatabase(dbName)\n    await runMigrations(dbName)\n    await seedDatabase(dbName)\n  }\n}\n\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  for (let i = 0; i < workerCount; i++) {\n    await dropDatabase(`test_db_worker_${i}`)\n  }\n}\n```\n\n## Environment Provisioning\n\n### Start Services in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync, spawn} from 'child_process'\n\nlet serverProcess: any\n\nasync function globalSetup() {\n  // Start backend server\n  serverProcess = spawn('npm', ['run', 'start:test'], {\n    stdio: 'pipe',\n    detached: true,\n  })\n\n  // Wait for server to be ready\n  await waitForServer('http://localhost:3000/health', 30000)\n\n  // Store PID for teardown\n  process.env.SERVER_PID = serverProcess.pid.toString()\n}\n\nasync function waitForServer(url: string, timeout: number) {\n  const start = Date.now()\n\n  while (Date.now() - start < timeout) {\n    try {\n      const response = await fetch(url)\n      if (response.ok) return\n    } catch {\n      // Server not ready yet\n    }\n    await new Promise((r) => setTimeout(r, 1000))\n  }\n\n  throw new Error(`Server did not start within ${timeout}ms`)\n}\n\nexport default globalSetup\n```\n\n### Docker Compose Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Starting Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml up -d', {\n    stdio: 'inherit',\n  })\n\n  // Wait for services to be healthy\n  execSync('docker-compose -f docker-compose.test.yml exec -T db pg_isready', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalSetup\n```\n\n```typescript\n// global-teardown.ts\nimport {execSync} from 'child_process'\n\nasync function globalTeardown() {\n  console.log('Stopping Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml down -v', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalTeardown\n```\n\n### Environment Variables Setup\n\n```typescript\nimport path from 'path'\n\n// global-setup.ts\nimport dotenv from 'dotenv'\n\nasync function globalSetup() {\n  // Load test-specific environment\n  const envFile = process.env.CI ? '.env.ci' : '.env.test'\n  dotenv.config({path: path.resolve(process.cwd(), envFile)})\n\n  // Validate required variables\n  const required = ['DATABASE_URL', 'API_KEY', 'TEST_EMAIL']\n  for (const key of required) {\n    if (!process.env[key]) {\n      throw new Error(`Missing required environment variable: ${key}`)\n    }\n  }\n}\n\nexport default globalSetup\n```\n\n## Setup Projects vs Global Setup\n\n### When to Use Each\n\n| Use Global Setup                      | Use Setup Projects                       |\n| ------------------------------------- | ---------------------------------------- |\n| One-time setup (migrations, services) | Per-project setup (auth states)          |\n| No access to Playwright fixtures      | Need page, request fixtures              |\n| Runs once before all projects         | Can run per-project or have dependencies |\n| Shared across all workers             | Can be parallelized                      |\n\n### Setup Project Pattern\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    // Setup project\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n    // Test projects depend on setup\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Combining Both\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Global: Start services, run migrations\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n\n  projects: [\n    // Setup project: Create auth states\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n## Parallel Execution Caveats\n\n### Understanding Global Setup Execution\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  globalSetup runs ONCE                                      │\n│  ↓                                                          │\n│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │\n│  │ Worker 1│  │ Worker 2│  │ Worker 3│  │ Worker 4│        │\n│  │ tests   │  │ tests   │  │ tests   │  │ tests   │        │\n│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │\n│  ↓                                                          │\n│  globalTeardown runs ONCE                                   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Key implications:**\n\n- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)\n- State created in global setup is **shared** across all workers\n- If tests **modify** shared state, they may conflict with parallel workers\n- Global setup **cannot** react to individual test needs\n\n### When to Prefer Worker-Scoped Fixtures\n\nUse **worker-scoped fixtures** instead of globalSetup when:\n\n| Scenario                             | Why Fixtures Are Better                              |\n| ------------------------------------ | ---------------------------------------------------- |\n| Each worker needs isolated resources | Fixtures can create per-worker databases, servers    |\n| Setup needs Playwright APIs          | Fixtures have access to `page`, `request`, `browser` |\n| Setup depends on test configuration  | Fixtures receive test context and options            |\n| Resources need cleanup per worker    | Worker fixtures auto-cleanup when worker exits       |\n\n### Common Parallel Pitfall\n\n```typescript\n// ❌ BAD: Global setup creates ONE user, all workers fight over it\nasync function globalSetup() {\n  await createUser({email: 'test@example.com'}) // Shared!\n}\n\n// ✅ GOOD: Each worker gets its own user via worker-scoped fixture\n// Uses workerInfo.workerIndex to create unique data per worker\n```\n\n> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                          | Solution                                   |\n| ------------------------------ | -------------------------------- | ------------------------------------------ |\n| Heavy setup in globalSetup     | Slow test startup                | Use setup projects for parallelizable work |\n| Not cleaning up in teardown    | Leaks resources, flaky CI        | Always clean up or use containers          |\n| Hardcoded URLs in setup        | Breaks in different environments | Use config.projects[0].use.baseURL         |\n| No timeout on service wait     | Hangs forever if service fails   | Add timeout with clear error               |\n| Shared mutable state           | Race conditions in parallel      | Use worker-scoped fixtures for isolation   |\n| Global setup for per-test data | Tests conflict                   | Use test-scoped fixtures                   |\n\n## Related References\n\n- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration\n",".agents/skills/playwright-best-practices/core/locators.md":"# Locator Strategies\n\n## Table of Contents\n\n1. [Priority Order](#priority-order)\n2. [User-Facing Locators](#user-facing-locators)\n3. [Filtering & Chaining](#filtering--chaining)\n4. [Dynamic Content](#dynamic-content)\n5. [Shadow DOM](#shadow-dom)\n6. [Iframes](#iframes)\n\n## Priority Order\n\nUse locators in this order of preference:\n\n1. **Role-based** (most resilient): `getByRole`\n2. **Label-based**: `getByLabel`, `getByPlaceholder`\n3. **Text-based**: `getByText`, `getByTitle`\n4. **Test IDs** (when semantic locators aren't possible): `getByTestId`\n5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`\n\n## User-Facing Locators\n\n### getByRole\n\nMost robust approach - matches how users and assistive technology perceive the page.\n\n```typescript\n// Buttons\npage.getByRole('button', {name: 'Submit', exact: true}) // exact accessible name\npage.getByRole('button', {name: /submit/i}) // flexible case-insensitive match\n\n// Links\npage.getByRole('link', {name: 'Home'})\n\n// Form elements\npage.getByRole('textbox', {name: 'Email'})\npage.getByRole('checkbox', {name: 'Remember me'})\npage.getByRole('combobox', {name: 'Country'})\npage.getByRole('radio', {name: 'Option A'})\n\n// Headings\npage.getByRole('heading', {name: 'Welcome', level: 1})\n\n// Lists & items\npage.getByRole('list').getByRole('listitem')\n\n// Navigation & regions\npage.getByRole('navigation')\npage.getByRole('main')\npage.getByRole('dialog')\npage.getByRole('alert')\n```\n\n### getByLabel\n\nFor form elements with associated labels.\n\n```typescript\n// Input with <label for=\"email\">\npage.getByLabel('Email address')\n\n// Input with aria-label\npage.getByLabel('Search')\n\n// Exact match\npage.getByLabel('Email', {exact: true})\n```\n\n### getByPlaceholder\n\n```typescript\npage.getByPlaceholder('Enter your email')\npage.getByPlaceholder(/email/i)\n```\n\n### getByText\n\n```typescript\n// Partial match (default)\npage.getByText('Welcome')\n\n// Exact match\npage.getByText('Welcome to our site', {exact: true})\n\n// Regex\npage.getByText(/welcome/i)\n```\n\n### getByTestId\n\nConfigure custom test ID attribute in `playwright.config.ts`:\n\n```typescript\nuse: {\n  testIdAttribute: 'data-testid' // default\n}\n```\n\nUsage:\n\n```typescript\n// HTML: <button data-testid=\"submit-btn\">Submit</button>\npage.getByTestId('submit-btn')\n```\n\n## Filtering & Chaining\n\n### filter()\n\nNarrow down locators:\n\n```typescript\n// Filter by text\npage.getByRole('listitem').filter({hasText: 'Product'})\n\n// Filter by NOT having text\npage.getByRole('listitem').filter({hasNotText: 'Out of stock'})\n\n// Filter by child locator\npage.getByRole('listitem').filter({\n  has: page.getByRole('button', {name: 'Buy'}),\n})\n\n// Combine filters\npage\n  .getByRole('listitem')\n  .filter({hasText: 'Product'})\n  .filter({has: page.getByText('$9.99')})\n```\n\n### Chaining\n\n```typescript\n// Navigate down the DOM tree\npage.getByRole('article').getByRole('heading')\n\n// Get parent/ancestor\npage.getByText('Child').locator('..')\npage.getByText('Child').locator('xpath=ancestor::article')\n```\n\n### nth() and first()/last()\n\n```typescript\npage.getByRole('listitem').first()\npage.getByRole('listitem').last()\npage.getByRole('listitem').nth(2) // 0-indexed\n```\n\n## Dynamic Content\n\n### Waiting for Elements\n\nLocators auto-wait for actionability by default. For explicit state waiting:\n\n```typescript\nawait page.getByRole('button').waitFor({state: 'visible'})\nawait page.getByText('Loading').waitFor({state: 'hidden'})\n```\n\n> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n### Lists with Dynamic Items\n\n```typescript\n// Wait for specific count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Get all matching elements\nconst items = await page.getByRole('listitem').all()\nfor (const item of items) {\n  await expect(item).toBeVisible()\n}\n```\n\n## Shadow DOM\n\nPlaywright pierces shadow DOM by default:\n\n```typescript\n// Automatically finds elements inside shadow roots\npage.getByRole('button', {name: 'Shadow Button'})\n\n// Explicit shadow DOM traversal (if needed)\npage.locator('my-component').locator('internal:shadow=button')\n```\n\n## Iframes\n\n```typescript\n// By frame name or URL\nconst frame = page.frameLocator('iframe[name=\"content\"]')\nawait frame.getByRole('button').click()\n\n// By index\nconst frame = page.frameLocator('iframe').first()\n\n// Nested iframes\nconst nestedFrame = page.frameLocator('#outer').frameLocator('#inner')\nawait nestedFrame.getByText('Content').click()\n```\n\n## Debugging Locators\n\n```typescript\n// Highlight element in headed mode\nawait page.getByRole('button').highlight()\n\n// Count matches\nconst count = await page.getByRole('listitem').count()\n\n// Check if exists without waiting\nconst exists = (await page.getByRole('button').count()) > 0\n\n// Use Playwright Inspector\n// PWDEBUG=1 npx playwright test\n```\n\n## Common Issues & Solutions\n\n| Issue                   | Solution                                         |\n| ----------------------- | ------------------------------------------------ |\n| Multiple elements match | Add filters or use `nth()`, `first()`, `last()`  |\n| Element not found       | Check visibility, wait for load, verify selector |\n| Stale element           | Locators are lazy; re-query if DOM changes       |\n| Dynamic IDs             | Use stable attributes like role, text, test-id   |\n| Hidden elements         | Use `{ force: true }` only when necessary        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                      | Problem                           | Solution                                          |\n| --------------------------------- | --------------------------------- | ------------------------------------------------- |\n| `page.locator('.btn-primary')`    | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })`    |\n| `page.locator('#dynamic-id-123')` | Breaks when IDs change            | Use stable attributes like role, text, or test-id |\n| Testing implementation details    | Breaks on refactoring             | Test user-visible behavior                        |\n\n## Related References\n\n- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies\n- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators\n",".agents/skills/playwright-best-practices/core/page-object-model.md":"# Page Object Model (POM)\n\n## Table of Contents\n\n1. [Overview](#overview)\n2. [Basic Structure](#basic-structure)\n3. [Component Objects](#component-objects)\n4. [Composition Patterns](#composition-patterns)\n5. [Factory Functions](#factory-functions)\n6. [Best Practices](#best-practices)\n\n## Overview\n\nPage Object Model encapsulates page structure and interactions, providing:\n\n- **Maintainability**: Change selectors in one place\n- **Reusability**: Share page interactions across tests\n- **Readability**: Tests express intent, not implementation\n\n## Basic Structure\n\n### Page Class\n\n```typescript\n// pages/login.page.ts\nimport {Page, Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly emailInput: Locator\n  readonly passwordInput: Locator\n  readonly submitButton: Locator\n  readonly errorMessage: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.emailInput = page.getByLabel('Email')\n    this.passwordInput = page.getByLabel('Password')\n    this.submitButton = page.getByRole('button', {name: 'Sign in'})\n    this.errorMessage = page.getByRole('alert')\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n  }\n\n  async login(email: string, password: string) {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n  }\n\n  async expectError(message: string) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n}\n```\n\n### Usage in Tests\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../pages/login.page'\n\ntest.describe('Login', () => {\n  test('successful login redirects to dashboard', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('user@example.com', 'password123')\n    await expect(page).toHaveURL('/dashboard')\n  })\n\n  test('shows error for invalid credentials', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('invalid@example.com', 'wrong')\n    await loginPage.expectError('Invalid credentials')\n  })\n})\n```\n\n## Component Objects\n\nFor reusable UI components:\n\n```typescript\n// components/navbar.component.ts\nimport {Page, Locator} from '@playwright/test'\n\nexport class NavbarComponent {\n  readonly container: Locator\n  readonly logo: Locator\n  readonly searchInput: Locator\n  readonly userMenu: Locator\n\n  constructor(page: Page) {\n    this.container = page.getByRole('navigation')\n    this.logo = this.container.getByRole('link', {name: 'Home'})\n    this.searchInput = this.container.getByRole('searchbox')\n    this.userMenu = this.container.getByRole('button', {name: /user menu/i})\n  }\n\n  async search(query: string) {\n    await this.searchInput.fill(query)\n    await this.searchInput.press('Enter')\n  }\n\n  async openUserMenu() {\n    await this.userMenu.click()\n  }\n}\n```\n\n```typescript\n// components/modal.component.ts\nimport {Locator, expect} from '@playwright/test'\n\nexport class ModalComponent {\n  readonly container: Locator\n  readonly title: Locator\n  readonly closeButton: Locator\n  readonly confirmButton: Locator\n\n  constructor(container: Locator) {\n    this.container = container\n    this.title = container.getByRole('heading')\n    this.closeButton = container.getByRole('button', {name: 'Close'})\n    this.confirmButton = container.getByRole('button', {name: 'Confirm'})\n  }\n\n  async expectTitle(title: string) {\n    await expect(this.title).toHaveText(title)\n  }\n\n  async close() {\n    await this.closeButton.click()\n  }\n\n  async confirm() {\n    await this.confirmButton.click()\n  }\n}\n```\n\n## Composition Patterns\n\n### Page with Components\n\n```typescript\n// pages/dashboard.page.ts\nimport {Page, Locator} from '@playwright/test'\n\nimport {ModalComponent} from '../components/modal.component'\nimport {NavbarComponent} from '../components/navbar.component'\n\nexport class DashboardPage {\n  readonly page: Page\n  readonly navbar: NavbarComponent\n  readonly newProjectButton: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.navbar = new NavbarComponent(page)\n    this.newProjectButton = page.getByRole('button', {name: 'New Project'})\n  }\n\n  async goto() {\n    await this.page.goto('/dashboard')\n  }\n\n  async createProject() {\n    await this.newProjectButton.click()\n    return new ModalComponent(this.page.getByRole('dialog'))\n  }\n}\n```\n\n### Page Navigation\n\n```typescript\n// pages/base.page.ts\nimport {Page} from '@playwright/test'\n\nexport abstract class BasePage {\n  constructor(readonly page: Page) {}\n\n  abstract goto(): Promise<void>\n\n  async getTitle(): Promise<string> {\n    return this.page.title()\n  }\n}\n```\n\n```typescript\n// Return new page object on navigation\nexport class LoginPage extends BasePage {\n  async login(email: string, password: string): Promise<DashboardPage> {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n    return new DashboardPage(this.page)\n  }\n}\n\n// Usage\nconst loginPage = new LoginPage(page)\nawait loginPage.goto()\nconst dashboardPage = await loginPage.login('user@example.com', 'pass')\nawait dashboardPage.expectWelcomeMessage()\n```\n\n## Factory Functions\n\nAlternative to classes for simpler pages:\n\n```typescript\n// pages/login.page.ts\nimport {Page} from '@playwright/test'\n\nexport function createLoginPage(page: Page) {\n  const emailInput = page.getByLabel('Email')\n  const passwordInput = page.getByLabel('Password')\n  const submitButton = page.getByRole('button', {name: 'Sign in'})\n\n  return {\n    goto: () => page.goto('/login'),\n    login: async (email: string, password: string) => {\n      await emailInput.fill(email)\n      await passwordInput.fill(password)\n      await submitButton.click()\n    },\n    emailInput,\n    passwordInput,\n    submitButton,\n  }\n}\n\n// Usage\nconst loginPage = createLoginPage(page)\nawait loginPage.goto()\nawait loginPage.login('user@example.com', 'password')\n```\n\n## Best Practices\n\n### Do\n\n- **Keep locators in page objects** - Single source of truth\n- **Return new page objects** when navigation occurs\n- **Expose elements** for custom assertions in tests\n- **Use descriptive method names** - `submitOrder()` not `clickButton()`\n- **Keep methods focused** - One action per method\n\n### Don't\n\n- **Don't include assertions in page methods** (usually) - Keep in tests\n- **Don't expose implementation details** - Hide complex interactions\n- **Don't make page objects too large** - Split into components\n- **Don't share state** between page object instances\n\n### Directory Structure\n\n```\ntests/\n├── pages/\n│   ├── base.page.ts\n│   ├── login.page.ts\n│   ├── dashboard.page.ts\n│   └── settings.page.ts\n├── components/\n│   ├── navbar.component.ts\n│   ├── modal.component.ts\n│   └── table.component.ts\n├── fixtures/\n│   └── pages.fixture.ts\n└── specs/\n    ├── login.spec.ts\n    └── dashboard.spec.ts\n```\n\n### Using with Fixtures\n\n```typescript\n// fixtures/pages.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {DashboardPage} from '../pages/dashboard.page'\nimport {LoginPage} from '../pages/login.page'\n\ntype Pages = {\n  loginPage: LoginPage\n  dashboardPage: DashboardPage\n}\n\nexport const test = base.extend<Pages>({\n  loginPage: async ({page}, use) => {\n    await use(new LoginPage(page))\n  },\n  dashboardPage: async ({page}, use) => {\n    await use(new DashboardPage(page))\n  },\n})\n\n// Usage in tests\ntest('can login', async ({loginPage}) => {\n  await loginPage.goto()\n  await loginPage.login('user@example.com', 'password')\n})\n```\n\n## Related References\n\n- **Locator strategies**: See [locators.md](locators.md) for selecting elements\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites\n",".agents/skills/playwright-best-practices/core/projects-dependencies.md":"# Projects & Dependencies\n\n## Table of Contents\n\n1. [Project Configuration](#project-configuration)\n2. [Project Dependencies](#project-dependencies)\n3. [Setup Projects](#setup-projects)\n4. [Filtering & Running Projects](#filtering--running-projects)\n5. [Sharing Configuration](#sharing-configuration)\n6. [Advanced Patterns](#advanced-patterns)\n\n## Project Configuration\n\n### Basic Multi-Browser Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n    },\n    {\n      name: 'webkit',\n      use: {...devices['Desktop Safari']},\n    },\n  ],\n})\n```\n\n### Environment-Based Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'staging',\n      use: {\n        baseURL: 'https://staging.example.com',\n      },\n    },\n    {\n      name: 'production',\n      use: {\n        baseURL: 'https://example.com',\n      },\n    },\n    {\n      name: 'local',\n      use: {\n        baseURL: 'http://localhost:3000',\n      },\n    },\n  ],\n})\n```\n\n### Test Type Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'e2e',\n      testDir: './tests/e2e',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'api',\n      testDir: './tests/api',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'visual',\n      testDir: './tests/visual',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n    },\n  ],\n})\n```\n\n## Project Dependencies\n\n### Setup Dependency\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Setup project runs first\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n\n    // Browser projects depend on setup\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Auth setup projects\n    {\n      name: 'setup-admin',\n      testMatch: /admin\\.setup\\.ts/,\n    },\n    {\n      name: 'setup-user',\n      testMatch: /user\\.setup\\.ts/,\n    },\n\n    // Admin tests\n    {\n      name: 'admin-tests',\n      testDir: './tests/admin',\n      use: {storageState: '.auth/admin.json'},\n      dependencies: ['setup-admin'],\n    },\n\n    // User tests\n    {\n      name: 'user-tests',\n      testDir: './tests/user',\n      use: {storageState: '.auth/user.json'},\n      dependencies: ['setup-user'],\n    },\n\n    // Tests that need both\n    {\n      name: 'integration-tests',\n      testDir: './tests/integration',\n      dependencies: ['setup-admin', 'setup-user'],\n    },\n  ],\n})\n```\n\n### Chained Dependencies\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Step 1: Database setup\n    {\n      name: 'db-setup',\n      testMatch: /db\\.setup\\.ts/,\n    },\n\n    // Step 2: Auth setup (needs DB)\n    {\n      name: 'auth-setup',\n      testMatch: /auth\\.setup\\.ts/,\n      dependencies: ['db-setup'],\n    },\n\n    // Step 3: Seed data (needs auth)\n    {\n      name: 'seed-setup',\n      testMatch: /seed\\.setup\\.ts/,\n      dependencies: ['auth-setup'],\n    },\n\n    // Tests (need everything)\n    {\n      name: 'tests',\n      testDir: './tests',\n      dependencies: ['seed-setup'],\n    },\n  ],\n})\n```\n\n## Setup Projects\n\n### Authentication Setup\n\nSetup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.\n\n> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Data Seeding Setup\n\n```typescript\n// seed.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('seed test data', async ({request}) => {\n  // Create test data via API\n  await request.post('/api/test/seed', {\n    data: {\n      users: 10,\n      products: 50,\n      orders: 100,\n    },\n  })\n})\n```\n\n### Cleanup Setup\n\n```typescript\n// cleanup.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('cleanup previous run', async ({request}) => {\n  // Clean up data from previous test runs\n  await request.delete('/api/test/cleanup')\n})\n```\n\n## Filtering & Running Projects\n\n### Run Specific Project\n\n```bash\n# Run single project\nnpx playwright test --project=chromium\n\n# Run multiple projects\nnpx playwright test --project=chromium --project=firefox\n```\n\n### Run by Grep\n\n```bash\n# Run tests matching pattern\nnpx playwright test --grep @smoke\n\n# Run project with grep\nnpx playwright test --project=chromium --grep @critical\n\n# Exclude pattern\nnpx playwright test --grep-invert @slow\n```\n\n### Project-Specific Grep\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n## Sharing Configuration\n\n### Base Configuration\n\n```typescript\n// playwright.config.ts\nconst baseConfig = {\n  timeout: 30000,\n  expect: {timeout: 5000},\n  use: {\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n}\n\nexport default defineConfig({\n  ...baseConfig,\n  projects: [\n    {\n      name: 'chromium',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n### Shared Project Settings\n\n```typescript\nconst sharedBrowserConfig = {\n  timeout: 60000,\n  retries: 2,\n  use: {\n    video: 'on-first-retry',\n    trace: 'on-first-retry',\n  },\n}\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n## Advanced Patterns\n\n### Conditional Projects\n\n```typescript\nconst projects = [\n  {\n    name: 'chromium',\n    use: {...devices['Desktop Chrome']},\n  },\n]\n\n// Add Firefox only in CI\nif (process.env.CI) {\n  projects.push({\n    name: 'firefox',\n    use: {...devices['Desktop Firefox']},\n  })\n}\n\n// Add mobile only for specific test dirs\nif (process.env.TEST_MOBILE) {\n  projects.push({\n    name: 'mobile',\n    use: {...devices['iPhone 14']},\n  })\n}\n\nexport default defineConfig({projects})\n```\n\n### Project Metadata\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      metadata: {\n        platform: 'desktop',\n        browser: 'chromium',\n        priority: 'high',\n      },\n    },\n  ],\n})\n\n// Access in test\ntest('example', async ({page}, testInfo) => {\n  const {platform, priority} = testInfo.project.metadata\n  console.log(`Running on ${platform} with ${priority} priority`)\n})\n```\n\n### Teardown Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n      teardown: 'teardown', // Run teardown after this completes\n    },\n    {\n      name: 'teardown',\n      testMatch: /.*\\.teardown\\.ts/,\n    },\n    {\n      name: 'tests',\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```typescript\n// cleanup.teardown.ts\nimport {test as teardown} from '@playwright/test'\n\nteardown('cleanup', async ({request}) => {\n  await request.delete('/api/test/data')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                | Solution                            |\n| -------------------------- | ---------------------- | ----------------------------------- |\n| Too many browser projects  | Slow CI, expensive     | Focus on critical browsers          |\n| Missing setup dependencies | Tests fail randomly    | Declare all dependencies explicitly |\n| Duplicated configuration   | Hard to maintain       | Extract shared config               |\n| Not using setup projects   | Repeated auth in tests | Use setup project + storageState    |\n\n## Related References\n\n- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI\n",".agents/skills/playwright-best-practices/core/test-data.md":"# Test Data Factories & Generators\n\nThis file covers **reusable test data builders** (factories, Faker, data generators). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n## Table of Contents\n\n1. [Factory Pattern](#factory-pattern)\n2. [Faker Integration](#faker-integration)\n3. [Data-Driven Testing](#data-driven-testing)\n4. [Test Data Fixtures](#test-data-fixtures)\n5. [Database Seeding](#database-seeding)\n\n## Factory Pattern\n\n### Basic Factory\n\n```typescript\n// factories/user.factory.ts\ninterface User {\n  id: string\n  email: string\n  name: string\n  role: 'admin' | 'user' | 'guest'\n  createdAt: Date\n}\n\nlet userIdCounter = 0\n\nexport function createUser(overrides: Partial<User> = {}): User {\n  userIdCounter++\n  return {\n    id: `user-${userIdCounter}`,\n    email: `user${userIdCounter}@test.com`,\n    name: `Test User ${userIdCounter}`,\n    role: 'user',\n    createdAt: new Date(),\n    ...overrides,\n  }\n}\n\n// Usage\nconst user = createUser()\nconst admin = createUser({role: 'admin', name: 'Admin User'})\n```\n\n### Factory with Traits\n\n```typescript\n// factories/product.factory.ts\ninterface Product {\n  id: string\n  name: string\n  price: number\n  stock: number\n  category: string\n  featured: boolean\n}\n\ntype ProductTrait = 'outOfStock' | 'featured' | 'expensive' | 'sale'\n\nconst traits: Record<ProductTrait, Partial<Product>> = {\n  outOfStock: {stock: 0},\n  featured: {featured: true},\n  expensive: {price: 999.99},\n  sale: {price: 9.99},\n}\n\nlet productIdCounter = 0\n\nexport function createProduct(\n  overrides: Partial<Product> = {},\n  ...traitNames: ProductTrait[]\n): Product {\n  productIdCounter++\n\n  const appliedTraits = traitNames.reduce((acc, trait) => ({...acc, ...traits[trait]}), {})\n\n  return {\n    id: `prod-${productIdCounter}`,\n    name: `Product ${productIdCounter}`,\n    price: 29.99,\n    stock: 100,\n    category: 'General',\n    featured: false,\n    ...appliedTraits,\n    ...overrides,\n  }\n}\n\n// Usage\nconst product = createProduct()\nconst featuredProduct = createProduct({}, 'featured')\nconst saleItem = createProduct({name: 'Sale Item'}, 'sale', 'featured')\nconst soldOut = createProduct({}, 'outOfStock')\n```\n\n### Factory with Relationships\n\n```typescript\nimport {createProduct, Product} from './product.factory'\n// factories/order.factory.ts\nimport {createUser, User} from './user.factory'\n\ninterface OrderItem {\n  product: Product\n  quantity: number\n}\n\ninterface Order {\n  id: string\n  user: User\n  items: OrderItem[]\n  total: number\n  status: 'pending' | 'paid' | 'shipped' | 'delivered'\n}\n\nlet orderIdCounter = 0\n\nexport function createOrder(overrides: Partial<Order> = {}): Order {\n  orderIdCounter++\n\n  const user = overrides.user ?? createUser()\n  const items = overrides.items ?? [{product: createProduct(), quantity: 1}]\n  const total = items.reduce((sum, item) => sum + item.product.price * item.quantity, 0)\n\n  return {\n    id: `order-${orderIdCounter}`,\n    user,\n    items,\n    total,\n    status: 'pending',\n    ...overrides,\n  }\n}\n\n// Usage\nconst order = createOrder()\nconst bigOrder = createOrder({\n  items: [\n    {product: createProduct({price: 100}), quantity: 5},\n    {product: createProduct({price: 50}), quantity: 2},\n  ],\n})\n```\n\n## Faker Integration\n\n### Setup Faker\n\n```bash\nnpm install -D @faker-js/faker\n```\n\n```typescript\n// factories/faker-user.factory.ts\nimport {faker} from '@faker-js/faker'\n\ninterface User {\n  id: string\n  email: string\n  name: string\n  avatar: string\n  address: {\n    street: string\n    city: string\n    country: string\n    zipCode: string\n  }\n}\n\nexport function createFakeUser(overrides: Partial<User> = {}): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    avatar: faker.image.avatar(),\n    address: {\n      street: faker.location.streetAddress(),\n      city: faker.location.city(),\n      country: faker.location.country(),\n      zipCode: faker.location.zipCode(),\n    },\n    ...overrides,\n  }\n}\n```\n\n### Seeded Faker for Reproducibility\n\n```typescript\nimport {faker} from '@faker-js/faker'\n\n// Set seed for reproducible data\nfaker.seed(12345)\n\nexport function createDeterministicUser(): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    // Same seed = same data every time\n  }\n}\n\n// Or seed per test\ntest('user profile', async ({page}) => {\n  faker.seed(42) // Reset seed for this test\n  const user = createFakeUser()\n  // user will always have the same data\n})\n```\n\n### Faker Fixture\n\n```typescript\nimport {faker} from '@faker-js/faker'\n// fixtures/faker.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype FakerFixtures = {\n  fake: typeof faker\n}\n\nexport const test = base.extend<FakerFixtures>({\n  fake: async ({}, use, testInfo) => {\n    // Seed based on test name for reproducibility\n    faker.seed(testInfo.title.length)\n    await use(faker)\n  },\n})\n\n// Usage\ntest('create user with fake data', async ({page, fake}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Name').fill(fake.person.fullName())\n  await page.getByLabel('Email').fill(fake.internet.email())\n  await page.getByLabel('Password').fill(fake.internet.password())\n\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n})\n```\n\n## Data-Driven Testing\n\n### test.each with Arrays\n\n```typescript\nconst loginScenarios = [\n  {email: 'user@example.com', password: 'pass123', expected: 'Dashboard'},\n  {email: 'admin@example.com', password: 'admin123', expected: 'Admin Panel'},\n  {\n    email: 'invalid@example.com',\n    password: 'wrong',\n    expected: 'Invalid credentials',\n  },\n]\n\nfor (const {email, password, expected} of loginScenarios) {\n  test(`login with ${email}`, async ({page}) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Sign In'}).click()\n\n    await expect(page.getByText(expected)).toBeVisible()\n  })\n}\n```\n\n### Parameterized Tests\n\n```typescript\n// data/checkout-scenarios.ts\nexport const checkoutScenarios = [\n  {\n    name: 'standard shipping',\n    shipping: 'standard',\n    expectedDays: '5-7 business days',\n    expectedCost: '$5.99',\n  },\n  {\n    name: 'express shipping',\n    shipping: 'express',\n    expectedDays: '2-3 business days',\n    expectedCost: '$14.99',\n  },\n  {\n    name: 'overnight shipping',\n    shipping: 'overnight',\n    expectedDays: 'Next business day',\n    expectedCost: '$29.99',\n  },\n]\n```\n\n```typescript\nimport {checkoutScenarios} from './data/checkout-scenarios'\n\ntest.describe('shipping options', () => {\n  for (const scenario of checkoutScenarios) {\n    test(`checkout with ${scenario.name}`, async ({page}) => {\n      await page.goto('/checkout')\n\n      await page.getByLabel(scenario.shipping, {exact: false}).check()\n\n      await expect(page.getByText(scenario.expectedDays)).toBeVisible()\n      await expect(page.getByText(scenario.expectedCost)).toBeVisible()\n    })\n  }\n})\n```\n\n### CSV/JSON Data Source\n\n```typescript\nimport fs from 'fs'\n\ninterface TestCase {\n  input: string\n  expected: string\n}\n\n// Load test data from JSON\nconst testCases: TestCase[] = JSON.parse(fs.readFileSync('./data/search-tests.json', 'utf-8'))\n\ntest.describe('search functionality', () => {\n  for (const {input, expected} of testCases) {\n    test(`search for \"${input}\"`, async ({page}) => {\n      await page.goto('/search')\n      await page.getByLabel('Search').fill(input)\n      await page.getByLabel('Search').press('Enter')\n\n      await expect(page.getByText(expected)).toBeVisible()\n    })\n  }\n})\n```\n\n## Test Data Fixtures\n\n### Fixture with Factory\n\n```typescript\n// fixtures/data.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {createProduct, Product} from '../factories/product.factory'\nimport {createUser, User} from '../factories/user.factory'\n\ntype DataFixtures = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<DataFixtures>({\n  testUser: async ({}, use) => {\n    const user = createUser({name: 'E2E Test User'})\n    await use(user)\n  },\n\n  testProducts: async ({}, use) => {\n    const products = [\n      createProduct({name: 'Test Product 1'}),\n      createProduct({name: 'Test Product 2'}),\n      createProduct({name: 'Test Product 3'}),\n    ]\n    await use(products)\n  },\n})\n\n// Usage\ntest('add product to cart', async ({page, testUser, testProducts}) => {\n  // Mock API with test data\n  await page.route('**/api/user', (route) => route.fulfill({json: testUser}))\n  await page.route('**/api/products', (route) => route.fulfill({json: testProducts}))\n\n  await page.goto('/products')\n  await expect(page.getByText(testProducts[0].name)).toBeVisible()\n})\n```\n\n## Database Seeding\n\n### API-Based Seeding\n\n```typescript\n// fixtures/seed.fixture.ts\nimport {test as base, APIRequestContext} from '@playwright/test'\n\nimport {createUser} from '../factories/user.factory'\n\ntype SeedFixtures = {\n  seedUser: (overrides?: Partial<User>) => Promise<User>\n  cleanupUsers: string[]\n}\n\nexport const test = base.extend<SeedFixtures>({\n  cleanupUsers: [],\n\n  seedUser: async ({request, cleanupUsers}, use) => {\n    await use(async (overrides = {}) => {\n      const userData = createUser(overrides)\n\n      const response = await request.post('/api/test/users', {\n        data: userData,\n      })\n      const user = await response.json()\n\n      cleanupUsers.push(user.id)\n      return user\n    })\n  },\n\n  // Cleanup after test\n  cleanupUsers: async ({request}, use) => {\n    const userIds: string[] = []\n    await use(userIds)\n\n    // Delete all created users\n    for (const id of userIds) {\n      await request.delete(`/api/test/users/${id}`)\n    }\n  },\n})\n\n// Usage\ntest('user profile page', async ({page, seedUser}) => {\n  const user = await seedUser({name: 'John Doe'})\n\n  await page.goto(`/users/${user.id}`)\n  await expect(page.getByText('John Doe')).toBeVisible()\n})\n```\n\n### Transaction Rollback Seeding\n\n```typescript\n// fixtures/db.fixture.ts\nexport const test = base.extend<{}, {db: DbTransaction}>({\n  db: [\n    async ({}, use) => {\n      const client = await pool.connect()\n      await client.query('BEGIN')\n\n      await use({\n        query: (sql: string, params?: any[]) => client.query(sql, params),\n        seed: async (table: string, data: object) => {\n          const keys = Object.keys(data)\n          const values = Object.values(data)\n          const placeholders = keys.map((_, i) => `$${i + 1}`)\n\n          const result = await client.query(\n            `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`,\n            values,\n          )\n          return result.rows[0]\n        },\n      })\n\n      await client.query('ROLLBACK')\n      client.release()\n    },\n    {scope: 'test'},\n  ],\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                    | Problem                         | Solution                   |\n| ------------------------------- | ------------------------------- | -------------------------- |\n| Hardcoded test data             | Brittle, repetitive             | Use factories              |\n| Random data without seed        | Non-reproducible failures       | Seed faker per test        |\n| Shared mutable test data        | Tests interfere with each other | Create fresh data per test |\n| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories    |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns\n- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n",".agents/skills/playwright-best-practices/core/test-suite-structure.md":"# Test Suite Structure\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [E2E Tests](#e2e-tests)\n3. [Component Tests](#component-tests)\n4. [API Tests](#api-tests)\n5. [Visual Regression Tests](#visual-regression-tests)\n6. [Directory Structure](#directory-structure)\n7. [Tagging & Filtering](#tagging--filtering)\n\n### Project Setup\n\n```bash\nnpm init playwright@latest\n```\n\n## Configuration\n\n### Essential Configuration\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: [['html'], ['list']],\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n  ],\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n})\n```\n\n## E2E Tests\n\nFull user journey tests through the browser.\n\n### Structure\n\n```typescript\n// tests/e2e/checkout.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Checkout Flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/products')\n  })\n\n  test('complete purchase as guest', async ({page}) => {\n    // Add to cart\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await expect(page.getByTestId('cart-count')).toHaveText('1')\n\n    // Go to checkout\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n\n    // Fill shipping\n    await page.getByLabel('Email').fill('guest@example.com')\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByRole('button', {name: 'Continue'}).click()\n\n    // Payment\n    await page.getByLabel('Card Number').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay Now'}).click()\n\n    // Confirmation\n    await expect(page.getByRole('heading')).toHaveText('Order Confirmed')\n  })\n\n  test('apply discount code', async ({page}) => {\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await page.getByRole('link', {name: 'Cart'}).click()\n\n    await page.getByLabel('Discount Code').fill('SAVE10')\n    await page.getByRole('button', {name: 'Apply'}).click()\n\n    await expect(page.getByText('10% discount applied')).toBeVisible()\n  })\n})\n```\n\n### Best Practices\n\n- Test critical user journeys\n- Keep tests independent\n- Use realistic data\n- Clean up test data in teardown\n\n## Component Tests\n\nTest individual components in isolation using Playwright Component Testing.\n\n```bash\nnpm init playwright@latest -- --ct\n```\n\nFor comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.\n\n## API Tests\n\nTest backend APIs without browser.\n\n### API Mocking Patterns\n\nFor E2E tests that need to mock API responses:\n\n```typescript\n// Mock single endpoint\ntest('displays mocked users', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 200,\n      json: [{id: 1, name: 'Test User'}],\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Test User')).toBeVisible()\n})\n\n// Mock with different responses\ntest('handles API errors', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 500,\n      json: {error: 'Server error'},\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Server error')).toBeVisible()\n})\n\n// Conditional mocking\ntest('mocks based on request', async ({page}) => {\n  await page.route('**/api/users', (route, request) => {\n    if (request.method() === 'GET') {\n      route.fulfill({json: [{id: 1, name: 'User'}]})\n    } else {\n      route.continue()\n    }\n  })\n})\n\n// Mock with delay (simulate slow network)\ntest('handles slow API', async ({page}) => {\n  await page.route('**/api/data', (route) =>\n    route.fulfill({\n      json: {data: 'test'},\n      delay: 2000, // 2 second delay\n    }),\n  )\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('Loading...')).toBeVisible()\n  await expect(page.getByText('test')).toBeVisible()\n})\n```\n\nFor advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.\n\n## Visual Regression Tests\n\nCompare screenshots to detect visual changes.\n\n### Basic Visual Test\n\n```typescript\n// tests/visual/homepage.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('homepage visual', async ({page}) => {\n  await page.goto('/')\n  await expect(page).toHaveScreenshot('homepage.png')\n})\n\ntest('component visual', async ({page}) => {\n  await page.goto('/components')\n\n  const button = page.getByRole('button', {name: 'Primary'})\n  await expect(button).toHaveScreenshot('primary-button.png')\n})\n```\n\n### Visual Test Options\n\n```typescript\ntest('dashboard visual', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page).toHaveScreenshot('dashboard.png', {\n    fullPage: true, // Capture entire scrollable page\n    maxDiffPixels: 100, // Allow up to 100 different pixels\n    maxDiffPixelRatio: 0.01, // Or 1% difference\n    threshold: 0.2, // Pixel comparison threshold\n    animations: 'disabled', // Disable animations\n    mask: [page.getByTestId('date')], // Mask dynamic content\n  })\n})\n```\n\n### Handling Dynamic Content\n\n```typescript\ntest('page with dynamic content', async ({page}) => {\n  await page.goto('/profile')\n\n  // Mask elements that change\n  await expect(page).toHaveScreenshot('profile.png', {\n    mask: [page.getByTestId('timestamp'), page.getByTestId('avatar'), page.getByRole('img')],\n  })\n})\n\n// Or hide elements via CSS\ntest('page hiding dynamic elements', async ({page}) => {\n  await page.goto('/profile')\n\n  await page.addStyleTag({\n    content: `\n      .dynamic-content { visibility: hidden !important; }\n      [data-testid=\"ad-banner\"] { display: none !important; }\n    `,\n  })\n\n  await expect(page).toHaveScreenshot('profile-stable.png')\n})\n```\n\n### Visual Test Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  expect: {\n    toHaveScreenshot: {\n      maxDiffPixels: 50,\n      animations: 'disabled',\n    },\n  },\n  projects: [\n    {\n      name: 'visual-chrome',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n      testMatch: /.*visual.*\\.spec\\.ts/,\n    },\n  ],\n})\n```\n\n### Update Snapshots\n\n```bash\n# Update all snapshots\nnpx playwright test --update-snapshots\n\n# Update specific test\nnpx playwright test homepage.spec.ts --update-snapshots\n```\n\n## Directory Structure\n\n```\ntests/\n├── e2e/                    # End-to-end tests\n│   ├── auth.spec.ts\n│   ├── checkout.spec.ts\n│   └── dashboard.spec.ts\n├── component/              # Component tests\n│   ├── Button.spec.tsx\n│   └── Modal.spec.tsx\n├── api/                    # API tests\n│   ├── users.spec.ts\n│   └── products.spec.ts\n├── visual/                 # Visual regression tests\n│   └── homepage.spec.ts\n├── fixtures/               # Custom fixtures\n│   ├── auth.fixture.ts\n│   └── api.fixture.ts\n└── pages/                  # Page objects\n    ├── login.page.ts\n    └── dashboard.page.ts\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                            | Solution                  |\n| ------------------------------------- | ---------------------------------- | ------------------------- |\n| Long test files                       | Hard to maintain, slow to navigate | Split by feature, use POM |\n| Tests depend on execution order       | Flaky, hard to debug               | Keep tests independent    |\n| Testing multiple features in one test | Hard to debug failures             | One feature per test      |\n\n## Related References\n\n- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering\n- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions\n- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data\n\n## Tagging & Filtering\n\n### Using Tags\n\n```typescript\ntest('user login @smoke @auth', async ({page}) => {\n  // ...\n})\n\ntest('checkout flow @e2e @critical', async ({page}) => {\n  // ...\n})\n\ntest.describe('API tests @api', () => {\n  test('create user', async ({request}) => {\n    // ...\n  })\n})\n```\n\n### Running Tagged Tests\n\n```bash\n# Run smoke tests\nnpx playwright test --grep @smoke\n\n# Run all except slow tests\nnpx playwright test --grep-invert @slow\n\n# Combine tags\nnpx playwright test --grep \"@smoke|@critical\"\n```\n\nFor project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.\n",".agents/skills/playwright-best-practices/core/test-tags.md":"# Test Tags\n\n## Table of Contents\n\n1. [Basic Tagging](#basic-tagging)\n2. [Tagging Describe Blocks](#tagging-describe-blocks)\n3. [Running Tagged Tests](#running-tagged-tests)\n4. [Filtering by Tags](#filtering-by-tags)\n5. [Configuration-Based Filtering](#configuration-based-filtering)\n6. [Tag Organization Patterns](#tag-organization-patterns)\n7. [Common Tag Categories](#common-tag-categories)\n8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)\n9. [Related References](#related-references)\n\n## Basic Tagging\n\n### Tag via Details Object\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest(\n  'test login page',\n  {\n    tag: '@fast',\n  },\n  async ({page}) => {\n    await page.goto('/login')\n    await expect(page.getByRole('heading')).toBeVisible()\n  },\n)\n\ntest(\n  'test dashboard',\n  {\n    tag: '@slow',\n  },\n  async ({page}) => {\n    await page.goto('/dashboard')\n    await expect(page.getByTestId('charts')).toBeVisible()\n  },\n)\n```\n\n### Tag via Title (not recommended)\n\n```typescript\ntest('test full report @slow', async ({page}) => {\n  await page.goto('/reports/full')\n  await expect(page.getByText('Report loaded')).toBeVisible()\n})\n\ntest('quick validation @fast @smoke', async ({page}) => {\n  await page.goto('/')\n  await expect(page.locator('body')).toBeVisible()\n})\n```\n\n## Tagging Describe Blocks\n\n### Tag All Tests in Group\n\n```typescript\ntest.describe(\n  'report tests',\n  {\n    tag: '@report',\n  },\n  () => {\n    test('test report header', async ({page}) => {\n      // Inherits @report tag\n    })\n\n    test('test report footer', async ({page}) => {\n      // Inherits @report tag\n    })\n  },\n)\n```\n\n### Combine Group and Test Tags\n\n```typescript\ntest.describe(\n  'admin features',\n  {\n    tag: '@admin',\n  },\n  () => {\n    test('admin dashboard', async ({page}) => {\n      // Has @admin tag\n    })\n\n    test(\n      'admin settings',\n      {\n        tag: ['@slow', '@critical'],\n      },\n      async ({page}) => {\n        // Has @admin, @slow, @critical tags\n      },\n    )\n  },\n)\n```\n\n## Running Tagged Tests\n\n### Run Tests with Specific Tag\n\n```bash\n# Run all @fast tests\nnpx playwright test --grep @fast\n```\n\n### Exclude Tests with Tag\n\n```bash\n# Run all tests except @slow\nnpx playwright test --grep-invert @slow\n```\n\n## Filtering by Tags\n\n### Logical OR (Either Tag)\n\n```bash\n# Run tests with @fast OR @smoke\nnpx playwright test --grep \"@fast|@smoke\"\n```\n\n### Logical AND (Both Tags)\n\n```bash\n# Run tests with both @fast AND @critical\nnpx playwright test --grep \"(?=.*@fast)(?=.*@critical)\"\n```\n\n### Complex Patterns\n\n```bash\n# Run @e2e tests that are also @critical\nnpx playwright test --grep \"(?=.*@e2e)(?=.*@critical)\"\n\n# Run @api tests excluding @slow\nnpx playwright test --grep \"@api\" --grep-invert \"@slow\"\n```\n\n## Configuration-Based Filtering\n\n### Filter in playwright.config.ts\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  grep: /@smoke/,\n  grepInvert: /@flaky/,\n})\n```\n\n### Project-Specific Tags\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n    },\n  ],\n})\n```\n\n### Environment-Based Filtering\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nconst isCI = !!process.env.CI\n\nexport default defineConfig({\n  grep: isCI ? /@smoke|@critical/ : undefined,\n  grepInvert: isCI ? /@flaky/ : undefined,\n})\n```\n\n## Tag Organization Patterns\n\n### By Test Type\n\n```typescript\n// Smoke tests - quick validation\ntest('homepage loads', {tag: '@smoke'}, async ({page}) => {})\ntest('login works', {tag: '@smoke'}, async ({page}) => {})\n\n// Regression tests - comprehensive\ntest('full checkout flow', {tag: '@regression'}, async ({page}) => {})\ntest('all payment methods', {tag: '@regression'}, async ({page}) => {})\n\n// E2E tests - user journeys\ntest('complete user journey', {tag: '@e2e'}, async ({page}) => {})\n```\n\n### By Priority\n\n```typescript\ntest(\n  'payment processing',\n  {\n    tag: ['@critical', '@p0'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'user preferences',\n  {\n    tag: ['@p1'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'theme customization',\n  {\n    tag: ['@p2'],\n  },\n  async ({page}) => {},\n)\n```\n\n### By Feature Area\n\n```typescript\ntest.describe(\n  'authentication',\n  {\n    tag: '@auth',\n  },\n  () => {\n    test('login @smoke', async ({page}) => {})\n    test('logout', async ({page}) => {})\n    test('password reset @slow', async ({page}) => {})\n  },\n)\n\ntest.describe(\n  'payments',\n  {\n    tag: '@payments',\n  },\n  () => {\n    test('credit card @critical', async ({page}) => {})\n    test('paypal @critical', async ({page}) => {})\n  },\n)\n```\n\n## Common Tag Categories\n\n| Category        | Tags                                          | Purpose                       |\n| --------------- | --------------------------------------------- | ----------------------------- |\n| **Speed**       | `@fast`, `@slow`                              | Execution time classification |\n| **Priority**    | `@critical`, `@p0`, `@p1`, `@p2`              | Business importance           |\n| **Type**        | `@smoke`, `@regression`, `@e2e`               | Test suite categorization     |\n| **Feature**     | `@auth`, `@payments`, `@settings`             | Feature area grouping         |\n| **Pipeline**    | `@pr`, `@nightly`, `@release`                 | CI/CD execution timing        |\n| **Status**      | `@flaky`, `@wip`, `@quarantine`               | Test health tracking          |\n| **Environment** | `@local`, `@staging`, `@prod`                 | Target environment            |\n| **Team**        | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment               |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                  | Solution                                       |\n| ------------------------ | ------------------------ | ---------------------------------------------- |\n| Too many tags per test   | Hard to maintain         | Limit to 2-3 relevant tags                     |\n| Inconsistent naming      | Confusing filtering      | Establish naming conventions                   |\n| Missing `@` prefix       | Tags won't match filters | Always prefix with `@`                         |\n| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics                     |\n| Not using tags           | Can't selectively run    | Tag by type, priority, or feature              |\n| Tags in test title       | Hard to parse/filter     | Use the details object for tags, not the title |\n\n## Related References\n\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow\n- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup\n",".agents/skills/playwright-best-practices/debugging/console-errors.md":"# Browser Console & JavaScript Error Handling\n\n## Table of Contents\n\n1. [Capturing Console Messages](#capturing-console-messages)\n2. [Failing on Console Errors](#failing-on-console-errors)\n3. [JavaScript Error Detection](#javascript-error-detection)\n4. [Monitoring Warnings](#monitoring-warnings)\n5. [Console Fixtures](#console-fixtures)\n\n## Capturing Console Messages\n\n### Basic Console Capture\n\n```typescript\ntest('capture console logs', async ({page}) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`${msg.type()}: ${msg.text()}`)\n  })\n\n  await page.goto('/')\n\n  // Check what was logged\n  console.log('Captured logs:', logs)\n})\n```\n\n### Capture by Type\n\n```typescript\ntest('capture specific console types', async ({page}) => {\n  const errors: string[] = []\n  const warnings: string[] = []\n  const infos: string[] = []\n\n  page.on('console', (msg) => {\n    switch (msg.type()) {\n      case 'error':\n        errors.push(msg.text())\n        break\n      case 'warning':\n        warnings.push(msg.text())\n        break\n      case 'info':\n      case 'log':\n        infos.push(msg.text())\n        break\n    }\n  })\n\n  await page.goto('/dashboard')\n\n  expect(errors).toHaveLength(0)\n  console.log('Warnings:', warnings)\n})\n```\n\n### Capture with Stack Trace\n\n```typescript\ntest('capture errors with location', async ({page}) => {\n  const errors: {message: string; location?: string}[] = []\n\n  page.on('console', async (msg) => {\n    if (msg.type() === 'error') {\n      const location = msg.location()\n      errors.push({\n        message: msg.text(),\n        location: location ? `${location.url}:${location.lineNumber}` : undefined,\n      })\n    }\n  })\n\n  await page.goto('/buggy-page')\n\n  // Log errors with source location\n  errors.forEach((e) => {\n    console.log(`Error: ${e.message}`)\n    if (e.location) console.log(`  at ${e.location}`)\n  })\n})\n```\n\n## Failing on Console Errors\n\n### Fail Test on Any Error\n\n```typescript\ntest('no console errors allowed', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Load Data'}).click()\n\n  // Fail if any console errors\n  expect(errors, `Console errors found:\\n${errors.join('\\n')}`).toHaveLength(0)\n})\n```\n\n### Fail with Allowed Exceptions\n\n```typescript\ntest('no unexpected console errors', async ({page}) => {\n  const allowedErrors = [/Failed to load resource.*favicon/, /ResizeObserver loop/]\n\n  const unexpectedErrors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      const text = msg.text()\n      const isAllowed = allowedErrors.some((pattern) => pattern.test(text))\n      if (!isAllowed) {\n        unexpectedErrors.push(text)\n      }\n    }\n  })\n\n  await page.goto('/')\n\n  expect(\n    unexpectedErrors,\n    `Unexpected console errors:\\n${unexpectedErrors.join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Auto-Fail Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleFixtures = {\n  failOnConsoleError: void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  failOnConsoleError: [\n    async ({page}, use, testInfo) => {\n      const errors: string[] = []\n\n      page.on('console', (msg) => {\n        if (msg.type() === 'error') {\n          errors.push(msg.text())\n        }\n      })\n\n      await use()\n\n      // After test, check for errors\n      if (errors.length > 0) {\n        testInfo.annotations.push({\n          type: 'console-errors',\n          description: errors.join('\\n'),\n        })\n        throw new Error(`Console errors detected:\\n${errors.join('\\n')}`)\n      }\n    },\n    {auto: true}, // Runs for every test\n  ],\n})\n```\n\n## JavaScript Error Detection\n\n### Catch Uncaught Exceptions\n\n```typescript\ntest('no uncaught exceptions', async ({page}) => {\n  const pageErrors: Error[] = []\n\n  page.on('pageerror', (error) => {\n    pageErrors.push(error)\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Trigger Action'}).click()\n\n  expect(\n    pageErrors,\n    `Uncaught exceptions:\\n${pageErrors.map((e) => e.message).join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Capture Error Details\n\n```typescript\ntest('capture JS error details', async ({page}) => {\n  const errors: {message: string; stack?: string}[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push({\n      message: error.message,\n      stack: error.stack,\n    })\n  })\n\n  await page.goto('/error-page')\n\n  if (errors.length > 0) {\n    console.log('JavaScript errors:')\n    errors.forEach((e) => {\n      console.log(`  Message: ${e.message}`)\n      console.log(`  Stack: ${e.stack}`)\n    })\n  }\n})\n```\n\n### Test Error Boundary Triggers\n\n```typescript\ntest('error boundary catches render error', async ({page}) => {\n  let errorCaught = false\n\n  page.on('pageerror', () => {\n    // Note: React error boundaries catch errors before they become pageerrors\n    // This would only fire for unhandled errors\n    errorCaught = true\n  })\n\n  // Trigger component error via props\n  await page.route(\n    '**/api/data',\n    (route) => route.fulfill({json: null}), // Will cause \"cannot read property of null\"\n  )\n\n  await page.goto('/dashboard')\n\n  // Error boundary should show fallback, not crash\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  expect(errorCaught).toBe(false) // Error was caught by boundary\n})\n```\n\n## Monitoring Warnings\n\n### Capture Deprecation Warnings\n\n```typescript\ntest('no deprecation warnings', async ({page}) => {\n  const deprecations: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('deprecated') || text.includes('Deprecation'))) {\n      deprecations.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  if (deprecations.length > 0) {\n    console.warn('Deprecation warnings found:')\n    deprecations.forEach((d) => console.warn(`  - ${d}`))\n  }\n\n  // Optionally fail\n  // expect(deprecations).toHaveLength(0);\n})\n```\n\n### React Development Warnings\n\n```typescript\ntest('no React warnings', async ({page}) => {\n  const reactWarnings: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('Warning:') || text.includes('React'))) {\n      reactWarnings.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  // Common React warnings to check\n  const criticalWarnings = reactWarnings.filter(\n    (w) =>\n      w.includes('Each child in a list should have a unique') ||\n      w.includes('Cannot update a component') ||\n      w.includes(\"Can't perform a React state update\"),\n  )\n\n  expect(criticalWarnings, `React warnings:\\n${criticalWarnings.join('\\n')}`).toHaveLength(0)\n})\n```\n\n## Console Fixtures\n\n### Comprehensive Console Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleMessage = {\n  type: string\n  text: string\n  location?: {url: string; line: number}\n  timestamp: number\n}\n\ntype ConsoleFixtures = {\n  consoleMessages: ConsoleMessage[]\n  getConsoleErrors: () => ConsoleMessage[]\n  getConsoleWarnings: () => ConsoleMessage[]\n  assertNoErrors: (allowedPatterns?: RegExp[]) => void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  consoleMessages: async ({page}, use) => {\n    const messages: ConsoleMessage[] = []\n\n    page.on('console', (msg) => {\n      const location = msg.location()\n      messages.push({\n        type: msg.type(),\n        text: msg.text(),\n        location: location ? {url: location.url, line: location.lineNumber} : undefined,\n        timestamp: Date.now(),\n      })\n    })\n\n    await use(messages)\n  },\n\n  getConsoleErrors: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'error'))\n  },\n\n  getConsoleWarnings: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'warning'))\n  },\n\n  assertNoErrors: async ({getConsoleErrors}, use) => {\n    await use((allowedPatterns = []) => {\n      const errors = getConsoleErrors()\n      const unexpected = errors.filter((e) => !allowedPatterns.some((p) => p.test(e.text)))\n\n      if (unexpected.length > 0) {\n        throw new Error(`Unexpected console errors:\\n${unexpected.map((e) => e.text).join('\\n')}`)\n      }\n    })\n  },\n})\n\n// Usage\ntest('page loads without errors', async ({page, assertNoErrors}) => {\n  await page.goto('/dashboard')\n  await page.getByRole('button', {name: 'Load'}).click()\n\n  assertNoErrors([/favicon/]) // Allow favicon errors\n})\n```\n\n### Attach Console to Report\n\n```typescript\ntest('capture console for debugging', async ({page}, testInfo) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`[${msg.type()}] ${msg.text()}`)\n  })\n\n  page.on('pageerror', (error) => {\n    logs.push(`[EXCEPTION] ${error.message}`)\n  })\n\n  await page.goto('/')\n  // ... test actions\n\n  // Attach console log to test report\n  await testInfo.attach('console-log', {\n    body: logs.join('\\n'),\n    contentType: 'text/plain',\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                    | Solution                    |\n| -------------------------- | -------------------------- | --------------------------- |\n| Ignoring console errors    | Bugs go unnoticed          | Check for errors in tests   |\n| Too strict error checking  | Tests fail on minor issues | Allow known/expected errors |\n| Not capturing stack traces | Hard to debug              | Include location info       |\n| Checking only at end       | Miss errors during actions | Capture continuously        |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for troubleshooting\n- **Error Testing**: See [error-testing.md](error-testing.md) for error scenarios\n",".agents/skills/playwright-best-practices/debugging/debugging.md":"# Debugging & Troubleshooting\n\n## Table of Contents\n\n1. [Debug Tools](#debug-tools)\n2. [Trace Viewer](#trace-viewer)\n3. [Identifying Flaky Tests](#identifying-flaky-tests)\n4. [Debugging Network Issues](#debugging-network-issues)\n5. [Debugging in CI](#debugging-in-ci)\n6. [Debugging Authentication](#debugging-authentication)\n7. [Debugging Screenshots](#debugging-screenshots)\n8. [Common Issues](#common-issues)\n9. [Logging](#logging)\n\n## Debug Tools\n\n### Playwright Inspector\n\n```bash\n# Run with inspector\nPWDEBUG=1 npx playwright test\n# Or specific test\nPWDEBUG=1 npx playwright test login.spec.ts\n```\n\nFeatures:\n\n- Step through test actions\n- Pick locators visually\n- Inspect DOM state\n- Edit and re-run\n\n### Headed Mode\n\n```bash\n# Run with visible browser\nnpx playwright test --headed\n\n# Interactive debugging (headed, paused, step-through)\nnpx playwright test --debug\n```\n\nYou can also set `slowMo` to add an `N` ms delay per action, making test execution easier to follow while debugging.\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    launchOptions: {\n      slowMo: 500,\n    },\n  },\n})\n```\n\n### UI Mode\n\n```bash\n# Interactive test runner\nnpx playwright test --ui\n```\n\nFeatures:\n\n- Watch mode\n- Test timeline\n- DOM snapshots\n- Network logs\n- Console logs\n\n### Debug in Code\n\n```typescript\ntest('debug example', async ({page}) => {\n  await page.goto('/')\n\n  // Pause and open inspector\n  await page.pause()\n\n  // Continue test...\n  await page.click('button')\n})\n```\n\n## Trace Viewer\n\n### Enable Traces\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    trace: 'on-first-retry', // Record on retry\n    // trace: 'on',                 // Always record\n    // trace: 'retain-on-failure',  // Keep only failures\n  },\n})\n```\n\n### View Traces\n\n```bash\n# Open trace file\nnpx playwright show-trace trace.zip\n\n# From test-results\nnpx playwright show-trace test-results/test-name/trace.zip\n```\n\n### Trace Contents\n\n- Screenshots at each action\n- DOM snapshots\n- Network requests/responses\n- Console logs\n- Action timeline\n- Source code\n\n### Programmatic Traces\n\n```typescript\ntest('manual trace', async ({page, context}) => {\n  await context.tracing.start({screenshots: true, snapshots: true})\n\n  await page.goto('/')\n  await page.click('button')\n\n  await context.tracing.stop({path: 'trace.zip'})\n})\n```\n\n## Identifying Flaky Tests\n\nIf a test fails intermittently, it's likely flaky. Quick checks:\n\n| Behavior                               | Likely Cause                  | Next Step                                    |\n| -------------------------------------- | ----------------------------- | -------------------------------------------- |\n| Fails sometimes, passes other times    | Flaky - timing/race condition | [flaky-tests.md](flaky-tests.md)             |\n| Fails only with multiple workers       | Flaky - parallelism/isolation | [flaky-tests.md](flaky-tests.md)             |\n| Fails only in CI                       | Environment difference        | [CI Debugging](#debugging-in-ci) below       |\n| Always fails                           | Bug in test or app            | Debug with tools above                       |\n| Always passes locally, always fails CI | CI-specific issue             | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) |\n\n> **For flaky test detection commands, root cause analysis, and fixing strategies**, see [flaky-tests.md](flaky-tests.md).\n\n## Debugging Network Issues\n\n### Monitor All Requests\n\n```typescript\ntest('debug network', async ({page}) => {\n  const requests: string[] = []\n  const failures: string[] = []\n\n  page.on('request', (req) => requests.push(`>> ${req.method()} ${req.url()}`))\n  page.on('requestfinished', (req) => {\n    const resp = req.response()\n    requests.push(`<< ${resp?.status()} ${req.url()}`)\n  })\n  page.on('requestfailed', (req) => {\n    failures.push(`FAILED: ${req.url()} - ${req.failure()?.errorText}`)\n  })\n\n  await page.goto('/dashboard')\n\n  // Log summary\n  console.log('Requests:', requests.length)\n  if (failures.length) console.log('Failures:', failures)\n})\n```\n\n### Wait for Specific API Response\n\nWhen debugging network-dependent issues, wait for specific API responses instead of arbitrary timeouts.\n\n```typescript\n// Start waiting BEFORE triggering the request\nconst responsePromise = page.waitForResponse(\n  (resp) => resp.url().includes('/api/data') && resp.status() === 200,\n)\nawait page.getByRole('button', {name: 'Load'}).click()\nconst response = await responsePromise\nconsole.log('Status:', response.status())\n```\n\n> **For comprehensive waiting patterns** (navigation, element state, network, polling), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies).\n\n### Debug Slow Requests\n\n```typescript\ntest('find slow requests', async ({page}) => {\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const total = timing.responseEnd - timing.requestStart\n    if (total > 1000) {\n      console.log(`SLOW (${total}ms): ${request.url()}`)\n    }\n  })\n\n  await page.goto('/')\n})\n```\n\n## Debugging in CI\n\n### Simulate CI Locally\n\n```bash\n# Run in headless mode like CI\nCI=true npx playwright test\n\n# Match CI browser versions\nnpx playwright install --with-deps\n\n# Run in Docker (same as CI)\ndocker run --rm -v $(pwd):/work -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### CI-Specific Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // More artifacts in CI for debugging\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n  },\n\n  // More retries in CI (but investigate failures!)\n  retries: process.env.CI ? 2 : 0,\n})\n```\n\n### Debug CI Environment\n\n```typescript\ntest('CI environment check', async ({page}, testInfo) => {\n  console.log('CI:', process.env.CI)\n  console.log('Project:', testInfo.project.name)\n  console.log('Worker:', testInfo.workerIndex)\n  console.log('Retry:', testInfo.retry)\n  console.log('Base URL:', testInfo.project.use.baseURL)\n\n  // Check viewport\n  const viewport = page.viewportSize()\n  console.log('Viewport:', viewport)\n})\n```\n\n## Debugging Authentication\n\n```typescript\ntest('debug auth', async ({page, context}) => {\n  // Inspect current storage state\n  const storage = await context.storageState()\n  console.log(\n    'Cookies:',\n    storage.cookies.map((c) => c.name),\n  )\n\n  // Check if auth cookies are present\n  const cookies = await context.cookies()\n  const authCookie = cookies.find((c) => c.name.includes('session'))\n  console.log('Auth cookie:', authCookie ? 'present' : 'MISSING')\n\n  await page.goto('/protected')\n\n  // Check if redirected to login (auth failed)\n  if (page.url().includes('/login')) {\n    console.error('Auth failed - redirected to login')\n    // Save state for inspection\n    await context.storageState({path: 'debug-auth.json'})\n  }\n})\n```\n\n## Debugging Screenshots\n\n### Compare Visual State\n\n```typescript\ntest('visual debug', async ({page}, testInfo) => {\n  await page.goto('/')\n\n  // Screenshot before action\n  await page.screenshot({\n    path: testInfo.outputPath('before.png'),\n    fullPage: true,\n  })\n\n  await page.getByRole('button', {name: 'Open Menu'}).click()\n\n  // Screenshot after action\n  await page.screenshot({\n    path: testInfo.outputPath('after.png'),\n    fullPage: true,\n  })\n\n  // Attach to report\n  await testInfo.attach('before', {\n    path: testInfo.outputPath('before.png'),\n    contentType: 'image/png',\n  })\n})\n```\n\n### Screenshot Specific Element\n\n```typescript\ntest('element screenshot', async ({page}) => {\n  await page.goto('/')\n\n  const element = page.getByTestId('problem-area')\n\n  // Screenshot just the element\n  await element.screenshot({path: 'element-debug.png'})\n\n  // Highlight element in full page screenshot\n  await element.evaluate((el) => (el.style.border = '3px solid red'))\n  await page.screenshot({path: 'highlighted.png'})\n})\n```\n\n## Common Issues\n\n### Element Not Found\n\n```typescript\n// Debug: Check if element exists\nconsole.log(await page.getByRole('button').count())\n\n// Debug: Log all buttons\nconst buttons = await page.getByRole('button').all()\nfor (const button of buttons) {\n  console.log(await button.textContent())\n}\n\n// Debug: Screenshot before action\nawait page.screenshot({path: 'debug.png'})\nawait page.getByRole('button').click()\n```\n\n### Timeout Issues\n\n```typescript\n// Increase timeout for slow operations\nawait expect(page.getByText('Loaded')).toBeVisible({timeout: 30000})\n\n// Global timeout increase\ntest.setTimeout(60000)\n\n// Check what's blocking\ntest('debug timeout', async ({page}) => {\n  await page.goto('/slow-page')\n\n  // Log network activity\n  page.on('request', (request) => console.log('>>', request.url()))\n  page.on('response', (response) => console.log('<<', response.url(), response.status()))\n})\n```\n\n### Selector Issues\n\n```typescript\n// Debug: Highlight element\nawait page.getByRole('button').highlight()\n\n// Debug: Evaluate selector in browser console\n// Run in Inspector console:\n// playwright.locator('button').first().highlight()\n\n// Debug: Get element info\nconst element = page.getByRole('button')\nconsole.log('Count:', await element.count())\nconsole.log('Visible:', await element.isVisible())\nconsole.log('Enabled:', await element.isEnabled())\n```\n\n### Frame Issues\n\n```typescript\n// Debug: List all frames\nfor (const frame of page.frames()) {\n  console.log('Frame:', frame.url())\n}\n\n// Debug: Check if element is in iframe\nconst frame = page.frameLocator('iframe').first()\nconsole.log(await frame.getByRole('button').count())\n```\n\n## Logging\n\n### Capture Browser Console\n\n```typescript\ntest('with logging', async ({page}) => {\n  page.on('console', (msg) => console.log('Browser:', msg.text()))\n  page.on('pageerror', (error) => console.log('Page error:', error.message))\n  await page.goto('/')\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Custom Test Attachments\n\n```typescript\ntest('with attachments', async ({page}, testInfo) => {\n  // Attach screenshot to report\n  const screenshot = await page.screenshot()\n  await testInfo.attach('screenshot', {\n    body: screenshot,\n    contentType: 'image/png',\n  })\n\n  // Attach logs or data\n  await testInfo.attach('logs', {\n    body: 'Custom log data',\n    contentType: 'text/plain',\n  })\n\n  // Use testInfo for output paths\n  const outputPath = testInfo.outputPath('debug-file.json')\n})\n```\n\n## Troubleshooting Checklist\n\n### By Symptom\n\n| Symptom                                       | Common Causes                                                | Quick Fixes                                                         | Reference                                                                                  |\n| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| **Element not found**                         | Wrong selector, element not visible, in iframe, timing issue | Check locator with Inspector, wait for visibility, use frameLocator | [locators.md](../core/locators.md), [assertions-waiting.md](../core/assertions-waiting.md) |\n| **Timeout errors**                            | Slow network, heavy page load, waiting for wrong condition   | Increase timeout, wait for specific response, check network tab     | [assertions-waiting.md](../core/assertions-waiting.md)                                     |\n| **Flaky tests**                               | Race conditions, shared state, timing dependencies           | See comprehensive flaky test guide                                  | [flaky-tests.md](flaky-tests.md)                                                           |\n| **Tests pass locally, fail in CI**            | Environment differences, missing dependencies, timing        | Simulate CI locally, check CI logs, verify environment vars         | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md), [flaky-tests.md](flaky-tests.md)             |\n| **Slow test execution**                       | Not parallelized, heavy network calls, unnecessary waits     | Enable parallelization, mock APIs, optimize waits                   | [performance.md](../infrastructure-ci-cd/performance.md)                                   |\n| **Selector works in browser but not in test** | Element not attached, wrong context, dynamic content         | Use auto-waiting, check iframe, verify element state                | [locators.md](../core/locators.md)                                                         |\n| **Test fails on retry**                       | Non-deterministic data, external dependencies                | Use test data fixtures, mock external services                      | [fixtures-hooks.md](../core/fixtures-hooks.md)                                             |\n\n### Step-by-Step Debugging Process\n\n1. **Reproduce the issue**\n\n   ```bash\n   # Run with trace enabled\n   npx playwright test tests/failing.spec.ts --trace on\n\n   # If intermittent, run multiple times\n   npx playwright test --repeat-each=10\n   ```\n\n2. **Inspect the failure**\n\n   ```bash\n   # View trace\n   npx playwright show-trace test-results/path-to-trace.zip\n\n   # Run in headed mode to watch\n   npx playwright test --headed\n\n   # Use inspector for step-by-step\n   PWDEBUG=1 npx playwright test\n   ```\n\n3. **Isolate the problem**\n\n   ```typescript\n   // Add debugging points\n   await page.pause()\n\n   // Log element state\n   console.log('Element count:', await page.getByRole('button').count())\n   console.log('Element visible:', await page.getByRole('button').isVisible())\n\n   // Take screenshot at failure point\n   await page.screenshot({path: 'debug.png'})\n   ```\n\n4. **Check related areas**\n   - Network requests: Are API calls completing? (see [Debugging Network Issues](#debugging-network-issues))\n   - Timing: Is auto-waiting working correctly?\n   - State: Is the test isolated? (see [flaky-tests.md](flaky-tests.md))\n   - Environment: Does it work locally but fail in CI? (see [Debugging in CI](#debugging-in-ci))\n\n5. **Apply fix and verify**\n   - Fix the root cause (not just symptoms)\n   - Run multiple times to confirm stability: `--repeat-each=10`\n   - Check related tests aren't affected\n\n## Related References\n\n- **Flaky tests**: See [flaky-tests.md](flaky-tests.md) for comprehensive flaky test guide\n- **Locator issues**: See [locators.md](../core/locators.md) for selector strategies\n- **Waiting problems**: See [assertions-waiting.md](../core/assertions-waiting.md) for waiting patterns\n- **Test isolation**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for fixtures and isolation\n- **CI issues**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI configuration\n",".agents/skills/playwright-best-practices/debugging/error-testing.md":"# Error & Edge Case Testing\n\n## Table of Contents\n\n1. [Error Boundaries](#error-boundaries)\n2. [Network Failures](#network-failures)\n3. [Offline Testing](#offline-testing)\n4. [Loading States](#loading-states)\n5. [Form Validation](#form-validation)\n\n## Error Boundaries\n\n### Test Component Errors\n\n```typescript\ntest('error boundary catches component error', async ({page}) => {\n  // Trigger error via mock\n  await page.route('**/api/user', (route) => {\n    route.fulfill({\n      json: null, // Will cause component to throw\n    })\n  })\n\n  await page.goto('/profile')\n\n  // Error boundary should render fallback\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Try Again'})).toBeVisible()\n})\n```\n\n### Test Error Recovery\n\n```typescript\ntest('recover from error state', async ({page}) => {\n  let requestCount = 0\n\n  await page.route('**/api/data', (route) => {\n    requestCount++\n    if (requestCount === 1) {\n      return route.fulfill({status: 500})\n    }\n    return route.fulfill({\n      json: {data: 'success'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Error state\n  await expect(page.getByText('Failed to load')).toBeVisible()\n\n  // Retry\n  await page.getByRole('button', {name: 'Retry'}).click()\n\n  // Success state\n  await expect(page.getByText('success')).toBeVisible()\n})\n```\n\n### Test JavaScript Errors\n\n```typescript\ntest('handles runtime error gracefully', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push(error.message)\n  })\n\n  await page.goto('/buggy-page')\n\n  // App should still be functional despite error\n  await expect(page.getByRole('navigation')).toBeVisible()\n\n  // Error was logged\n  expect(errors.length).toBeGreaterThan(0)\n})\n```\n\n## Network Failures\n\n### Test API Errors\n\n```typescript\ntest.describe('API error handling', () => {\n  const errorCodes = [400, 401, 403, 404, 500, 502, 503]\n\n  for (const status of errorCodes) {\n    test(`handles ${status} error`, async ({page}) => {\n      await page.route('**/api/data', (route) =>\n        route.fulfill({\n          status,\n          json: {error: `Error ${status}`},\n        }),\n      )\n\n      await page.goto('/dashboard')\n\n      // Appropriate error message shown\n      await expect(page.getByRole('alert')).toBeVisible()\n    })\n  }\n})\n```\n\n### Test Timeout\n\n```typescript\ntest('handles request timeout', async ({page}) => {\n  await page.route('**/api/slow', async (route) => {\n    // Never respond - simulates timeout\n    await new Promise(() => {})\n  })\n\n  await page.goto('/slow-page')\n\n  // Should show timeout message (app should have its own timeout)\n  await expect(page.getByText('Request timed out')).toBeVisible({\n    timeout: 15000,\n  })\n})\n```\n\n### Test Connection Reset\n\n```typescript\ntest('handles connection failure', async ({page}) => {\n  await page.route('**/api/data', (route) => {\n    route.abort('connectionfailed')\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByText('Connection failed')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Test Mid-Request Failure\n\n```typescript\ntest('handles failure during request', async ({page}) => {\n  let requestStarted = false\n\n  await page.route('**/api/upload', async (route) => {\n    requestStarted = true\n    // Abort after small delay (mid-request)\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.abort('failed')\n  })\n\n  await page.goto('/upload')\n  await page.getByLabel('File').setInputFiles('./fixtures/large-file.pdf')\n  await page.getByRole('button', {name: 'Upload'}).click()\n\n  // Should show failure, not hang\n  await expect(page.getByText('Upload failed')).toBeVisible()\n  expect(requestStarted).toBe(true)\n})\n```\n\n## Offline Testing\n\nThis section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing).\n\n### Go Offline During Session\n\n```typescript\ntest('handles going offline', async ({page, context}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByTestId('data')).toBeVisible()\n\n  // Go offline unexpectedly\n  await context.setOffline(true)\n\n  // Try to refresh data\n  await page.getByRole('button', {name: 'Refresh'}).click()\n\n  // Should show offline indicator\n  await expect(page.getByText(\"You're offline\")).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n\n  // Should recover\n  await page.getByRole('button', {name: 'Refresh'}).click()\n  await expect(page.getByText(\"You're offline\")).toBeHidden()\n})\n```\n\n### Test Network Recovery\n\n```typescript\ntest('recovers gracefully when connection returns', async ({page, context}) => {\n  await page.goto('/dashboard')\n\n  // Simulate connection drop\n  await context.setOffline(true)\n\n  // App should show degraded state\n  await expect(page.getByRole('alert')).toContainText(/offline|connection/i)\n\n  // Connection restored\n  await context.setOffline(false)\n\n  // Retry should work\n  await page.getByRole('button', {name: 'Retry'}).click()\n  await expect(page.getByTestId('data')).toBeVisible()\n})\n```\n\n## Loading States\n\n### Test Skeleton Loaders\n\n```typescript\ntest('shows skeleton during load', async ({page}) => {\n  // Add delay to API response\n  await page.route('**/api/posts', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 1000))\n    route.fulfill({\n      json: [{id: 1, title: 'Post 1'}],\n    })\n  })\n\n  await page.goto('/posts')\n\n  // Skeleton should appear immediately\n  await expect(page.getByTestId('skeleton')).toBeVisible()\n\n  // Then content replaces skeleton\n  await expect(page.getByText('Post 1')).toBeVisible()\n  await expect(page.getByTestId('skeleton')).toBeHidden()\n})\n```\n\n### Test Loading Indicators\n\n```typescript\ntest('shows loading state for actions', async ({page}) => {\n  await page.route('**/api/save', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.fulfill({json: {success: true}})\n  })\n\n  await page.goto('/editor')\n  await page.getByLabel('Content').fill('New content')\n\n  const saveButton = page.getByRole('button', {name: 'Save'})\n  await saveButton.click()\n\n  // Button should show loading state\n  await expect(saveButton).toBeDisabled()\n  await expect(page.getByTestId('spinner')).toBeVisible()\n\n  // Then success state\n  await expect(saveButton).toBeEnabled()\n  await expect(page.getByText('Saved')).toBeVisible()\n})\n```\n\n### Test Empty States\n\n```typescript\ntest('shows empty state when no data', async ({page}) => {\n  await page.route('**/api/items', (route) => route.fulfill({json: []}))\n\n  await page.goto('/items')\n\n  await expect(page.getByText('No items yet')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Create First Item'})).toBeVisible()\n})\n```\n\n## Form Validation\n\n### Test Client-Side Validation\n\n```typescript\ntest('validates required fields', async ({page}) => {\n  await page.goto('/signup')\n\n  // Submit empty form\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Should show validation errors\n  await expect(page.getByText('Email is required')).toBeVisible()\n  await expect(page.getByText('Password is required')).toBeVisible()\n\n  // Form should not submit\n  await expect(page).toHaveURL('/signup')\n})\n```\n\n### Test Format Validation\n\n```typescript\ntest('validates email format', async ({page}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Email').fill('invalid-email')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeVisible()\n\n  // Fix the error\n  await page.getByLabel('Email').fill('valid@email.com')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeHidden()\n})\n```\n\n### Test Server-Side Validation\n\n```typescript\ntest('handles server validation errors', async ({page}) => {\n  await page.route('**/api/register', (route) =>\n    route.fulfill({\n      status: 422,\n      json: {\n        errors: {\n          email: 'Email already exists',\n          username: 'Username is taken',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('taken@email.com')\n  await page.getByLabel('Username').fill('takenuser')\n  await page.getByLabel('Password').fill('password123')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Server errors should display\n  await expect(page.getByText('Email already exists')).toBeVisible()\n  await expect(page.getByText('Username is taken')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                               |\n| ------------------------ | ------------------------------ | -------------------------------------- |\n| Only testing happy path  | Misses error handling bugs     | Test all error scenarios               |\n| No network failure tests | App crashes on poor connection | Test offline/slow/failed requests      |\n| Skipping loading states  | Janky UX not caught            | Assert loading UI appears              |\n| Ignoring validation      | Form bugs slip through         | Test both client and server validation |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for mock patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for error assertions\n",".agents/skills/playwright-best-practices/debugging/flaky-tests.md":"# Debugging and Managing Flaky Tests\n\n## Table of Contents\n\n1. [Understanding Flakiness Types](#understanding-flakiness-types)\n2. [Detection and Reproduction](#detection-and-reproduction)\n3. [Root Cause Analysis](#root-cause-analysis)\n4. [Fixing Strategies by Type](#fixing-strategies-by-type)\n5. [CI-Specific Flakiness](#ci-specific-flakiness)\n6. [Quarantine and Management](#quarantine-and-management)\n7. [Prevention Strategies](#prevention-strategies)\n\n## Understanding Flakiness Types\n\n### Categories of Flakiness\n\nMost flaky tests fall into distinct categories requiring different remediation:\n\n| Category                    | Symptoms                        | Common Causes                                          |\n| --------------------------- | ------------------------------- | ------------------------------------------------------ |\n| **UI-driven**               | Element not found, click missed | Missing waits, animations, dynamic rendering           |\n| **Environment-driven**      | CI-only failures                | Slower CPU, memory limits, cold browser starts         |\n| **Data/parallelism-driven** | Fails with multiple workers     | Shared backend data, reused accounts, state collisions |\n| **Test-suite-driven**       | Fails when run with other tests | Leaked state, shared fixtures, order dependencies      |\n\n### Flakiness Decision Tree\n\n```\nTest fails intermittently\n├─ Fails locally too?\n│  ├─ YES → Timing/async issue → Check waits and assertions\n│  └─ NO → CI-specific → Check environment differences\n│\n├─ Fails only with multiple workers?\n│  └─ YES → Parallelism issue → Check data isolation\n│\n├─ Fails only when run after specific tests?\n│  └─ YES → State leak → Check fixtures and cleanup\n│\n└─ Fails randomly regardless of conditions?\n   └─ External dependency → Check network/API stability\n```\n\n## Detection and Reproduction\n\n### Confirming Flakiness\n\n```bash\n# Run test multiple times to confirm instability\nnpx playwright test tests/checkout.spec.ts --repeat-each=20\n\n# Run with single worker to isolate parallelism issues\nnpx playwright test --workers=1\n\n# Run in CI-like conditions locally\nCI=true npx playwright test --repeat-each=10\n```\n\n### Reproduction Strategies\n\n```typescript\n// playwright.config.ts - Enable artifacts for flaky test investigation\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0,\n  use: {\n    trace: 'on-first-retry', // Capture trace on retry\n    video: 'retain-on-failure',\n    screenshot: 'only-on-failure',\n  },\n})\n```\n\n### Identify Flaky Tests Programmatically\n\n```typescript\n// Track test results across runs\ntest.afterEach(async ({}, testInfo) => {\n  if (testInfo.retry > 0 && testInfo.status === 'passed') {\n    console.warn(`FLAKY: ${testInfo.title} passed on retry ${testInfo.retry}`)\n    // Log to your tracking system\n  }\n})\n```\n\n## Root Cause Analysis\n\n### Event Logging for Race Conditions\n\nAdd comprehensive event logging to expose timing issues:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  page.on('console', (msg) => console.log(`CONSOLE [${msg.type()}]:`, msg.text()))\n  page.on('pageerror', (err) => console.error('PAGE ERROR:', err.message))\n  page.on('requestfailed', (req) => console.error(`REQUEST FAILED: ${req.url()}`))\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Network Timing Analysis\n\n```typescript\n// Capture slow or failed requests\ntest.beforeEach(async ({page}) => {\n  const slowRequests: string[] = []\n\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const duration = timing.responseEnd - timing.requestStart\n    if (duration > 2000) {\n      slowRequests.push(`${request.url()} took ${duration}ms`)\n    }\n  })\n\n  page.on('requestfailed', (request) => {\n    console.error(`Failed: ${request.url()} - ${request.failure()?.errorText}`)\n  })\n})\n```\n\n### Trace Analysis\n\n```bash\n# View trace from failed CI run\nnpx playwright show-trace path/to/trace.zip\n\n# Generate trace for specific test\nnpx playwright test tests/flaky.spec.ts --trace on\n```\n\n## Fixing Strategies by Type\n\n### UI-Driven Flakiness\n\n**Problem: Element not ready when action executes**\n\n```typescript\n// ❌ BAD: No wait for element state\nawait page.click('#submit')\nawait page.fill('#username', 'test') // Element may not be ready\n\n// ✅ GOOD: Actions + assertions pattern (auto-waiting built-in)\nawait page.getByRole('button', {name: 'Submit'}).click()\nawait expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n```\n\n**Problem: Animations or transitions interfere**\n\n```typescript\n// ❌ BAD: Click during animation\nawait page.click('.menu-item')\n\n// ✅ GOOD: Wait for animation to complete\nawait page.getByRole('menuitem', {name: 'Settings'}).click()\nawait expect(page.getByRole('dialog')).toBeVisible()\n// Or disable animations entirely\nawait page.emulateMedia({reducedMotion: 'reduce'})\n```\n\n**Problem: Brittle selectors**\n\n```typescript\n// ❌ BAD: Fragile CSS chain\nawait page.click('div.container > div:nth-child(2) > button.btn-primary')\n\n// ✅ GOOD: Semantic selectors\nawait page.getByRole('button', {name: 'Continue'}).click()\nawait page.getByTestId('checkout-button').click()\nawait page.getByLabel('Email address').fill('test@example.com')\n```\n\n### Async/Timing Flakiness\n\n**Problem: Race between test and application**\n\n```typescript\n// ❌ BAD: Arbitrary sleep\nawait page.click('#load-data')\nawait page.waitForTimeout(3000) // Hope data loads in 3s\n\n// ✅ GOOD: Wait for specific condition\nawait page.click('#load-data')\nawait expect(page.locator('.data-row')).toHaveCount(10, {timeout: 10000})\n\n// ✅ BETTER: Wait for network response, then assert\nconst responsePromise = page.waitForResponse(\n  (r) => r.url().includes('/api/data') && r.request().method() === 'GET' && r.ok(),\n)\nawait page.click('#load-data')\nawait responsePromise\nawait expect(page.locator('.data-row')).toHaveCount(10)\n```\n\n> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n**Problem: Complex async state**\n\n```typescript\n// Custom wait for application-specific conditions\nawait page.waitForFunction(() => {\n  const app = (window as any).__APP_STATE__\n  return app?.isReady && !app?.isLoading\n})\n\n// Wait for multiple conditions\nawait Promise.all([\n  page.waitForResponse('**/api/user'),\n  page.waitForResponse('**/api/settings'),\n  page.getByRole('button', {name: 'Load'}).click(),\n])\n```\n\n### Data/Parallelism-Driven Flakiness\n\n**Problem: Tests share backend data**\n\n```typescript\n// ❌ BAD: All workers use same user\nconst testUser = {email: 'test@example.com', password: 'pass123'}\n\n// ✅ GOOD: Unique data per worker\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend<{}, {testUser: {email: string; id: string}}>({\n  testUser: [\n    async ({}, use, workerInfo) => {\n      const email = `test-${workerInfo.workerIndex}-${Date.now()}@example.com`\n      const user = await createTestUser(email)\n      await use(user)\n      await deleteTestUser(user.id)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n**Problem: Shared storageState across workers**\n\n```typescript\n// ❌ BAD: All workers share same auth state\nuse: {\n  storageState: '.auth/user.json',\n}\n\n// ✅ GOOD: Per-worker auth state\nexport const test = base.extend<{}, { workerStorageState: string }>({\n  workerStorageState: [\n    async ({ browser }, use, workerInfo) => {\n      const id = workerInfo.workerIndex;\n      const fileName = `.auth/user-${id}.json`;\n\n      if (!fs.existsSync(fileName)) {\n        const page = await browser.newPage({ storageState: undefined });\n        await authenticateUser(page, `worker${id}@test.com`);\n        await page.context().storageState({ path: fileName });\n        await page.close();\n      }\n\n      await use(fileName);\n    },\n    { scope: \"worker\" },\n  ],\n});\n```\n\n### Test-Suite-Driven Flakiness (State Leaks)\n\n**Problem: Tests affect each other**\n\n```typescript\n// ❌ BAD: Module-level state persists across tests\nlet sharedPage: Page\n\ntest.beforeAll(async ({browser}) => {\n  sharedPage = await browser.newPage() // Shared across tests!\n})\n\n// ✅ GOOD: Use Playwright's default isolation (fresh context per test)\ntest('first test', async ({page}) => {\n  // Fresh page for this test\n})\n\ntest('second test', async ({page}) => {\n  // Fresh page for this test\n})\n```\n\n**Problem: Fixture cleanup not happening**\n\n```typescript\n// ✅ GOOD: Proper fixture with cleanup\nexport const test = base.extend<{tempFile: string}>({\n  tempFile: async ({}, use) => {\n    const file = `/tmp/test-${Date.now()}.json`\n    fs.writeFileSync(file, '{}')\n\n    await use(file)\n\n    // Cleanup always runs, even on failure\n    if (fs.existsSync(file)) {\n      fs.unlinkSync(file)\n    }\n  },\n})\n```\n\n## CI-Specific Flakiness\n\n### Why Tests Fail Only in CI\n\n| CI Condition       | Impact                                | Solution                                             |\n| ------------------ | ------------------------------------- | ---------------------------------------------------- |\n| Slower CPU         | Actions complete later than expected  | Use auto-waiting, not timeouts                       |\n| Cold browser start | No cached assets, slower initial load | Add explicit waits for first navigation              |\n| Headless mode      | Different rendering behavior          | Test locally in headless mode                        |\n| Shared runners     | Resource contention                   | Reduce parallelism or use dedicated runners          |\n| Network latency    | API calls slower                      | Mock external APIs, increase timeouts for real calls |\n\n### Simulating CI Locally\n\n```bash\n# Run headless with CI environment variable\nCI=true npx playwright test\n\n# Limit CPU (Linux/Mac)\ncpulimit -l 50 -- npx playwright test\n\n# Run in Docker matching CI environment\ndocker run -it --rm \\\n  -v $(pwd):/work \\\n  -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### Consistent Viewport and Scale\n\n```typescript\n// playwright.config.ts - Match CI rendering exactly\nexport default defineConfig({\n  use: {\n    viewport: {width: 1280, height: 720},\n    deviceScaleFactor: 1,\n  },\n})\n```\n\n### Network Stubbing for External APIs\n\n```typescript\n// Eliminate external API flakiness\ntest.beforeEach(async ({page}) => {\n  // Stub unstable third-party APIs\n  await page.route('**/api.analytics.com/**', (route) => route.fulfill({body: ''}))\n  await page.route('**/api.payment-provider.com/**', (route) =>\n    route.fulfill({json: {status: 'ok'}}),\n  )\n})\n\n// Test-specific stub\ntest('checkout with payment', async ({page}) => {\n  await page.route('**/api/payment', (route) =>\n    route.fulfill({json: {success: true, transactionId: 'test-123'}}),\n  )\n  // Test proceeds with deterministic response\n})\n```\n\n## Quarantine and Management\n\n### Quarantine Pattern\n\n```typescript\n// playwright.config.ts - Separate flaky tests\nexport default defineConfig({\n  projects: [\n    {\n      name: 'stable',\n      testIgnore: ['**/*.flaky.spec.ts'],\n    },\n    {\n      name: 'quarantine',\n      testMatch: ['**/*.flaky.spec.ts'],\n      retries: 3,\n    },\n  ],\n})\n```\n\n### Annotation-Based Quarantine\n\n```typescript\n// Mark flaky tests with annotations\ntest('intermittent checkout issue', async ({page}, testInfo) => {\n  testInfo.annotations.push({\n    type: 'flaky',\n    description: 'Investigating payment API timing - JIRA-1234',\n  })\n\n  // Test implementation\n})\n\n// Skip flaky test conditionally\ntest('known CI flaky', async ({page}) => {\n  test.skip(!!process.env.CI, 'Flaky in CI - investigating JIRA-5678')\n  // Test implementation\n})\n```\n\n## Prevention Strategies\n\n### Test Burn-In\n\n```bash\n# Run new tests many times before merging\nnpx playwright test tests/new-feature.spec.ts --repeat-each=50\n\n# Run in parallel to expose race conditions\nnpx playwright test tests/new-feature.spec.ts --repeat-each=20 --workers=4\n```\n\n### Isolation Checklist\n\n```typescript\n// ✅ Each test should be self-contained\ntest.describe('User profile', () => {\n  test('can update name', async ({page, testUser}) => {\n    // Uses unique testUser fixture\n    // No dependency on other tests\n    // Cleanup handled by fixture\n  })\n\n  test('can update email', async ({page, testUser}) => {\n    // Independent of \"can update name\"\n    // Own testUser, own state\n  })\n})\n```\n\n### Defensive Assertions\n\n```typescript\n// ❌ BAD: Single point of failure\nawait expect(page.locator('.items')).toHaveCount(5)\n\n// ✅ GOOD: Progressive assertions that help diagnose\nawait expect(page.locator('.items-container')).toBeVisible()\nawait expect(page.locator('.loading')).not.toBeVisible()\nawait expect(page.locator('.items')).toHaveCount(5)\n```\n\n### Retry Budget\n\n```typescript\n// playwright.config.ts - Limit retries to avoid masking issues\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0, // Only retry in CI\n  expect: {\n    timeout: 10000, // Reasonable assertion timeout\n  },\n  timeout: 60000, // Test timeout\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                             | Solution                                       |\n| ----------------------------------------- | ----------------------------------- | ---------------------------------------------- |\n| `waitForTimeout()` as primary wait        | Arbitrary, hides real timing issues | Use auto-waiting assertions                    |\n| Increasing global timeout to \"fix\" flakes | Masks root cause, slows all tests   | Find and fix actual timing issue               |\n| Retrying until pass                       | Hides systemic problems             | Fix root cause, use retries for diagnosis only |\n| Shared test data across workers           | Race conditions, collisions         | Isolate data per worker                        |\n| Testing real external APIs                | Network variability                 | Mock external dependencies                     |\n| Module-level mutable state                | Leaks between tests                 | Use fixtures with proper cleanup               |\n| Ignoring flaky tests                      | Problem compounds over time         | Quarantine and track for fixing                |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for trace viewer and inspector\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped isolation\n- **Performance**: See [performance.md](../infrastructure-ci-cd/performance.md) for parallel execution patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for auto-waiting patterns\n- **Global Setup**: See [global-setup.md](../core/global-setup.md) for setup vs fixtures decision\n",".agents/skills/playwright-best-practices/frameworks/angular.md":"# Angular Testing with Playwright\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [Locator Strategies](#locator-strategies)\n3. [Reactive Forms](#reactive-forms)\n4. [Angular Material Components](#angular-material-components)\n5. [Router Navigation](#router-navigation)\n6. [Lazy-Loaded Modules](#lazy-loaded-modules)\n7. [Signals and Observables](#signals-and-observables)\n8. [Zone.js and Change Detection](#zonejs-and-change-detection)\n9. [SSR Testing](#ssr-testing)\n10. [Protractor Migration Reference](#protractor-migration-reference)\n11. [Build Configurations](#build-configurations)\n12. [CDK Overlay Container](#cdk-overlay-container)\n13. [Anti-Patterns](#anti-patterns)\n14. [Related](#related)\n\n> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.\n> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)\n\n## Configuration\n\n### Playwright Config\n\n```typescript\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:4200',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI\n      ? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'\n      : 'npx ng serve',\n    url: 'http://localhost:4200',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Project Structure\n\n```text\nmy-angular-app/\n  src/\n  e2e/\n    tests/\n      dashboard.spec.ts\n      login.spec.ts\n    fixtures/\n      auth.fixture.ts\n  playwright.config.ts\n  angular.json\n```\n\n### Package Scripts\n\n```json\n{\n  \"scripts\": {\n    \"e2e\": \"playwright test\",\n    \"e2e:headed\": \"playwright test --headed\",\n    \"e2e:debug\": \"playwright test --debug\",\n    \"e2e:report\": \"playwright show-report\"\n  }\n}\n```\n\n## Locator Strategies\n\nAngular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.\n\n```typescript\ntest('use semantic locators for Angular apps', async ({page}) => {\n  await page.goto('/projects')\n\n  // Role-based locators work with Angular Material and native HTML\n  await page.getByRole('button', {name: 'New project'}).click()\n  await expect(page.getByRole('heading', {name: 'Create Project'})).toBeVisible()\n\n  // Label-based for form fields\n  await page.getByLabel('Project title').fill('Alpha')\n\n  // Test IDs for complex components without semantic roles\n  const chart = page.getByTestId('metrics-chart')\n  await expect(chart).toBeVisible()\n\n  // Scope locators within component boundaries\n  const projectTable = page.getByRole('table', {name: 'Projects'})\n  const activeRow = projectTable.getByRole('row').filter({\n    has: page.getByRole('cell', {name: 'Active'}),\n  })\n  await activeRow.getByRole('button', {name: 'Edit'}).click()\n})\n```\n\n## Reactive Forms\n\nPlaywright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.\n\n```typescript\ntest.describe('form validation', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('displays validation errors on blur', async ({page}) => {\n    const emailField = page.getByLabel('Email')\n    await emailField.click()\n    await emailField.blur()\n    await expect(page.getByText('Email is required')).toBeVisible()\n\n    await emailField.fill('invalid')\n    await emailField.blur()\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('validates password confirmation', async ({page}) => {\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Mismatch')\n    await page.getByLabel('Confirm password').blur()\n\n    await expect(page.getByText('Passwords must match')).toBeVisible()\n\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await expect(page.getByText('Passwords must match')).toBeHidden()\n  })\n\n  test('handles FormArray add/remove', async ({page}) => {\n    await page.goto('/contacts/edit')\n\n    await page.getByRole('button', {name: 'Add email'}).click()\n    const emailInputs = page.getByLabel(/Email address/)\n    await expect(emailInputs).toHaveCount(2)\n\n    await emailInputs.nth(1).fill('backup@example.com')\n    await page.getByRole('button', {name: 'Remove email 1'}).click()\n\n    await expect(emailInputs).toHaveCount(1)\n    await expect(emailInputs.first()).toHaveValue('backup@example.com')\n  })\n\n  test('disables submit until form is valid', async ({page}) => {\n    const submitBtn = page.getByRole('button', {name: 'Register'})\n    await expect(submitBtn).toBeDisabled()\n\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await page.getByLabel('Accept terms').check()\n\n    await expect(submitBtn).toBeEnabled()\n  })\n\n  test('shows async validator loading state', async ({page}) => {\n    await page.route('**/api/username-check*', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({json: {available: true}})\n    })\n\n    await page.getByLabel('Username').fill('alice')\n    await page.getByLabel('Username').blur()\n\n    await expect(page.getByTestId('username-loading')).toBeVisible()\n    await expect(page.getByTestId('username-loading')).toBeHidden()\n    await expect(page.getByText('Username available')).toBeVisible()\n  })\n})\n```\n\n## Angular Material Components\n\nAngular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.\n\n```typescript\ntest.describe('Material components', () => {\n  test('mat-select dropdown', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Language'}).click()\n    await page.getByRole('option', {name: 'Spanish'}).click()\n\n    await expect(page.getByRole('combobox', {name: 'Language'})).toContainText('Spanish')\n  })\n\n  test('mat-autocomplete suggestions', async ({page}) => {\n    await page.goto('/members/add')\n\n    const roleField = page.getByRole('combobox', {name: 'Role'})\n    await roleField.fill('dev')\n\n    await expect(page.getByRole('option', {name: 'Developer'})).toBeVisible()\n    await expect(page.getByRole('option', {name: 'DevOps'})).toBeVisible()\n\n    await page.getByRole('option', {name: 'Developer'}).click()\n    await expect(roleField).toHaveValue('Developer')\n  })\n\n  test('mat-dialog interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove item'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByText('Confirm deletion?')).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('mat-table sorting', async ({page}) => {\n    await page.goto('/members')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    const header = page.getByRole('columnheader', {name: 'Name'})\n    await expect(header).toHaveAttribute('aria-sort', 'ascending')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    await expect(header).toHaveAttribute('aria-sort', 'descending')\n  })\n\n  test('mat-paginator navigation', async ({page}) => {\n    await page.goto('/members')\n\n    await expect(page.getByText('1 - 10 of 100')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next page'}).click()\n    await expect(page.getByText('11 - 20 of 100')).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Items per page'}).click()\n    await page.getByRole('option', {name: '50'}).click()\n    await expect(page.getByText('1 - 50 of 100')).toBeVisible()\n  })\n\n  test('mat-snack-bar notification', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Changes saved')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Close'}).click()\n    await expect(page.getByText('Changes saved')).toBeHidden()\n  })\n\n  test('mat-stepper wizard', async ({page}) => {\n    await page.goto('/wizard')\n\n    await expect(page.getByText('Step 1 of 3')).toBeVisible()\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 2 of 3')).toBeVisible()\n    await page.getByLabel('Organization').fill('Acme')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 3 of 3')).toBeVisible()\n    await expect(page.getByText('Bob')).toBeVisible()\n    await expect(page.getByText('Acme')).toBeVisible()\n  })\n})\n```\n\n## Router Navigation\n\n```typescript\ntest.describe('Angular Router', () => {\n  test('lazy-loaded module loads on navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.getByRole('link', {name: 'Reports'}).click()\n    await page.waitForURL('/reports')\n\n    await expect(page.getByRole('heading', {name: 'Reports Dashboard'})).toBeVisible()\n  })\n\n  test('route guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/settings')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n  })\n\n  test('resolver prefetches data', async ({page}) => {\n    const resolverPromise = page.waitForResponse('**/api/items/*')\n    await page.goto('/items/42')\n    await resolverPromise\n\n    await expect(page.getByRole('heading', {level: 1})).toContainText('Item')\n  })\n\n  test('nested router-outlet renders children', async ({page}) => {\n    await page.goto('/account/profile')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Profile', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Security'}).click()\n    await page.waitForURL('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n  })\n\n  test('query parameters drive filters', async ({page}) => {\n    await page.goto('/products?type=hardware&page=3')\n\n    await expect(page.getByRole('heading', {name: 'Hardware'})).toBeVisible()\n    await expect(page.getByText('Page 3')).toBeVisible()\n  })\n\n  test('browser back navigates history', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Products'}).click()\n    await page.waitForURL('/products')\n    await page.getByRole('link', {name: 'About'}).click()\n    await page.waitForURL('/about')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/products/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n})\n```\n\n## Lazy-Loaded Modules\n\n```typescript\ntest('lazy module loads without chunk errors', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') consoleErrors.push(msg.text())\n  })\n\n  await page.goto('/')\n\n  const chunkRequest = page.waitForResponse((r) => r.url().includes('.js') && r.status() === 200)\n  await page.getByRole('link', {name: 'Analytics'}).click()\n  await chunkRequest\n\n  await page.waitForURL('/analytics')\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n\n  const chunkErrors = consoleErrors.filter(\n    (e) => e.includes('ChunkLoadError') || e.includes('Loading chunk'),\n  )\n  expect(chunkErrors).toEqual([])\n})\n```\n\n## Signals and Observables\n\nPlaywright cannot subscribe to observables or read signals directly. Test through the rendered output.\n\n```typescript\ntest.describe('signals through UI', () => {\n  test('signal-based counter updates DOM', async ({page}) => {\n    await page.goto('/counter')\n\n    await expect(page.getByTestId('value')).toHaveText('0')\n\n    await page.getByRole('button', {name: 'Increment'}).click()\n    await expect(page.getByTestId('value')).toHaveText('1')\n\n    await page.getByRole('button', {name: 'Reset'}).click()\n    await expect(page.getByTestId('value')).toHaveText('0')\n  })\n\n  test('computed signal updates derived values', async ({page}) => {\n    await page.goto('/cart')\n    await expect(page.getByTestId('total')).toHaveText('$0.00')\n\n    await page.goto('/catalog')\n    await page\n      .getByRole('listitem')\n      .filter({hasText: '$19.99'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await expect(page.getByTestId('total')).toHaveText('$19.99')\n  })\n})\n\ntest.describe('observables through UI', () => {\n  test('debounced search batches API calls', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/search*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('playwright', {\n      delay: 50,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(5)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n\n  test('switchMap cancels stale requests', async ({page}) => {\n    await page.goto('/search')\n\n    await page.getByRole('textbox', {name: 'Search'}).fill('initial')\n    await page.getByRole('textbox', {name: 'Search'}).fill('final')\n\n    await expect(page.getByRole('listitem').first()).toContainText(/final/i)\n  })\n})\n```\n\n## Zone.js and Change Detection\n\nAngular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.\n\n- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.\n- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.\n- **Long-running async**: `setInterval` or long-running observables keep Angular \"not stable.\" This does not affect Playwright (unlike Protractor).\n\n## SSR Testing\n\n```typescript\n// playwright.config.ts for SSR\nwebServer: {\n  command: process.env.CI\n    ? 'npx ng build --ssr && node dist/my-app/server/server.mjs'\n    : 'npx ng serve --ssr',\n  url: 'http://localhost:4200',\n  reuseExistingServer: !process.env.CI,\n  timeout: 180_000,\n},\n```\n\n```typescript\ntest('no hydration errors', async ({page}) => {\n  const errors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error' && msg.text().includes('hydration')) {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  expect(errors).toEqual([])\n})\n```\n\n## Protractor Migration Reference\n\n| Protractor                              | Playwright                                     |\n| --------------------------------------- | ---------------------------------------------- |\n| `element(by.css('.btn'))`               | `page.getByRole('button', { name: '...' })`    |\n| `element(by.id('login'))`               | `page.getByTestId('login')`                    |\n| `element(by.buttonText('Submit'))`      | `page.getByRole('button', { name: 'Submit' })` |\n| `element(by.model('user.name'))`        | `page.getByLabel('Name')`                      |\n| `element(by.binding('user.name'))`      | `page.getByText(expectedValue)`                |\n| `element(by.repeater('item in items'))` | `page.getByRole('listitem')`                   |\n| `browser.waitForAngular()`              | Not needed — Playwright auto-waits             |\n| `browser.sleep(3000)`                   | `await expect(locator).toBeVisible()`          |\n| `browser.get('/path')`                  | `await page.goto('/path')`                     |\n| `protractor.ExpectedConditions`         | `await expect(locator).toBeVisible()`          |\n\n## Build Configurations\n\n| Scenario      | Command                                                       | Notes                          |\n| ------------- | ------------------------------------------------------------- | ------------------------------ |\n| Local dev     | `npx ng serve`                                                | Fast rebuild, source maps      |\n| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle        |\n| CI SSR        | `npx ng build --ssr && node dist/app/server/server.mjs`       | Tests server-side rendering    |\n| Staging       | No `webServer`                                                | Point `baseURL` to staging URL |\n\nThe `-s` flag on `http-server` enables SPA fallback for Angular Router.\n\n## CDK Overlay Container\n\nAngular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:\n\n```typescript\nconst dialog = page.getByRole('dialog')\nconst menu = page.getByRole('menu')\nconst listbox = page.getByRole('listbox')\n```\n\n## Anti-Patterns\n\n| Anti-Pattern                         | Problem                                        | Solution                                           |\n| ------------------------------------ | ---------------------------------------------- | -------------------------------------------------- |\n| `page.locator('[_ngcontent-xyz]')`   | Scoped style attributes change every build     | Use `getByRole`, `getByLabel`, `getByTestId`       |\n| `page.locator('[ng-reflect-model]')` | Only exists in dev mode                        | Test rendered value: `expect(input).toHaveValue()` |\n| `page.locator('app-my-component')`   | Component selectors are implementation details | Target rendered content with semantic locators     |\n| `page.locator('.mat-mdc-button')`    | Material classes change between versions       | `page.getByRole('button', { name: '...' })`        |\n| `page.evaluate(() => window.ng)`     | Not available in production builds             | Test through the DOM                               |\n| `await page.waitForTimeout(500)`     | Zone.js timing varies                          | Use auto-retrying assertions                       |\n| `browser.waitForAngular()`           | Does not exist in Playwright                   | Remove entirely                                    |\n| `ng serve` in CI                     | Slower, includes debug code                    | Use `ng build && http-server`                      |\n\n## Related\n\n- [core/locators.md](../core/locators.md) — locator strategies for Angular Material\n- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions\n- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed\n",".agents/skills/playwright-best-practices/frameworks/nextjs.md":"# Next.js Testing Patterns\n\n## Table of Contents\n\n1. [Setup](#setup)\n2. [App Router Patterns](#app-router-patterns)\n3. [Pages Router Patterns](#pages-router-patterns)\n4. [Dynamic Routes](#dynamic-routes)\n5. [API Routes](#api-routes)\n6. [Middleware Testing](#middleware-testing)\n7. [Hydration Testing](#hydration-testing)\n8. [next/image Testing](#nextimage-testing)\n9. [NextAuth.js Authentication](#nextauthjs-authentication)\n10. [Tips](#tips)\n11. [Anti-Patterns](#anti-patterns)\n12. [Related](#related)\n\n> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Setup\n\n### Configuration with webServer\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: process.env.CI ? 'production' : 'test',\n    },\n  },\n})\n```\n\n### Environment Variables\n\nNext.js loads `.env.test` when `NODE_ENV=test`:\n\n```bash\n# .env.test (commit this)\nNEXT_PUBLIC_API_URL=http://localhost:3000/api\nDATABASE_URL=postgresql://localhost:5432/test_db\n\n# .env.test.local (gitignored)\nNEXTAUTH_SECRET=test-secret-local\n```\n\n## App Router Patterns\n\n### Server Component Content\n\n```typescript\ntest('renders server component content', async ({page}) => {\n  await page.goto('/')\n\n  await expect(page.getByRole('heading', {name: 'Welcome', level: 1})).toBeVisible()\n  await expect(page.getByRole('navigation', {name: 'Main'})).toBeVisible()\n})\n```\n\n### Loading States with Streaming\n\n```typescript\ntest('loading state during data streaming', async ({page}) => {\n  await page.route('**/api/stats', async (route) => {\n    await new Promise((r) => setTimeout(r, 2000))\n    await route.continue()\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('progressbar')).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByRole('progressbar')).toBeHidden()\n})\n```\n\n### Nested Layouts\n\n```typescript\ntest('layouts persist across navigation', async ({page}) => {\n  await page.goto('/dashboard/analytics')\n\n  const sidebar = page.getByRole('navigation', {name: 'Dashboard'})\n  await expect(sidebar).toBeVisible()\n\n  await sidebar.getByRole('link', {name: 'Settings'}).click()\n  await page.waitForURL('/dashboard/settings')\n\n  await expect(sidebar).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible()\n})\n```\n\n## Pages Router Patterns\n\n### SSR with getServerSideProps\n\n```typescript\ntest('page with getServerSideProps renders data', async ({page}) => {\n  await page.goto('/blog')\n\n  await expect(page.getByRole('heading', {name: 'Blog', level: 1})).toBeVisible()\n  await expect(page.getByRole('article')).toHaveCount(10)\n  await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n})\n```\n\n### Static Generation with getStaticProps\n\n```typescript\ntest('static page shows pre-rendered content', async ({page}) => {\n  await page.goto('/about')\n\n  await expect(page.getByRole('heading', {name: 'About Us'})).toBeVisible()\n  await expect(page.getByText('Founded in 2020')).toBeVisible()\n})\n```\n\n## Dynamic Routes\n\n### Slug Parameters\n\n```typescript\ntest('dynamic [slug] renders correct content', async ({page}) => {\n  await page.goto('/blog/testing-guide')\n\n  await expect(page.getByRole('heading', {level: 1})).toContainText('Testing Guide')\n  await expect(page.getByText('Page not found')).toBeHidden()\n})\n\ntest('non-existent slug shows 404', async ({page}) => {\n  const response = await page.goto('/blog/nonexistent-post')\n\n  expect(response?.status()).toBe(404)\n  await expect(page.getByRole('heading', {name: '404'})).toBeVisible()\n})\n```\n\n### Catch-All Routes\n\n```typescript\ntest('catch-all handles nested paths', async ({page}) => {\n  await page.goto('/docs/getting-started/installation')\n  await expect(page.getByRole('heading', {name: 'Installation'})).toBeVisible()\n\n  await page.goto('/docs/api/configuration')\n  await expect(page.getByRole('heading', {name: 'Configuration'})).toBeVisible()\n})\n```\n\n### Query Parameters\n\n```typescript\ntest('query parameters filter content', async ({page}) => {\n  await page.goto('/products?category=electronics&sort=price-asc')\n\n  await expect(page.getByRole('heading', {name: 'Electronics'})).toBeVisible()\n\n  const prices = await page.getByTestId('product-price').allTextContents()\n  const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')))\n  expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))\n})\n```\n\n## API Routes\n\n### Direct API Testing\n\n```typescript\ntest('GET /api/products returns list', async ({request}) => {\n  const response = await request.get('/api/products')\n\n  expect(response.ok()).toBeTruthy()\n  const body = await response.json()\n  expect(body.products).toBeInstanceOf(Array)\n  expect(body.products[0]).toHaveProperty('id')\n  expect(body.products[0]).toHaveProperty('name')\n})\n\ntest('POST /api/products creates item', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: 'Test Product', price: 29.99},\n  })\n\n  expect(response.status()).toBe(201)\n  const body = await response.json()\n  expect(body.product.name).toBe('Test Product')\n})\n\ntest('POST /api/products validates fields', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: ''},\n  })\n\n  expect(response.status()).toBe(400)\n  const body = await response.json()\n  expect(body.error).toContainEqual(expect.objectContaining({field: 'price'}))\n})\n```\n\n### API Through UI\n\n```typescript\ntest('form submission calls API', async ({page}) => {\n  await page.goto('/products/new')\n\n  await page.getByLabel('Product name').fill('Widget')\n  await page.getByLabel('Price').fill('19.99')\n  await page.getByRole('button', {name: 'Create product'}).click()\n\n  await expect(page.getByText('Product created successfully')).toBeVisible()\n  await page.waitForURL('/products/**')\n})\n```\n\n## Middleware Testing\n\n### Auth Redirects\n\n```typescript\ntest('unauthenticated user redirected to login', async ({page}) => {\n  await page.goto('/dashboard')\n\n  expect(page.url()).toContain('/login')\n  await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n})\n\ntest('redirect preserves return URL', async ({page}) => {\n  await page.goto('/dashboard/settings')\n\n  const url = new URL(page.url())\n  expect(url.pathname).toBe('/login')\n  expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo')).toContain(\n    '/dashboard/settings',\n  )\n})\n```\n\n### Security Headers\n\n```typescript\ntest('middleware sets security headers', async ({page}) => {\n  const response = await page.goto('/')\n\n  const headers = response!.headers()\n  expect(headers['x-frame-options']).toBe('DENY')\n  expect(headers['x-content-type-options']).toBe('nosniff')\n})\n```\n\n### Locale Rewrites\n\n```typescript\ntest('middleware rewrites based on locale', async ({page, context}) => {\n  await context.setExtraHTTPHeaders({\n    'Accept-Language': 'fr-FR,fr;q=0.9',\n  })\n\n  await page.goto('/')\n\n  await expect(page.getByText('Bienvenue')).toBeVisible()\n})\n```\n\n## Hydration Testing\n\n### Console Error Detection\n\n```typescript\ntest('no hydration errors in console', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      consoleErrors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  const hydrationErrors = consoleErrors.filter(\n    (e) => e.includes('Hydration') || e.includes('hydration') || e.includes('did not match'),\n  )\n  expect(hydrationErrors).toEqual([])\n})\n```\n\n### Interactive Elements After Hydration\n\n```typescript\ntest('interactive elements work after hydration', async ({page}) => {\n  await page.goto('/')\n\n  const counter = page.getByTestId('counter-value')\n  await expect(counter).toHaveText('0')\n\n  await page.getByRole('button', {name: 'Increment'}).click()\n  await expect(counter).toHaveText('1')\n})\n```\n\n## next/image Testing\n\n```typescript\ntest('hero image loads with srcset', async ({page}) => {\n  await page.goto('/')\n\n  const heroImage = page.getByRole('img', {name: 'Hero banner'})\n  await expect(heroImage).toBeVisible()\n\n  const srcset = await heroImage.getAttribute('srcset')\n  expect(srcset).toBeTruthy()\n  expect(srcset).toContain('w=')\n\n  const loading = await heroImage.getAttribute('loading')\n  expect(loading).not.toBe('lazy')\n})\n\ntest('offscreen images lazy load', async ({page}) => {\n  await page.goto('/gallery')\n\n  const offscreenImage = page.getByRole('img', {name: 'Gallery item 20'})\n\n  await offscreenImage.scrollIntoViewIfNeeded()\n  await expect(offscreenImage).toBeVisible()\n\n  const naturalWidth = await offscreenImage.evaluate((img: HTMLImageElement) => img.naturalWidth)\n  expect(naturalWidth).toBeGreaterThan(0)\n})\n```\n\n## NextAuth.js Authentication\n\n### Setup Project\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /auth\\.setup\\.ts/},\n    {\n      name: 'authenticated',\n      use: {storageState: 'playwright/.auth/user.json'},\n      dependencies: ['setup'],\n    },\n    {name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts'},\n  ],\n})\n```\n\n### Auth Setup\n\n```typescript\n// tests/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/user.json'\n\nsetup('authenticate via credentials', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await page.waitForURL('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  await page.context().storageState({path: authFile})\n})\n```\n\n### Authenticated Tests\n\n```typescript\ntest('authenticated user sees dashboard', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByText('test@example.com')).toBeVisible()\n})\n```\n\n## Tips\n\n### Dev Server vs Production Build\n\n| Scenario          | Command                          | Trade-off                              |\n| ----------------- | -------------------------------- | -------------------------------------- |\n| Local development | `npm run dev`                    | Fast iteration, no production behavior |\n| CI pipeline       | `npm run build && npm run start` | Tests real production bundle           |\n\n### Turbopack\n\n```typescript\nwebServer: {\n  command: process.env.CI\n    ? 'npm run build && npm run start'\n    : 'npx next dev --turbopack',\n  url: 'http://localhost:3000',\n  reuseExistingServer: !process.env.CI,\n},\n```\n\n### Multiple webServer Entries\n\n```typescript\nwebServer: [\n  {\n    command: 'npm run dev:api',\n    url: 'http://localhost:4000/health',\n    reuseExistingServer: !process.env.CI,\n  },\n  {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n],\n```\n\n## Anti-Patterns\n\n| Don't Do This                               | Problem                             | Do This Instead                                                           |\n| ------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |\n| `await page.waitForTimeout(3000)`           | Arbitrary waits are fragile         | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |\n| Test `getServerSideProps` directly          | Depends on req/res context          | Navigate to page and verify rendered output                               |\n| Mock your own API routes                    | Hides real API bugs                 | Let real API handle requests; mock only external services                 |\n| `page.goto('http://localhost:3000/path')`   | Breaks when port changes            | Use `page.goto('/path')` with `baseURL`                                   |\n| Run `npm run build` locally for every test  | Extremely slow                      | Use `npm run dev` locally with `reuseExistingServer: true`                |\n| Test `next/image` by checking exact URLs    | Paths change between dev/prod       | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset`                 |\n| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons)                                       |\n\n## Related\n\n- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`\n- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`\n- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context\n- [react.md](react.md) -- React patterns for Next.js client components\n",".agents/skills/playwright-best-practices/frameworks/react.md":"# React Application Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Setup](#setup)\n3. [Framework Tips](#framework-tips)\n4. [Anti-Patterns](#anti-patterns)\n5. [Related](#related)\n\n> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Patterns\n\n### Testing Context and Global State\n\n**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.\n**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('theme switching', () => {\n  test('toggle applies dark mode across pages', async ({page}) => {\n    await page.goto('/preferences')\n\n    const root = page.locator('html')\n    await expect(root).not.toHaveClass(/dark-mode/)\n\n    await page.getByRole('switch', {name: 'Enable dark theme'}).click()\n    await expect(root).toHaveClass(/dark-mode/)\n\n    await page.getByRole('link', {name: 'Dashboard'}).click()\n    await expect(page.locator('html')).toHaveClass(/dark-mode/)\n  })\n})\n\ntest.describe('cart state persistence', () => {\n  test('item count updates globally', async ({page}) => {\n    await page.goto('/catalog')\n\n    const badge = page.getByTestId('cart-badge')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Wireless Headphones'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await expect(badge).toHaveText('1')\n  })\n})\n\ntest.describe('auth state', () => {\n  test('login updates header across components', async ({page}) => {\n    await page.goto('/')\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Login'}).click()\n    await page.getByLabel('Username').fill('testuser')\n    await page.getByLabel('Password').fill('secret123')\n    await page.getByRole('button', {name: 'Submit'}).click()\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeHidden()\n    await expect(page.getByText('testuser')).toBeVisible()\n  })\n})\n```\n\n### React Router Navigation\n\n**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.\n**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('client routing', () => {\n  test('navigation preserves SPA state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__spaMarker = 'active'\n    })\n\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n\n    const marker = await page.evaluate(() => (window as any).__spaMarker)\n    expect(marker).toBe('active')\n  })\n\n  test('query params filter content', async ({page}) => {\n    await page.goto('/items?type=books')\n\n    await expect(page.getByRole('heading', {name: 'Books'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Music'}).click()\n    await page.waitForURL('/items?type=music')\n\n    await expect(page.getByRole('heading', {name: 'Music'})).toBeVisible()\n  })\n\n  test('nested routes render layouts', async ({page}) => {\n    await page.goto('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Privacy'}).click()\n    await page.waitForURL('/account/privacy')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Privacy', level: 2})).toBeVisible()\n  })\n\n  test('history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n    await page.getByRole('link', {name: 'Help'}).click()\n    await page.waitForURL('/help')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/inventory/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n\n  test('protected route redirects', async ({page}) => {\n    await page.goto('/admin/users')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('unknown route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-path')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Testing Hooks Through UI\n\n**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.\n**Avoid when**: Hook logic is pure computation—use unit tests instead.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('useDebounce via SearchBox', () => {\n  test('batches rapid input', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/query*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('testing', {\n      delay: 40,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(3)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n})\n\ntest.describe('usePagination via DataGrid', () => {\n  test('page controls work', async ({page}) => {\n    await page.goto('/records')\n\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next'}).click()\n    await expect(page.getByText('Page 2 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Previous'}).click()\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Previous'})).toBeDisabled()\n  })\n})\n```\n\n### Form Libraries (React Hook Form, Formik)\n\n**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('signup form', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('validation on empty submit', async ({page}) => {\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByText('Email required')).toBeVisible()\n    await expect(page.getByText('Password required')).toBeVisible()\n  })\n\n  test('inline validation on blur', async ({page}) => {\n    const email = page.getByLabel('Email')\n    await email.fill('invalid')\n    await email.blur()\n\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('password strength indicator', async ({page}) => {\n    const pwd = page.getByLabel('Password', {exact: true})\n\n    await pwd.fill('weak')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/)\n\n    await pwd.fill('StrongPass1!')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/)\n  })\n\n  test('successful submission redirects', async ({page}) => {\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await page.waitForURL('/welcome')\n    await expect(page.getByText('Hello, Alice')).toBeVisible()\n  })\n\n  test('submit button disabled during request', async ({page}) => {\n    await page.route('**/api/signup', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({status: 201, json: {id: 1}})\n    })\n\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByLabel('Email').fill('bob@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByRole('button', {name: /Registering|Loading/})).toBeDisabled()\n  })\n})\n```\n\n### Portals (Modals, Tooltips, Dropdowns)\n\n**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('portal components', () => {\n  test('modal interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm removal'})\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeFocused()\n\n    await dialog.getByRole('button', {name: 'Remove'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('escape closes modal', async ({page}) => {\n    await page.goto('/items')\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n\n    await page.keyboard.press('Escape')\n    await expect(dialog).toBeHidden()\n  })\n\n  test('tooltip on hover', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Help'}).hover()\n    await expect(page.getByRole('tooltip')).toBeVisible()\n\n    await page.mouse.move(0, 0)\n    await expect(page.getByRole('tooltip')).toBeHidden()\n  })\n\n  test('dropdown menu', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Actions'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await menu.getByRole('menuitem', {name: 'Rename'}).click()\n    await expect(menu).toBeHidden()\n  })\n\n  test('toast auto-dismisses', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Preferences saved')).toBeVisible()\n\n    await expect(page.getByText('Preferences saved')).toBeHidden({timeout: 8000})\n  })\n})\n```\n\n### Error Boundaries\n\n**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.\n**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('error boundary', () => {\n  test('shows fallback on crash', async ({page}) => {\n    await page.route('**/api/widgets', (route) => {\n      route.fulfill({\n        status: 200,\n        json: {widgets: null},\n      })\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n    await expect(page.getByRole('navigation')).toBeVisible()\n  })\n\n  test('retry recovers component', async ({page}) => {\n    let calls = 0\n    await page.route('**/api/widgets', (route) => {\n      calls++\n      if (calls === 1) {\n        route.fulfill({status: 200, json: {widgets: null}})\n      } else {\n        route.fulfill({status: 200, json: {widgets: [{id: 1, name: 'Chart'}]}})\n      }\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Retry'}).click()\n\n    await expect(page.getByText('Something went wrong')).toBeHidden()\n    await expect(page.getByText('Chart')).toBeVisible()\n  })\n})\n```\n\n### Component Testing (Experimental)\n\n**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.\n**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-react'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport { test, expect } from '@playwright/experimental-ct-react';\nimport Stepper from '../../src/components/Stepper';\n\ntest('increments on click', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} />);\n\n  await expect(component.getByText('Value: 0')).toBeVisible();\n  await component.getByRole('button', { name: '+' }).click();\n  await expect(component.getByText('Value: 1')).toBeVisible();\n});\n\ntest('fires onChange callback', async ({ mount }) => {\n  const values: number[] = [];\n  const component = await mount(\n    <Stepper initial={0} onChange={(v) => values.push(v)} />\n  );\n\n  await component.getByRole('button', { name: '+' }).click();\n  await component.getByRole('button', { name: '+' }).click();\n\n  expect(values).toEqual([1, 2]);\n});\n\ntest('respects min boundary', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} min={0} />);\n\n  await expect(component.getByRole('button', { name: '-' })).toBeDisabled();\n});\n```\n\n## Setup\n\n### E2E Config (Vite)\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### CRA vs Vite Differences\n\n| Aspect           | Create React App             | Vite                           |\n| ---------------- | ---------------------------- | ------------------------------ |\n| Default port     | `3000`                       | `5173`                         |\n| Build output     | `build/`                     | `dist/`                        |\n| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |\n| Env var prefix   | `REACT_APP_*`                | `VITE_*`                       |\n\n## Framework Tips\n\n### Strict Mode Double Effects\n\nReact Strict Mode runs effects twice in development. Tests should be resilient:\n\n- Don't assert exact API call counts in dev mode\n- Run against production build for call count assertions, or account for double invocations\n\n### Suspense and Lazy Components\n\n```typescript\ntest('lazy route loads content', async ({page}) => {\n  await page.goto('/')\n\n  await page.getByRole('link', {name: 'Analytics'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n})\n```\n\n### Detecting Memory Leaks\n\n```typescript\ntest('no unmounted state warnings', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('unmounted')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/panel')\n  await page.getByRole('link', {name: 'Settings'}).click()\n  await page.goBack()\n  await page.getByRole('link', {name: 'Profile'}).click()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Don't                                          | Problem                             | Do Instead                                                  |\n| ---------------------------------------------- | ----------------------------------- | ----------------------------------------------------------- |\n| `page.evaluate(() => store.getState())`        | Couples tests to implementation     | Assert on UI: `expect(badge).toHaveText('3')`               |\n| Import components in E2E tests                 | E2E runs in Node, not browser       | Use `@playwright/experimental-ct-react` for components      |\n| `page.waitForTimeout(500)` after state changes | Timing varies across machines       | `expect(locator).toHaveText('value')` auto-retries          |\n| `page.locator('.MuiButton-root')`              | Class names change between versions | `page.getByRole('button', { name: 'Submit' })`              |\n| Test every component with CT                   | Overhead for simple components      | CT for complex widgets, unit tests for logic, E2E for flows |\n| Skip keyboard navigation tests                 | Accessibility regressions common    | Test Tab, Enter, Escape, Arrow interactions                 |\n| Assert on `__REACT_FIBER__` internals          | Not stable across versions          | Only interact with rendered DOM                             |\n\n## Related\n\n- [locators.md](../core/locators.md) — locator strategies for any React component library\n- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes\n- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing\n- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions\n- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR\n",".agents/skills/playwright-best-practices/frameworks/vue.md":"# Vue and Nuxt Testing\n\n## Table of Contents\n\n1. [Commands](#commands)\n2. [Configuration](#configuration)\n3. [Patterns](#patterns)\n4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)\n5. [Component Testing Dependencies](#component-testing-dependencies)\n6. [Testing v-model](#testing-v-model)\n7. [Capturing Vue Warnings](#capturing-vue-warnings)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.\n\n## Commands\n\n```bash\nnpm init playwright@latest\nnpm install -D @playwright/experimental-ct-vue\nnpx playwright test\nnpx playwright test -c playwright-ct.config.ts\n```\n\n## Configuration\n\n### Vue with Vite\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Nuxt 3\n\nNuxt uses port 3000 and requires a build step before testing.\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n\n  webServer: {\n    command: process.env.CI ? 'npx nuxi build && npx nuxi preview' : 'npx nuxi dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',\n    },\n  },\n})\n```\n\n### Component Testing\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-vue'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n## Patterns\n\n### Component Testing with Experimental CT\n\n**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).\n\n**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport {test, expect} from '@playwright/experimental-ct-vue'\n\nimport Stepper from '../../src/components/Stepper.vue'\n\ntest('increments value on button click', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n  })\n\n  await expect(component.getByText('Value: 0')).toBeVisible()\n  await component.getByRole('button', {name: '+'}).click()\n  await expect(component.getByText('Value: 1')).toBeVisible()\n})\n\ntest('emits change event', async ({mount}) => {\n  const changes: number[] = []\n  const component = await mount(Stepper, {\n    props: {value: 10},\n    on: {\n      change: (val: number) => changes.push(val),\n    },\n  })\n\n  await component.getByRole('button', {name: '+'}).click()\n  await component.getByRole('button', {name: '+'}).click()\n\n  expect(changes).toEqual([11, 12])\n})\n\ntest('renders slot content', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n    slots: {\n      default: '<span class=\"label\">Quantity</span>',\n    },\n  })\n\n  await expect(component.getByText('Quantity')).toBeVisible()\n})\n```\n\n### Pinia Store Testing Through UI\n\n**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.\n\n**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('shopping cart store', () => {\n  test('adding products updates cart badge', async ({page}) => {\n    await page.goto('/shop')\n\n    const badge = page.getByTestId('cart-badge')\n    await expect(badge).toHaveText('0')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Cap'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('2')\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.waitForURL('/cart')\n\n    await expect(page.getByText('Hoodie')).toBeVisible()\n    await expect(page.getByText('Cap')).toBeVisible()\n  })\n\n  test('persisted state survives reload', async ({page}) => {\n    await page.goto('/shop')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.reload()\n\n    await expect(page.getByTestId('cart-badge')).toHaveText('1')\n  })\n})\n```\n\n### Vue Router Navigation\n\n**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('router navigation', () => {\n  test('client-side navigation preserves state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('dynamic route params render content', async ({page}) => {\n    await page.goto('/items/99')\n\n    await expect(page.getByRole('heading', {level: 1})).toBeVisible()\n    await expect(page.getByText('Item #99')).toBeVisible()\n  })\n\n  test('navigation guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/dashboard')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Login'})).toBeVisible()\n  })\n\n  test('browser history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await page.waitForURL('/contact')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/shop/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n\n    await page.goForward()\n    await expect(page).toHaveURL(/\\/shop/)\n  })\n\n  test('query params update reactive state', async ({page}) => {\n    await page.goto('/items?sort=price&type=clothing')\n\n    await expect(page.getByRole('heading', {name: 'Clothing'})).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Sort'}).selectOption('name')\n    await expect(page).toHaveURL(/sort=name/)\n  })\n\n  test('catch-all route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-page')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Teleport Components\n\n**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('teleported elements', () => {\n  test('modal is visible and interactive', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm'})\n    await expect(dialog).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('notification auto-dismisses', async ({page}) => {\n    await page.goto('/profile')\n\n    await page.getByRole('button', {name: 'Update'}).click()\n\n    const alert = page.getByRole('alert')\n    await expect(alert).toBeVisible()\n    await expect(alert).toContainText('Saved')\n\n    await expect(alert).toBeHidden({timeout: 10_000})\n  })\n\n  test('dropdown closes on outside click', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('button', {name: 'Menu'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await page.locator('body').click({position: {x: 10, y: 10}})\n    await expect(menu).toBeHidden()\n  })\n})\n```\n\n### Transitions and Animations\n\n**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('transitions', () => {\n  test('item appears after add', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Write tests')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Write tests')).toBeVisible()\n  })\n\n  test('item disappears after delete', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Temp item')\n    await page.getByRole('button', {name: 'Add'}).click()\n    await expect(page.getByText('Temp item')).toBeVisible()\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Temp item'})\n      .getByRole('button', {name: 'Remove'})\n      .click()\n\n    await expect(page.getByText('Temp item')).toBeHidden()\n  })\n\n  test('disable animations for faster tests', async ({page}) => {\n    await page.addStyleTag({\n      content: `\n        *, *::before, *::after {\n          animation-duration: 0s !important;\n          animation-delay: 0s !important;\n          transition-duration: 0s !important;\n          transition-delay: 0s !important;\n        }\n      `,\n    })\n\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Quick task')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Quick task')).toBeVisible()\n  })\n})\n```\n\n### Composition API Components\n\n**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('composition API', () => {\n  test('computed properties update reactively', async ({page}) => {\n    await page.goto('/pricing')\n\n    await page.getByLabel('Amount').fill('50')\n    await page.getByLabel('Qty').fill('4')\n\n    await expect(page.getByTestId('sum')).toHaveText('$200.00')\n\n    await page.getByLabel('Discount').fill('20')\n    await expect(page.getByTestId('sum')).toHaveText('$160.00')\n  })\n\n  test('watcher triggers on change', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Locale'}).selectOption('de')\n\n    await expect(page.getByRole('heading', {name: 'Einstellungen'})).toBeVisible()\n  })\n\n  test('composable provides debounced search', async ({page}) => {\n    await page.goto('/shop')\n\n    const input = page.getByRole('textbox', {name: 'Search'})\n    await input.pressSequentially('hoodie', {delay: 50})\n\n    await expect(page.getByRole('listitem')).toHaveCount(2)\n    await expect(page.getByText('Black Hoodie')).toBeVisible()\n  })\n\n  test('provide/inject updates all consumers', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('switch', {name: 'Dark theme'}).click()\n\n    await expect(page.locator('body')).toHaveClass(/dark/)\n  })\n})\n```\n\n### Nuxt-Specific Patterns\n\n**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('nuxt features', () => {\n  test('SSR renders server-fetched data', async ({page}) => {\n    await page.goto('/posts')\n\n    await expect(page.getByRole('article')).toHaveCount(10)\n    await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n  })\n\n  test('server route returns data', async ({request}) => {\n    const response = await request.get('/api/items')\n\n    expect(response.ok()).toBeTruthy()\n    const data = await response.json()\n    expect(data).toBeInstanceOf(Array)\n    expect(data[0]).toHaveProperty('id')\n  })\n\n  test('middleware redirects unauthorized', async ({page}) => {\n    await page.goto('/admin')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('NuxtLink enables SPA navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Posts'}).click()\n    await page.waitForURL('/posts')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('useHead sets meta tags', async ({page}) => {\n    await page.goto('/posts/hello-world')\n\n    const title = await page.title()\n    expect(title).toContain('Hello World')\n\n    const desc = await page.locator('meta[name=\"description\"]').getAttribute('content')\n    expect(desc).toBeTruthy()\n    expect(desc!.length).toBeGreaterThan(50)\n  })\n})\n```\n\n## Vue vs Nuxt Differences\n\n| Aspect             | Vue 3 (Vite)                        | Nuxt 3                                      |\n| ------------------ | ----------------------------------- | ------------------------------------------- |\n| Default port       | `5173`                              | `3000`                                      |\n| Dev command        | `npm run dev`                       | `npx nuxi dev`                              |\n| Build + preview    | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview`        |\n| SSR                | Optional                            | Built-in                                    |\n| API routes         | External backend                    | `/server/api/` built-in                     |\n| Env variables      | `VITE_*` prefix                     | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |\n| File-based routing | No                                  | Yes                                         |\n\n## Component Testing Dependencies\n\nComponents depending on Pinia or Vue Router need these provided:\n\n```typescript\n// playwright/index.ts\nimport {beforeMount} from '@playwright/experimental-ct-vue/hooks'\nimport {createPinia} from 'pinia'\nimport {createMemoryHistory, createRouter} from 'vue-router'\n\nbeforeMount(async ({app, hooksConfig}) => {\n  const pinia = createPinia()\n  app.use(pinia)\n\n  if (hooksConfig?.routes) {\n    const router = createRouter({\n      history: createMemoryHistory(),\n      routes: hooksConfig.routes,\n    })\n    app.use(router)\n  }\n})\n```\n\n## Testing v-model\n\n`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:\n\n```typescript\nawait page.getByLabel('Email').fill('user@test.com')\nawait page.getByRole('checkbox', {name: 'Subscribe'}).check()\nawait page.getByRole('combobox', {name: 'Country'}).selectOption('US')\n```\n\n## Capturing Vue Warnings\n\n```typescript\ntest('no Vue warnings during render', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Avoid                                                                 | Problem                                            | Instead                                                   |\n| --------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |\n| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades         | Assert on UI that state produces                          |\n| `page.locator('[data-v-abc123]')`                                     | Scoped style hashes change on every build          | Use `getByRole`, `getByText`, `getByTestId`               |\n| Import `.vue` files in E2E tests                                      | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |\n| `page.waitForTimeout(300)` for transitions                            | Arbitrary waits are fragile                        | `await expect(locator).toBeVisible()` auto-waits          |\n| Mock Pinia by patching `window.__pinia`                               | Fragile; may not trigger reactivity                | Control state through UI or mock API responses            |\n| Test composables via `page.evaluate`                                  | Composables need Vue's setup context               | Test through components or unit test with Vitest          |\n| `page.locator('.v-btn')` for Vuetify                                  | Class names change between versions                | `page.getByRole('button', { name: 'Submit' })`            |\n| Run Nuxt dev server in CI                                             | Dev mode is slower with hot reload overhead        | Use `npx nuxi build && npx nuxi preview`                  |\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md":"# CI/CD Integration\n\n## Table of Contents\n\n1. [GitHub Actions](#github-actions)\n2. [Docker](#docker)\n3. [Reporting](#reporting)\n4. [Sharding](#sharding)\n5. [Environment Management](#environment-management)\n6. [Caching](#caching)\n\n## GitHub Actions\n\n### Basic Workflow\n\n```yaml\n# .github/workflows/playwright.yml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 30\n```\n\n### With Sharding\n\n```yaml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shardIndex: [1, 2, 3, 4]\n        shardTotal: [4]\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n\n      - name: Upload blob report\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: blob-report-${{ matrix.shardIndex }}\n          path: blob-report\n          retention-days: 1\n\n  merge-reports:\n    if: ${{ !cancelled() }}\n    needs: [test]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter html ./all-blob-reports\n\n      - name: Upload HTML report\n        uses: actions/upload-artifact@v4\n        with:\n          name: html-report\n          path: playwright-report\n          retention-days: 14\n```\n\n### With Container\n\n```yaml\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    container:\n      # Use latest or more appropriate playwright version (match package.json)\n      image: mcr.microsoft.com/playwright:v1.40.0-jammy\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n```\n\n## Docker\n\n### Dockerfile\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.40.0-jammy\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\n### Docker Compose\n\n```yaml\n# docker-compose.yml\nversion: '3.8'\n\nservices:\n  playwright:\n    build: .\n    volumes:\n      - ./playwright-report:/app/playwright-report\n      - ./test-results:/app/test-results\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n\n  app:\n    build: ./app\n    ports:\n      - '3000:3000'\n```\n\n### Run with Docker\n\n```bash\n# Build and run\ndocker build -t playwright-tests .\ndocker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests\n\n# With docker-compose\ndocker-compose run --rm playwright\n```\n\n## Reporting\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: [\n    // Always generate\n    ['html', {outputFolder: 'playwright-report'}],\n\n    // Console output\n    ['list'],\n\n    // CI-friendly\n    ['github'], // GitHub Actions annotations\n\n    // JUnit for CI integration\n    ['junit', {outputFile: 'results.xml'}],\n\n    // JSON for custom processing\n    ['json', {outputFile: 'results.json'}],\n\n    // Blob for merging shards\n    ['blob', {outputDir: 'blob-report'}],\n  ],\n})\n```\n\n### CI-Specific Reporter\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n})\n```\n\n## Sharding\n\n### Command Line\n\n```bash\n# Split into 4 shards, run shard 1\nnpx playwright test --shard=1/4\n\n# Run shard 2\nnpx playwright test --shard=2/4\n```\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Evenly distribute tests across shards\n  fullyParallel: true,\n\n  // For blob reporter to merge later\n  reporter: process.env.CI ? [['blob']] : [['html']],\n})\n```\n\n### Merge Sharded Reports\n\n```bash\n# After all shards complete, merge blob reports\nnpx playwright merge-reports --reporter html ./all-blob-reports\n```\n\n## Environment Management\n\n### Environment Variables\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\n// Load env file based on environment\ndotenv.config({path: `.env.${process.env.NODE_ENV || 'development'}`})\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n### Multiple Environments\n\n```yaml\n# .github/workflows/playwright.yml\njobs:\n  test:\n    strategy:\n      matrix:\n        environment: [staging, production]\n    steps:\n      - name: Run tests\n        run: npx playwright test\n        env:\n          BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}\n          TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}\n```\n\n### Secrets Management\n\n```yaml\n# GitHub Actions secrets\n- name: Run tests\n  run: npx playwright test\n  env:\n    TEST_EMAIL: ${{ secrets.TEST_EMAIL }}\n    TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n```typescript\n// tests use environment variables\ntest('login', async ({page}) => {\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n})\n```\n\n## Caching\n\n### Cache Playwright Browsers\n\n```yaml\n- name: Cache Playwright browsers\n  uses: actions/cache@v4\n  id: playwright-cache\n  with:\n    path: ~/.cache/ms-playwright\n    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n- name: Install Playwright browsers\n  if: steps.playwright-cache.outputs.cache-hit != 'true'\n  run: npx playwright install --with-deps\n\n- name: Install system deps only\n  if: steps.playwright-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Cache Node Modules\n\n```yaml\n- uses: actions/setup-node@v4\n  with:\n    node-version: 22\n    cache: 'npm'\n\n- name: Install dependencies\n  run: npm ci\n```\n\n## Tag-Based Test Filtering\n\n### Run Specific Tags in CI\n\n```yaml\n# Run smoke tests on PR\n- name: Run smoke tests\n  run: npx playwright test --grep @smoke\n\n# Run full regression nightly\n- name: Run regression\n  run: npx playwright test --grep @regression\n\n# Exclude flaky tests\n- name: Run stable tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### PR vs Nightly Strategy\n\n```yaml\n# .github/workflows/pr.yml - Fast feedback\n- name: Run critical tests\n  run: npx playwright test --grep \"@smoke|@critical\"\n\n# .github/workflows/nightly.yml - Full coverage\n- name: Run all tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### Tag Filtering in Config\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n### Project-Based Tag Filtering\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n  ],\n})\n```\n\n## Best Practices\n\n| Practice                      | Benefit                   |\n| ----------------------------- | ------------------------- |\n| Use `npm ci`                  | Deterministic installs    |\n| Run headless in CI            | Faster, no display needed |\n| Set retries in CI only        | Handle flakiness          |\n| Upload artifacts on failure   | Debug failures            |\n| Use sharding for large suites | Faster execution          |\n| Cache browsers                | Faster setup              |\n| Use blob reporter for shards  | Merge reports correctly   |\n| Use tags for PR vs nightly    | Fast feedback + coverage  |\n| Exclude @flaky in CI          | Stable pipeline           |\n\n## CI Configuration Reference\n\n```typescript\n// playwright.config.ts - CI optimized\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'on-first-retry',\n  },\n})\n```\n\n## Related References\n\n- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns\n- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization\n- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md":"# Container-Based Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Decision Guide](#decision-guide)\n3. [Anti-Patterns](#anti-patterns)\n4. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.\n\n## Patterns\n\n### Official Image Usage\n\nRun tests without building a custom image:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -w /app \\\n  -e CI=true \\\n  -e BASE_URL=http://host.docker.internal:3000 \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\nExtract reports with bind mounts:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -v $(pwd)/playwright-report:/app/playwright-report \\\n  -v $(pwd)/test-results:/app/test-results \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\n### Custom Dockerfile\n\nBuild a custom image when you need additional dependencies or pre-installed packages:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\n\nWORKDIR /app\n\nCOPY package.json package-lock.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nChromium-only slim image:\n\n```dockerfile\nFROM node:latest-slim\n\nRUN npx playwright install --with-deps chromium\n\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\", \"--project=chromium\"]\n```\n\n### Docker Compose Stack\n\nFull application stack with database, cache, and test runner:\n\n```yaml\nservices:\n  app:\n    build: .\n    ports:\n      - '3000:3000'\n    environment:\n      - NODE_ENV=test\n      - DATABASE_URL=postgresql://postgres:postgres@db:5432/test\n    depends_on:\n      db:\n        condition: service_healthy\n\n  db:\n    image: postgres:latest-alpine\n    environment:\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: postgres\n      POSTGRES_DB: test\n    healthcheck:\n      test: ['CMD-SHELL', 'pg_isready -U postgres']\n      interval: 5s\n      timeout: 5s\n      retries: 5\n    tmpfs:\n      - /var/lib/postgresql/data\n\n  e2e:\n    image: mcr.microsoft.com/playwright:v1.48.0-noble\n    working_dir: /app\n    volumes:\n      - .:/app\n      - /app/node_modules\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n    command: bash -c \"npm ci && npx playwright test\"\n    profiles:\n      - test\n```\n\nRun commands:\n\n```bash\ndocker compose --profile test up --abort-on-container-exit --exit-code-from e2e\n\ndocker compose --profile test down -v\n```\n\n### CI Container Jobs\n\n**GitHub Actions:**\n\n```yaml\njobs:\n  test:\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n    steps:\n      - uses: actions/checkout@v4\n      - run: npm ci\n      - run: npx playwright test\n        env:\n          HOME: /root\n```\n\n**GitLab CI:**\n\n```yaml\ntest:\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  script:\n    - npm ci\n    - npx playwright test\n```\n\n**Jenkins:**\n\n```groovy\npipeline {\n    agent {\n        docker {\n            image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n            args '-u root'\n        }\n    }\n    stages {\n        stage('Test') {\n            steps {\n                sh 'npm ci'\n                sh 'npx playwright test'\n            }\n        }\n    }\n}\n```\n\n### Dev Container Setup\n\nVS Code Dev Container or GitHub Codespaces configuration:\n\n```json\n{\n  \"name\": \"Playwright Dev\",\n  \"image\": \"mcr.microsoft.com/playwright:v1.48.0-noble\",\n  \"features\": {\n    \"ghcr.io/devcontainers/features/node:latest\": {\n      \"version\": \"20\"\n    }\n  },\n  \"postCreateCommand\": \"npm ci\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"ms-playwright.playwright\"]\n    }\n  },\n  \"forwardPorts\": [3000, 9323],\n  \"remoteUser\": \"root\"\n}\n```\n\n## Decision Guide\n\n| Scenario                          | Approach                                       |\n| --------------------------------- | ---------------------------------------------- |\n| Simple CI pipeline                | Official image as CI container                 |\n| Tests need database + cache       | Docker Compose with app, db, e2e services      |\n| Team needs identical environments | Dev Container or custom Dockerfile             |\n| Only testing Chromium             | Slim image with `install --with-deps chromium` |\n| Cross-browser testing             | Official image (all browsers pre-installed)    |\n| Local development                 | Run directly on host for faster iteration      |\n\n## Anti-Patterns\n\n| Anti-Pattern                                     | Problem                            | Solution                                                        |\n| ------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------- |\n| Installing browsers at runtime                   | Wastes 60-90 seconds per run       | Use official image or bake browsers into custom image           |\n| Running as non-root without sandbox config       | Chromium sandbox permission errors | Run as root or disable sandbox                                  |\n| Bind-mounting `node_modules` from host           | Platform-specific binary crashes   | Use anonymous volume: `-v /app/node_modules`                    |\n| No health checks on dependent services           | Tests start before database ready  | Add `healthcheck` with `depends_on: condition: service_healthy` |\n| Building application inside Playwright container | Large image, slow builds           | Separate app and e2e containers                                 |\n\n## Troubleshooting\n\n### \"browserType.launch: Executable doesn't exist\"\n\nPlaywright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:\n\n```bash\nnpm ls @playwright/test\ndocker pull mcr.microsoft.com/playwright:v<matching-version>-noble\n```\n\n### \"net::ERR_CONNECTION_REFUSED\" in docker-compose\n\nTests trying to reach `localhost` instead of service name. Configure `baseURL`:\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n```yaml\ne2e:\n  environment:\n    - BASE_URL=http://app:3000\n```\n\n### Permission denied on mounted volumes\n\nMatch user IDs or run as root:\n\n```bash\ndocker run --rm -u $(id -u):$(id -g) \\\n  -v $(pwd):/app -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  npx playwright test\n```\n\n### Slow container tests on macOS/Windows\n\nDocker Desktop I/O overhead. Copy files instead of mounting:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\nWORKDIR /app\nCOPY . .\nRUN npm ci\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nOr use delegated mount:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app:delegated \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md":"# GitHub Actions for Playwright\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Workflow Patterns](#workflow-patterns)\n3. [Scenario Guide](#scenario-guide)\n4. [Common Mistakes](#common-mistakes)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.\n\n## CLI Commands\n\n```bash\nnpx playwright install --with-deps    # browsers + OS dependencies\nnpx playwright test --shard=1/4       # run shard 1 of 4\nnpx playwright test --reporter=github # PR annotations\nnpx playwright merge-reports ./blob-report  # combine shard reports\n```\n\n## Workflow Patterns\n\n### Basic Workflow\n\n**Use when**: Starting a new project or running a small test suite.\n\n```yaml\n# .github/workflows/e2e.yml\nname: E2E Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - run: npx playwright test\n\n      - name: Upload report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n\n      - name: Upload traces\n        uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: traces\n          path: test-results/\n          retention-days: 7\n```\n\n### Sharded Execution\n\n**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.\n**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.\n\n```yaml\n# .github/workflows/e2e-sharded.yml\nname: E2E Tests (Sharded)\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 20\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1/4, 2/4, 3/4, 4/4]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests (shard ${{ matrix.shard }})\n        run: npx playwright test --shard=${{ matrix.shard }}\n\n      - name: Upload blob report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: blob-${{ strategy.job-index }}\n          path: blob-report/\n          retention-days: 1\n\n  merge:\n    if: ${{ !cancelled() }}\n    needs: test\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blobs\n          pattern: blob-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter=html ./all-blobs\n\n      - name: Upload merged report\n        uses: actions/upload-artifact@v4\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Config for sharding**—enable blob reporter:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['github']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Container-Based Execution\n\n**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.\n**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.\n\n```yaml\n# .github/workflows/e2e-container.yml\nname: E2E Tests (Container)\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Environment Secrets\n\n**Use when**: Tests target staging/production with credentials.\n**Avoid when**: Tests only run against local dev server.\n\n```yaml\n# .github/workflows/e2e-staging.yml\nname: Staging Tests\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    environment: staging\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}\n      API_TOKEN: ${{ secrets.API_TOKEN }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run smoke tests\n        run: npx playwright test --grep @smoke\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: staging-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Scheduled Runs\n\n**Use when**: Full regression suite is too slow for every PR—run nightly instead.\n**Avoid when**: Suite runs under 15 minutes and can run on every PR.\n\n```yaml\n# .github/workflows/nightly.yml\nname: Nightly Regression\n\non:\n  schedule:\n    - cron: '0 3 * * 1-5'\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Install browsers\n        run: npx playwright install --with-deps\n\n      - name: Run full regression\n        run: npx playwright test --grep @regression\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: nightly-${{ github.run_number }}\n          path: playwright-report/\n          retention-days: 30\n\n      - name: Notify on failure\n        if: failure()\n        uses: slackapi/slack-github-action@latest\n        with:\n          payload: |\n            {\n              \"text\": \"Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"\n            }\n        env:\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n```\n\n### Reusable Workflow\n\n**Use when**: Multiple repositories share the same Playwright setup.\n**Avoid when**: Single repo with one workflow.\n\n```yaml\n# .github/workflows/pw-reusable.yml\nname: Playwright Reusable\n\non:\n  workflow_call:\n    inputs:\n      node-version:\n        type: string\n        default: 'lts/*'\n      test-command:\n        type: string\n        default: 'npx playwright test'\n    secrets:\n      BASE_URL:\n        required: false\n      TEST_PASSWORD:\n        required: false\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ secrets.BASE_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n          cache: npm\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests\n        run: ${{ inputs.test-command }}\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Calling the reusable workflow:**\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  e2e:\n    uses: ./.github/workflows/pw-reusable.yml\n    with:\n      node-version: 'lts/*'\n    secrets:\n      BASE_URL: ${{ secrets.STAGING_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n## Scenario Guide\n\n| Scenario                   | Approach                                   |\n| -------------------------- | ------------------------------------------ |\n| Small suite (< 5 min)      | Single job, no sharding                    |\n| Medium suite (5-20 min)    | 2-4 shards with matrix                     |\n| Large suite (20+ min)      | 4-8 shards + blob merge                    |\n| Cross-browser on PRs       | Chromium only on PRs; all browsers on main |\n| Staging/prod smoke tests   | Separate workflow with `environment:`      |\n| Nightly full regression    | `schedule` trigger + `workflow_dispatch`   |\n| Multiple repos, same setup | Reusable workflow with `workflow_call`     |\n| Reproducible env needed    | Container job with Playwright image        |\n\n## Common Mistakes\n\n| Mistake                         | Problem                      | Fix                                                         |\n| ------------------------------- | ---------------------------- | ----------------------------------------------------------- |\n| No `concurrency` group          | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |\n| `fail-fast: true` with sharding | One failure cancels others   | Set `fail-fast: false`                                      |\n| No browser caching              | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright`                              |\n| No `timeout-minutes`            | Stuck jobs run for 6 hours   | Set explicit timeout: 20-30 minutes                         |\n| Artifacts only on failure       | No report when tests pass    | Use `if: ${{ !cancelled() }}`                               |\n| Hardcoded secrets               | Security risk                | Use GitHub Secrets and Environments                         |\n| All browsers on every PR        | 3x CI cost                   | Chromium on PR; cross-browser on main                       |\n| No artifact retention           | Default 90-day fills storage | Set `retention-days: 7-14`                                  |\n| Missing `--with-deps`           | Browser launch failures      | Always use `npx playwright install --with-deps`             |\n\n## Troubleshooting\n\n### Browser launch fails: \"Missing dependencies\"\n\n**Cause**: Browsers restored from cache but OS dependencies weren't cached.\n\n**Fix**: Run `npx playwright install-deps` on cache hit:\n\n```yaml\n- name: Install OS dependencies\n  if: steps.browser-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Tests pass locally but timeout in CI\n\n**Cause**: CI runners have fewer resources than dev machines.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Sharded reports incomplete\n\n**Cause**: Artifact names collide or `merge-multiple` not set.\n\n**Fix**: Unique names per shard and enable merge:\n\n```yaml\n# Upload in each shard\n- uses: actions/upload-artifact@v4\n  with:\n    name: blob-${{ strategy.job-index }}\n    path: blob-report/\n\n# Download in merge job\n- uses: actions/download-artifact@v4\n  with:\n    path: all-blobs\n    pattern: blob-*\n    merge-multiple: true\n```\n\n### `webServer` fails: \"port already in use\"\n\n**Cause**: Zombie process from previous run.\n\n**Fix**: Kill stale processes before starting:\n\n```yaml\n- name: Kill stale processes\n  run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true\n```\n\n### No PR annotations\n\n**Cause**: `github` reporter not configured.\n\n**Fix**: Add `github` reporter for CI:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n})\n```\n\n## Related\n\n- [test-tags.md](../core/test-tags.md) — tagging and filtering tests\n- [parallel-sharding.md](parallel-sharding.md) — sharding strategies\n- [reporting.md](reporting.md) — reporter configuration\n- [docker.md](docker.md) — container images\n- [gitlab.md](gitlab.md) — GitLab CI equivalent\n- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md":"# GitLab CI/CD Configuration\n\n## Table of Contents\n\n1. [Key Commands](#key-commands)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.\n\n## Key Commands\n\n```bash\nnpx playwright install --with-deps    # install browsers + OS deps\nnpx playwright test --shard=1/4       # run 1 of 4 parallel shards\nnpx playwright merge-reports ./blob-report  # merge shard results\nnpx playwright test --reporter=dot    # minimal output for CI logs\n```\n\n## Patterns\n\n### Basic Pipeline Configuration\n\n**Use when**: Any GitLab project with Playwright tests.\n\n```yaml\n# .gitlab-ci.yml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Sharded Parallel Execution\n\n**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.\n**Avoid when**: Suite runs under 5 minutes.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  parallel: 4\n  script:\n    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL\n  artifacts:\n    when: always\n    paths:\n      - blob-report/\n    expire_in: 1 hour\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n\ncombine-reports:\n  stage: report\n  needs: [e2e]\n  when: always\n  script:\n    - npx playwright merge-reports --reporter=html ./blob-report\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 14 days\n```\n\n**Config for sharded pipelines:**\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Environment Variables and Secrets\n\n**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - test\n\nvariables:\n  CI: 'true'\n\ne2e:staging:\n  stage: test\n  variables:\n    BASE_URL: $STAGING_URL\n    TEST_PASSWORD: $TEST_PASSWORD\n    API_KEY: $API_KEY\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n    - when: manual\n      allow_failure: true\n```\n\n**Setting variables in GitLab:**\nNavigate to **Settings > CI/CD > Variables** and add:\n\n- `STAGING_URL` -- not masked, not protected\n- `TEST_PASSWORD` -- masked, protected\n- `API_KEY` -- masked, protected\n\n### Multi-Browser Matrix\n\n**Use when**: Running Chromium on MRs and all browsers on the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n\nvariables:\n  CI: 'true'\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:chromium:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test --project=chromium\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n\ne2e:all-browsers:\n  stage: test\n  needs: [setup]\n  parallel:\n    matrix:\n      - PROJECT: [chromium, firefox, webkit]\n  script:\n    - npx playwright test --project=$PROJECT\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services Integration (Database, Cache)\n\n**Use when**: Tests need the application running alongside Playwright, or you need external services.\n\n```yaml\nstages:\n  - test\n\ne2e:integration:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  services:\n    - name: postgres:latest\n      alias: db\n    - name: redis:latest\n      alias: cache\n  variables:\n    CI: 'true'\n    DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n    REDIS_URL: 'redis://cache:6379'\n    POSTGRES_PASSWORD: 'postgres'\n    POSTGRES_DB: 'testdb'\n  before_script:\n    - npm ci\n    - npx prisma db push\n    - npx prisma db seed\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Scheduled Nightly Regression\n\n**Use when**: Full regression is too slow for every MR.\n\n```yaml\ne2e:nightly:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test --grep @regression\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 30 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"schedule\"\n```\n\nSet up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).\n\n## Decision Guide\n\n| Scenario                             | Approach                                               | Why                                                 |\n| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |\n| Simple project, < 5 min suite        | Single `test` job using Playwright Docker image        | No sharding overhead; artifacts capture report      |\n| Suite > 10 min                       | `parallel: N` with `--shard`                           | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |\n| Merge request fast feedback          | Chromium only on MRs; all browsers on main             | 3x fewer pipeline minutes on MRs                    |\n| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images         | GitLab manages service lifecycle                    |\n| Secrets for staging environment      | GitLab CI/CD Variables (masked + protected)            | Never hardcode secrets in `.gitlab-ci.yml`          |\n| Full nightly regression              | Pipeline schedule (`CI_PIPELINE_SOURCE == \"schedule\"`) | Avoids blocking MR pipelines                        |\n| Report browsing                      | `artifacts:` with `paths: [playwright-report/]`        | Browse directly in GitLab job artifacts UI          |\n\n## Anti-Patterns\n\n| Anti-Pattern                                         | Problem                                                            | Do This Instead                                                           |\n| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |\n| Not using the Playwright Docker image                | Installing browsers every run adds 1-2 minutes                     | Use `mcr.microsoft.com/playwright:v1.48.0-noble` as base image            |\n| `artifacts: when: on_failure` only                   | No report when tests pass; can't verify results                    | Use `when: always` to capture reports regardless                          |\n| No `expire_in` on artifacts                          | Artifacts accumulate and consume storage                           | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |\n| Hardcoding `CI_NODE_TOTAL` in shard flag             | Breaks when you change `parallel:` value                           | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL`                               |\n| Skipping `needs:` between stages                     | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs                                |\n| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts                               | Key cache on `package-lock.json` hash                                     |\n\n## Troubleshooting\n\n### Browser launch fails: \"Failed to launch browser\"\n\n**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.\n\n**Fix**: Match the Docker image tag to your Playwright version:\n\n```yaml\n# Check your version: npm ls @playwright/test\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Tests hang in GitLab runner: \"Navigation timeout exceeded\"\n\n**Cause**: GitLab shared runners may have limited resources.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\nexport default defineConfig({\n  workers: process.env.CI ? 2 : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Pipeline runs on every push, not just merge requests\n\n**Cause**: Missing `rules:` configuration.\n\n**Fix**: Add explicit rules:\n\n```yaml\nrules:\n  - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services (Postgres/Redis) not reachable from tests\n\n**Cause**: Using `localhost` instead of the service alias.\n\n**Fix**: Use the service alias as hostname:\n\n```yaml\nservices:\n  - name: postgres:latest\n    alias: db\n\nvariables:\n  DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n```\n\n### Merged report is empty after sharded run\n\n**Cause**: Each shard job needs the `blob` reporter, not `html`.\n\n**Fix**: Configure blob reporter for CI:\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fother-providers.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fparallel-sharding.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fperformance.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Freporting.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Ftest-coverage.md -->",".agents/skills/playwright-best-practices/testing-patterns/accessibility.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Faccessibility.md -->",".agents/skills/playwright-best-practices/testing-patterns/api-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fapi-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fbrowser-extensions.md -->",".agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcanvas-webgl.md -->",".agents/skills/playwright-best-practices/testing-patterns/component-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcomponent-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/drag-drop.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fdrag-drop.md -->",".agents/skills/playwright-best-practices/testing-patterns/electron.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Felectron.md -->",".agents/skills/playwright-best-practices/testing-patterns/file-operations.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-operations.md -->",".agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-upload-download.md -->",".agents/skills/playwright-best-practices/testing-patterns/forms-validation.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fforms-validation.md -->",".agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fgraphql-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/i18n.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fi18n.md -->",".agents/skills/playwright-best-practices/testing-patterns/performance-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fperformance-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/security-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fsecurity-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/visual-regression.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fvisual-regression.md -->",".agents/skills/playwright-cli/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2FSKILL.md -->",".agents/skills/playwright-cli/references/request-mocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frequest-mocking.md -->",".agents/skills/playwright-cli/references/running-code.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frunning-code.md -->",".agents/skills/playwright-cli/references/session-management.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fsession-management.md -->",".agents/skills/playwright-cli/references/storage-state.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fstorage-state.md -->",".agents/skills/playwright-cli/references/test-generation.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftest-generation.md -->",".agents/skills/playwright-cli/references/tracing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftracing.md -->",".agents/skills/playwright-cli/references/video-recording.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fvideo-recording.md -->",".agents/skills/pr-description/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fpr-description%2FSKILL.md -->",".agents/skills/react-devtools/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2FSKILL.md -->",".agents/skills/react-devtools/references/commands.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fcommands.md -->",".agents/skills/react-devtools/references/profiling-guide.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fprofiling-guide.md -->",".agents/skills/react-devtools/references/setup.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fsetup.md -->",".agents/skills/react-rx-hook-audit/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-rx-hook-audit%2FSKILL.md -->",".agents/skills/rxjs-like-a-pro/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2FSKILL.md -->",".agents/skills/rxjs-like-a-pro/references/custom-operators.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fcustom-operators.md -->",".agents/skills/rxjs-like-a-pro/references/inner-observable-chains.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Finner-observable-chains.md -->",".agents/skills/rxjs-like-a-pro/references/loading-state-patterns.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Floading-state-patterns.md -->",".agents/skills/rxjs-like-a-pro/references/massive-observable.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fmassive-observable.md -->",".agents/skills/sanity-bench/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-bench%2FSKILL.md -->",".agents/skills/sanity-config-reducers/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-config-reducers%2FSKILL.md -->",".agents/skills/sanity-default-plugins/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-default-plugins%2FSKILL.md -->",".agents/skills/sanity-i18n-translate/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-i18n-translate%2FSKILL.md -->",".agents/skills/sanity-plugin-authoring/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-plugin-authoring%2FSKILL.md -->",".agents/skills/sanity-radar-investigate/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar-investigate%2FSKILL.md -->",".agents/skills/sanity-radar/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2FSKILL.md -->",".agents/skills/sanity-radar/references/groq-recipes.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2Freferences%2Fgroq-recipes.md -->",".agents/skills/sanity-tsdown-config/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-tsdown-config%2FSKILL.md -->",".agents/skills/sanity-ui-migration-progress/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-ui-migration-progress%2FSKILL.md -->",".agents/skills/sanity-visual-coverage/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-coverage%2FSKILL.md -->",".agents/skills/sanity-visual-regression/REFERENCE.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FREFERENCE.md -->",".agents/skills/sanity-visual-regression/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FSKILL.md -->",".agents/skills/stories/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstories%2FSKILL.md -->",".agents/skills/storybook-init/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-init%2FSKILL.md -->",".agents/skills/storybook-setup/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-setup%2FSKILL.md -->",".agents/skills/storybook-upgrade/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-upgrade%2FSKILL.md -->",".agents/skills/tdd/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2FSKILL.md -->",".agents/skills/tdd/deep-modules.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fdeep-modules.md -->",".agents/skills/tdd/interface-design.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Finterface-design.md -->",".agents/skills/tdd/mocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fmocking.md -->",".agents/skills/tdd/refactoring.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Frefactoring.md -->",".agents/skills/tdd/tests.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Ftests.md -->",".agents/skills/vercel-react-best-practices/AGENTS.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FAGENTS.md -->",".agents/skills/vercel-react-best-practices/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FSKILL.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-event-handler-refs.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-init-once.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-init-once.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-use-latest.md -->",".agents/skills/vercel-react-best-practices/rules/async-api-routes.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-api-routes.md -->",".agents/skills/vercel-react-best-practices/rules/async-defer-await.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-defer-await.md -->",".agents/skills/vercel-react-best-practices/rules/async-dependencies.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-dependencies.md -->",".agents/skills/vercel-react-best-practices/rules/async-parallel.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-parallel.md -->",".agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-suspense-boundaries.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-barrel-imports.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-conditional.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-conditional.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-defer-third-party.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-dynamic-imports.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-preload.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-preload.md -->",".agents/skills/vercel-react-best-practices/rules/client-event-listeners.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-event-listeners.md -->",".agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-localstorage-schema.md -->",".agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-passive-event-listeners.md -->",".agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-swr-dedup.md -->",".agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-batch-dom-css.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-function-results.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-property-access.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-storage.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-storage.md -->",".agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-combine-iterations.md -->",".agents/skills/vercel-react-best-practices/rules/js-early-exit.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-early-exit.md -->",".agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-hoist-regexp.md -->",".agents/skills/vercel-react-best-practices/rules/js-index-maps.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-index-maps.md -->",".agents/skills/vercel-react-best-practices/rules/js-length-check-first.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-length-check-first.md -->",".agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-min-max-loop.md -->",".agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-set-map-lookups.md -->",".agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-tosorted-immutable.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-activity.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-activity.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-animate-svg-wrapper.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-conditional-render.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-content-visibility.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hoist-jsx.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-no-flicker.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-suppress-warning.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-svg-precision.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-usetransition-loading.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-defer-reads.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-dependencies.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state-no-effect.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-functional-setstate.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-lazy-state-init.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo-with-default-value.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-memo.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-move-effect-to-event.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-simple-expression-in-memo.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-transitions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-transitions.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-use-ref-transient-values.md -->",".agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-after-nonblocking.md -->",".agents/skills/vercel-react-best-practices/rules/server-auth-actions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-auth-actions.md -->",".agents/skills/vercel-react-best-practices/rules/server-cache-lru.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-lru.md -->",".agents/skills/vercel-react-best-practices/rules/server-cache-react.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-react.md -->",".agents/skills/vercel-react-best-practices/rules/server-dedup-props.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-dedup-props.md -->",".agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-parallel-fetching.md -->",".agents/skills/vercel-react-best-practices/rules/server-serialization.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-serialization.md -->",".agents/skills/write-a-skill/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fwrite-a-skill%2FSKILL.md -->","AGENTS.md":"# AGENTS.md - AI Agent Guidelines for Sanity Monorepo\n\nThis document helps AI agents work successfully with the Sanity monorepo.\n\n> **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.\n\n## Prerequisites\n\n- **Node.js**: v24 or latest LTS. Published packages must declare `\"engines\": { \"node\": \">=22.12\" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing).\n- **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json)\n\n## Quick Reference\n\n```bash\n# Install dependencies (pnpm ONLY - enforced)\npnpm install\n\n# Build all packages (required before testing)\npnpm build\n\n# Run dev studio (requires auth, see below)\npnpm dev\n\n# Format code (MUST pass CI)\npnpm chore:format:fix\n\n# Fix all lint issues (MUST pass CI) — includes TypeScript type checking via oxlint\npnpm lint:fix\n\n# Run tests\npnpm test\n\n# Update snapshots if tests fail due to expected changes\npnpm test -- -u\n\n# Lint + type check (oxlint typeAware + typeCheck; no separate tsc step)\npnpm check:oxlint\n```\n\n## CI Checks - What Must Pass\n\nThese checks run on every PR and **must pass**:\n\n| Check            | Command               | Notes                                                                                                                                                            |\n| ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Format**       | `pnpm check:format`   | Uses oxfmt. Fix with `pnpm chore:format:fix`                                                                                                                     |\n| **Oxlint**       | `pnpm check:oxlint`   | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix`                              |\n| **Unit Tests**   | `pnpm test`           | Vitest, sharded in CI                                                                                                                                            |\n| **Export Tests** | `pnpm test:exports`   | Ensures ESM/CJS/DTS work                                                                                                                                         |\n| **Dep Check**    | `pnpm depcheck`       | Finds unused/missing deps                                                                                                                                        |\n| **Zizmor**       | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. |\n| **PR Title**     | Conventional commits  | e.g., `feat(scope): description`                                                                                                                                 |\n\n### Before Committing\n\nRun these commands to avoid CI failures:\n\n```bash\n# Fix all formatting and lint issues\npnpm lint:fix\n\n# Verify tests pass (build first if needed)\npnpm build && pnpm test\n```\n\nIf tests fail due to **expected snapshot changes**, update them:\n\n```bash\npnpm test -- -u\n```\n\nSnapshot files are located in `__snapshots__` directories alongside test files.\n\n## Project Structure\n\n```\nsanity/\n├── packages/\n│   ├── sanity/           # Main Sanity studio package\n│   ├── @sanity/          # Scoped packages (cli, types, schema, etc.)\n│   └── @repo/            # Internal tooling (test-config, tsconfig, etc.)\n├── dev/                  # Development studios for testing\n│   ├── test-studio/      # Primary dev studio (pnpm dev runs this)\n│   ├── studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON\n│   └── preview-iframe/   # Presentation preview iframe (vanilla Vite, port 3334)\n├── e2e/                  # End-to-end Playwright tests\n├── perf/                 # Performance testing\n└── examples/             # Example studios\n```\n\n### Key Packages\n\n- **`packages/sanity`** - Core studio package with all UI components\n- **`packages/@sanity/types`** - TypeScript type definitions\n- **`packages/@sanity/schema`** - Schema compilation\n- **`packages/@sanity/mutator`** - Document mutation logic\n\n## Build System\n\n- **Package Manager**: pnpm (version 10.x, enforced via `preinstall`)\n- **Build Orchestration**: Turbo (caches builds)\n- **Versioning**: Lerna-lite with conventional commits\n\n### Build Commands\n\n```bash\npnpm build              # Build all packages\npnpm watch              # Watch mode for development\n```\n\n### Running the Dev Studio\n\n```bash\npnpm dev                # Starts dev studio at http://localhost:3333\n```\n\n**Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio.\n\n## Local Development\n\nThis section clarifies what requires authentication and what doesn't—critical for AI agents to avoid getting stuck on auth flows.\n\n### Running Tests (No Auth Required)\n\nUnit tests run in jsdom with mocks and **do not require any authentication**:\n\n```bash\n# Build first (required), then run all tests\npnpm build && pnpm test\n\n# Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests)\npnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts\n\n# Run a single test file with verbose output\npnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts\n\n# Watch mode for iterative development\npnpm test -- --watch\n\n# Run tests for a specific package\npnpm test -- --project=sanity\n```\n\n**Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file — it runs all tests across all projects. Use `pnpm vitest run --project=<project> <path>` instead.\n\nThe headless validation package is registered as `@sanity/validation`: run its full suite with\n`pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's\ncompilation path too: call `inferFromSchema` on the built-in schema before compiling custom types,\nthen call it on the compiled custom schema. Studio inherits already-normalized built-in rules.\n\nComponents that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes.\n\n### Running the Dev Studio (Auth Required)\n\n```bash\npnpm dev  # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334\n```\n\n- **Requires browser authentication** on first visit—you'll be prompted to log in with a Sanity account\n- Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`)\n- Uses staging API by default (`api.sanity.work`)\n- Session persists in browser, so subsequent visits won't require re-authentication\n- `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`.\n- Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`)\n\nUse the dev studio when you need to:\n\n- Visually verify UI changes\n- Test real document editing workflows\n- Debug issues that only appear with real data\n- Exercise Presentation / visual editing against the local preview iframe\n\n### Inspecting Production Builds with Vite DevTools\n\nThe test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` server—no restart needed.\n\n```bash\n# Builds the test studio with devtools enabled, then starts the dev server\n# (so there's a build session to inspect right away)\npnpm devtools:test-studio\n```\n\nOpen `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels.\n\nTo inspect a **new** build after making changes—while `pnpm devtools:test-studio` is still running—run in a second terminal:\n\n```bash\n# Creates a fresh build session that shows up in the running DevTools dock\npnpm devtools:test-studio:build\n```\n\nSessions can be compared against each other in the DevTools UI to diff bundle changes.\n\nHow it works:\n\n- Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools`\n- Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored)\n- The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes\n- Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag\n\n### Debugging presence with fake users\n\n**Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking).\n\n**URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: \"presence-debug\"`) and `show_own` (also show your own session).\n\n### Profiling studio re-renders with React DevTools (agent-react-devtools)\n\nThe test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI — made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling.\n\nFor `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern.\n\n```bash\n# 1. Start the daemon (port 8097)\npnpm --filter sanity-test-studio exec agent-react-devtools start\n\n# 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true)\npnpm react-devtools:test-studio\n\n# 3. Open http://localhost:3333 in a browser, then verify the app registered\npnpm --filter sanity-test-studio exec agent-react-devtools status\n\n# 4. Profile an interaction\npnpm --filter sanity-test-studio exec agent-react-devtools profile start\n# ... interact with the studio ...\npnpm --filter sanity-test-studio exec agent-react-devtools profile stop\npnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10\n```\n\nHow it works and gotchas:\n\n- `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) — it can never reach `sanity build` output.\n- **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs).\n- Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations — production studios don't run StrictMode.\n- Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas).\n\n### Analyzing the `sanity` package bundle\n\nThe `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`:\n\n```bash\npnpm analyze:sanity\n```\n\nThe report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown).\n\n### Auto-updating studio CSS (Lightning CSS `light-dark()`)\n\nThe CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance ≠ Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native — same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo.\n\n### Panda CSS\n\nThe static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in\n`.css.ts` modules next to the components, imported from the generated `#styled-system/css`\nruntime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on\n`pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the\n**only Panda project in the repository** — the `} satisfies Config)` pattern, no presets,\ncontent-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues`\n— and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity`\nwrites Panda CSS. The `migrate-styled-components-to-panda-css` skill\n(`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the\n`&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the\npackage, vanilla-extract stays where it is for now — the `.css.ts` modules of `@sanity/vision`\n(with its `vanillaExtract` tsdown option) and the test studio's\n`plugins/style-outline/styleOutline.css.ts` — until\n[#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps\nthat build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's\n`sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live\nin the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the\nVite apps that build the package from source get their Panda wiring from `@repo/pandacss`\n(`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those\nthemselves.\n\nHow the CSS reaches a page:\n\n- **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts`\n  (the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the\n  diff between two published versions stays readable, see below for the layers) rewrites every\n  `css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`,\n  injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional\n  `./bundle.css` export (`browser`/`style` → the stylesheet, `node`/`default` → a no-op shim).\n  The published code carries no Panda runtime, which is why the manifest resets `imports`\n  under `publishConfig`. A `css()` call the build cannot resolve statically (computed values,\n  loops) makes it warn and bundle the runtime — keep the style objects literal.\n- **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`,\n  Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace\n  packages to their sources (the `monorepo` export condition), so they bundle the package's\n  `.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite`\n  (`packages/@repo/pandacss`): `pandacss({cwd: <packages/sanity>, transform: true})` from\n  `@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its\n  `include` globs and the codegen `outdir` there), and\n  `removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from\n  `@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css`\n  (below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares\n  Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the\n  `@layer reset, base, tokens, recipes, utilities;` statement — the dev counterpart of the\n  `sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and\n  `packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts`\n  (`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import\n  injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin,\n  registered for dev only. A `sanity build` (and the viewer's build) resolves the built package\n  and its `bundle.css`, so nothing Panda runs there — which is also why a studio that installs\n  `sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS\n  support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and\n  keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from\n  every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the\n  test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's\n  modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is\n  wrapped so that its transform's hook filter excludes `packages/sanity/src/` — it would otherwise\n  compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css`\n  import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite`\n  directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test\n  asserts on styles.\n- **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`,\n  `recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats\n  every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and\n  selected states to any unlayered rule that also matches — styled-components output, a reset\n  like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's\n  write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after\n  Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before\n  its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and\n  the studio's rules cascade like the vanilla-extract output did: by specificity, then source\n  order. The same pass runs on the dev stylesheets and the CDN bundle through\n  `removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS\n  module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own\n  `panda.css`), and leaves other layers alone — which is why that file declares Panda's default\n  layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a\n  single `@layer`. Panda's `polyfill` option is\n  not an alternative: it emulates the layer order with `:not(#\\#…)` selector chains that inflate\n  every rule's specificity. The studio declares no `@layer` statement of its own either: with\n  `bundle.css` unlayered and `@sanity/ui` ≥ 4.4.8 (the catalog version) shipping its static styles\n  unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a\n  studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing\n  to order it against — the same situation as before the migration. What this means when\n  authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order\n  alone — a plain class beats a styled-components base rule that loads later only when it is more\n  specific, which is what the `&&` doubling is for.\n- **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct\n  `property: value` pair once, grouped by property with the values sorted; a modifier class that\n  must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`)\n  instead of relying on being declared later, and parent-state selectors key on data attributes\n  the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in\n  `ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted.\n\n### Studio performance benchmarks (perf/bench — No Auth Required)\n\nThe `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API — fully hermetic, no tokens, no network:\n\n```bash\npnpm build:bench                                   # build packages + bench studio (required first)\npnpm bench help                                    # list all bench CLI commands\npnpm bench run --scenario singleString             # absolute interaction benchmark\npnpm bench run --mode pageload --scenario singleString  # load vitals + bundle size\npnpm bench:unit                                    # mock-contract + stats unit tests\npnpm bench dev                                     # mock + `sanity dev` for interactive debugging\n```\n\nSee `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.)\n\nThree skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit).\n\n### Engine confidence score (No Auth Required locally)\n\nThe Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail (\"X of N known bugs caught\"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail.\n\n```bash\npnpm engine-confidence stryker --target mutator --full   # also: --target sanity; omit --full for an incremental run\npnpm engine-confidence catalogue                         # --suites unit:mutator,unit:engine skips fuzz; --only <ids>; --check-only\npnpm engine-confidence score                             # writes engine-confidence/reports/engine-confidence-run.json\npnpm engine-confidence store <file> --dry-run            # prints the document id and body, needs no token\npnpm engine-confidence pin                               # recomputes config.json's pinnedHash and bumps version when it changed\npnpm engine-confidence unit                              # the package's unit tests\n```\n\n- **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once.\n- **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards.\n- **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed.\n- **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check.\n- **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules.\n- **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only <id>` to confirm the verdict. `engine-confidence/README.md` has the steps.\n- **Where it shows:** the Radar \"Engine confidence\" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week.\n\n### E2E Tests (Token Required)\n\nE2E tests require authentication tokens. Add these to `.env.local` in the repo root:\n\n```bash\nSANITY_E2E_SESSION_TOKEN=<your-token>\nSANITY_E2E_PROJECT_ID=<project-id>\nSANITY_E2E_DATASET=<dataset-name>\n```\n\n**How to get a token:**\n\n```bash\n# Option 1: Use your CLI token\nsanity login\nsanity debug --secrets  # Look for \"Auth token\"\n\n# Option 2: Create a project token at https://sanity.io/manage\n# Navigate to: Project Settings → API → Tokens → Add API token\n```\n\nThen run E2E tests:\n\n```bash\npnpm e2e:build              # Build E2E studio\npnpm test:e2e               # Run E2E tests\npnpm test:e2e --ui          # Interactive mode\n```\n\n**Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests.\n\nWhen CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `<report-url>/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt).\n\n#### Diagnosing e2e flake: failure diagnostics and the flake report\n\nEvery failed or timed-out e2e test attempt attaches `studio-diagnostics.json` — the same JSON the Studio Diagnostics dialog's \"Copy output\" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog (\"Too many requests\", server error, network error) when it is showing — the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`.\n\nTo turn those captures into numbers (\"how many failed runs were platform-caused?\"), run the flake report:\n\n```bash\npnpm e2e:flake-report --days 7 --out flake-report.md   # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login`\npnpm e2e:flake-report --help\npnpm --filter e2e test:unit                              # classifier unit tests\n```\n\nIt lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact.\n\nStaging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does.\n\n### Important Note for AI Agents\n\n**What requires authentication:**\n\n- Running the dev studio (`pnpm dev`)\n- E2E tests (`pnpm test:e2e`)\n- Any command that connects to Sanity APIs\n\n**What does NOT require authentication:**\n\n- Building packages (`pnpm build`)\n- Running unit tests (`pnpm test`)\n- Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`)\n\n**Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed.\n\n## Coding Standards\n\nCoding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` — no separate `tsc` step. Check your code with:\n\n```bash\npnpm lint              # Check for issues (oxlint, includes type checking)\npnpm lint:fix          # Auto-fix issues (oxfmt + oxlint --fix)\n```\n\nAll packages use **ESM** (`\"type\": \"module\"`). TypeScript strict mode is enabled.\n\nRules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide — run `pnpm lint` and follow the reported messages, which explain the expected pattern.\n\n### Do Not Weaken the Linter\n\nFix the reported problem instead of silencing it. In order of preference:\n\n1. **Fix the code** so the rule passes. This is almost always the right answer.\n2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line <rule> -- <why>`. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green.\n3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative — an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description.\n\nFile-wide `/* oxlint-disable <rule> */` is reserved for files that are an exception as a whole — vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors.\n\n`options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI — drop suppressions when the code underneath them changes.\n\n### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n\nEvery declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the\n`@internal` TSDoc release tag is also exported from\n`sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n(`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`),\nan entry whose name spells out its contract: anything on it can change or disappear in any\nrelease. The public entries still export those symbols today; the entry is the migration target\nfor consumers that depend on them, ahead of the public entries dropping them in a future major.\n`packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of\n`pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry,\nwhen the two entries export different declarations under one name, or when the entry exports\nsomething that is not tagged `@internal`.\n\nRules that follow from this:\n\n- A new `@internal` export on a public barrel needs a matching line in the internals barrel\n  (grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity`\n  (`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from\n  `sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing\n  relatively: the entry re-exports modules from all three areas, so importing it from any of them\n  is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in\n  with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the\n  three areas.\n- The entry is generated from the tags, not curated: whether a symbol belongs there is decided by\n  its `@internal` tag. Retagging a symbol (`@internal` → `@beta`/`@public`, or the other way) is\n  the API decision; check sanity.io/docs and the published first-party plugins before demoting a\n  symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the\n  studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure\n  `renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`,\n  `generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one\n  deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its\n  `@internal @deprecated` tags because those tell customers to stop using it, it stays off the\n  entry so the deprecated `sanity` import remains the only one, and it is listed in\n  `PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by\n  the internals moving. Do not retag it.\n- The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly;\n  a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a\n  404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports`\n  regenerates the d.ts fixture for the entry after its export list changes.\n- A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`,\n  a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the\n  entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u`\n  regenerates it without a build.\n\n### An entry's type surface is what its barrel exports, nothing more\n\nA bundled `.d.ts` can only name declarations the entry barrel actually exports. Until\n`rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {…}` clause\nhit TypeScript's rule that every top-level declaration of such a declaration file is implicitly\nexported, so private helpers pulled in for a signature became part of the published type surface —\nincluding `const`s and `function`s that the emitted `.js` never exported, so importing them\ntype-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead\n([\"prevent implicit exports of private declarations\"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)),\nso only explicit exports are public.\n\nThat makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a\nname no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check\neach dropped name before regenerating:\n\n- A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as\n  a type-only re-export (`export {type Foo} from '…'`), which leaves the emitted `.js` untouched.\n- A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup\n  artifact that was never API — `sanity/media-library` emitted one because `VideoDefinition` is\n  declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared\n  twice, pick which one is public by convention, not by which one the old output left unsuffixed:\n  that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like\n  `ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`,\n  while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation.\n- A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported\n  it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost\n  more than it looks — re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into\n  `sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away.\n- Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from\n  `createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and\n  `lib-check.test.ts` runs with `skipLibCheck: false`.\n\nRegenerating needs a full `pnpm build` first: the generator reads each package's built\n`lib/**/*.d.ts`.\n\n### Effect events: use `use-effect-event`, not React's native hook\n\nImport `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook\nreturns first-render values when the calling component is wrapped in `forwardRef` or `memo`\n([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries).\n`eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that\nwraps the native hook, so check the implementation before trusting one.\n\n### react-rx: stable observables, explicit initial values\n\n`react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the\n`initialValue` (required — pass `undefined` explicitly when there is nothing better) until the\nsubscription started on commit delivers a value, and a synchronous emission arrives one pass later.\nTwo rules follow:\n\n- **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a\n  store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than\n  on arrays or objects recreated every render. An observable rebuilt each render is torn down and\n  re-subscribed each commit; when it synchronously replays a value that differs from the\n  `initialValue`, React aborts with \"Maximum update depth exceeded\"\n  (`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case).\n- **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a\n  first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's\n  exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render\n  when it depends on the observable's parameters (`useEditState`), since react-rx captures\n  `initialValue` once per hook instance.\n\n### Translate: never define `components` inline\n\nComponents passed to `<Translate>`'s `components` map must be stable, module-scope components —\ndefining them inline during render creates a new component identity every render and remounts\nthe subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain\nHTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule\n`@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces\nthis for object literals in the JSX attribute; maps built during render via `useMemo` or factory\nfunctions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate`\nskill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns.\n\n### Locale namespaces: plugin strings stay inside the plugin\n\nOnly `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always\nincluded) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`,\n`structure`, ...) is registered by a plugin that a studio can leave out, and a component that\nreads it while that plugin is not loaded renders raw keys. Strings for code that renders outside\na plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two\nin-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and\n`@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace\nthey are read from). Both read the namespace table in\n`packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or\nchanging which plugin registers one.\n\n### `<Activity mode=\"hidden\">`: effects are torn down, DOM and state stay\n\nReact's `<Activity>` is used for closed `@sanity/ui` overlays, collapsed document lists, and the\ntop-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs\nevery effect cleanup in the subtree and showing runs the effects again, without a remount, so:\n\n- Anything that lives only in an effect-created object is lost on reveal unless the effect can\n  rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup\n  and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for\n  that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal.\n- Loading states come back on reveal unless the code remembers it already loaded. A comlink\n  channel recreated by an effect handshakes as a brand new connection (the presentation machine\n  tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a\n  `startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes\n  it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context\n  populated by registering in an effect empties out while hidden, unmounting whatever renders\n  from it (`PresentationDocumentProvider` lists its own options at render time).\n- Never reorder keyed siblings that hold an `<iframe>`. React moves the DOM node when the key\n  order changes, and a re-inserted iframe reloads. `useMountedTools` renders tools in workspace\n  order for this reason even though eviction is least-recently-used.\n- `@sanity/ui`'s `useElementSize` (4.2.4) stops reporting after a reveal. Its shared\n  `_elementSizeObserver` keeps one `ResizeObserver` per element and disconnects it when the last\n  subscriber leaves, but never forgets the element, so the effect re-run after a reveal finds the\n  cached entry and creates no new observer: the hook keeps the size from before hiding, also for\n  later resizes. Any layout derived from it inside a tool (Vision's column/stacked breakpoints,\n  `PaneLayout`, `DocumentLayout`, the PTE toolbar) freezes. Vision's redesign measures with its\n  own `useContentSize`, which observes afresh on every effect run; the fix belongs in `@sanity/ui`.\n- Hidden content keeps `display: none` on its topmost host nodes only, and portals under the\n  boundary are hidden too (`hideOrUnhideNearestPortals`); descendants report their own `display`.\n\n### Refs: use `props.ref`, not `forwardRef`\n\nReact 19 passes `ref` as a regular prop. Do not use `forwardRef` — destructure `ref` from props\n(so it is not left in a `...rest` spread) and forward it like any other prop.\n`eslint/no-restricted-imports` bans importing `forwardRef` from `react`.\n\nPrefer a named function declaration over `const X = function …` / arrow wrappers:\n\n```ts\n// preferred\nexport function MyComponent(props: Props & RefAttributes<HTMLDivElement>) {\n  const {ref, ...rest} = props\n  return <div ref={ref} {...rest} />\n}\n\n// avoid\nexport const MyComponent = function MyComponent(props: …) { … }\nexport const MyComponent = (props: …) => { … }\n```\n\nWhen wrapping with `memo`, declare the component as a function first, then memoize:\n\n```ts\nfunction MyComponent(props: …) { … }\nexport const MyComponentMemo = memo(MyComponent)\n```\n\nDo not assign `.displayName` on components, HOCs, styled components, or `createContext`\nresults. That property write is a module-level side effect and keeps the export from being\ntree-shaken. Named function declarations already give React DevTools a name via\n`function.name`. Exception (temporary): document/badge hooks prefixed with `use` and the\nHookState collection still set `displayName` — leave those until that API is reworked.\n\nFor typings, include `ref` on the props type: stop omitting `'ref'` from `HTMLProps` /\n`ComponentProps`, or intersect with `RefAttributes<T>`. Avoid `PropsWithRef` — in `@types/react`\n19 it is a deprecated identity alias and trips `typescript/no-deprecated`.\n\n## Testing\n\n### Unit Tests (Vitest)\n\n```bash\npnpm test                    # Run all tests\npnpm test -- --watch        # Watch mode\npnpm test -- -u             # Update snapshots\npnpm test -- --project=sanity  # Run specific project\n```\n\nTests require a build first because some tests use compiled output:\n\n```bash\npnpm build && pnpm test\n```\n\nOn macOS, use `TMPDIR=/private/tmp pnpm test` if the E2E summary reporter test fails with a\n`/var` versus `/private/var` path mismatch. The test changes its working directory, which resolves\nthe symlink; using a canonical temporary path keeps its expected and actual paths consistent.\n\n#### Types in tests\n\nApplies to every package's `*.test.ts` / `*.test.tsx` (and `*.browser.test.tsx`) files only.\nDo not use `as unknown as T` to force a value into a narrower public type. If a helper's\nreturn type is not in the union the function accepts, cover that behaviour through a surface\nthat already accepts the value, or drop that case from the typed call. Do not widen a public\nunion solely so a test type-checks. A single `as T` on a deliberately incomplete object is\nfine when the test is the error path for a missing field. This rule does not apply to\nproduction source.\n\n#### Prefer real implementations over test-file doubles\n\nDrive the unit through its production API and the same factories production uses.\nDo not add mock functions, stub components, or a parallel compiler to the test file\njust to reach a branch.\n\n- Compile schemas with Studio `createSchema` plus `defineType` / `defineField`\n  (`packages/sanity/src/core/schema/createSchema.ts`). That path validates, parents\n  the builtin schema, and runs `inferValidation`. Do not hand-roll\n  `@sanity/schema` `Schema.compile({types: [...]})` unless the test is about that\n  compiler.\n- Call the production function (`createPrepareFormState()`, an exported mapper)\n  without `vi.fn` decorator spies unless the suite is about call counts.\n- Typed lookups of a production union (`fieldMember`, `errorMember`) are accessors,\n  not doubles. A schema-construction helper for a depth fixture is fine. A second\n  fake form tree, or a local `vi.mock` of the module under test, is not.\n- A harness that needs a mock client and a page of `vi.fn` callbacks is the wrong\n  seam when the unit is a pure functio\n\n<!-- [TRUNCATED AT 50KB] -->","CLAUDE.md":"@AGENTS.md\n","packages/@repo/ailf/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Failf%2FREADME.md -->","packages/@repo/debug-proxy/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fdebug-proxy%2FREADME.md -->","packages/@repo/pandacss/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fpandacss%2FREADME.md -->","packages/@repo/release-notes/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Frelease-notes%2FREADME.md -->","packages/@sanity/diff/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fdiff%2FREADME.md -->","packages/@sanity/mutator/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fmutator%2FREADME.md -->","packages/@sanity/schema/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2FREADME.md -->","packages/@sanity/schema/src/descriptors/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2Fsrc%2Fdescriptors%2FREADME.md -->","packages/@sanity/util/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Futil%2FREADME.md -->","packages/@sanity/validation/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvalidation%2FREADME.md -->","packages/@sanity/vision/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvision%2FREADME.md -->","packages/groq/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fgroq%2FREADME.md -->","packages/sanity/AGENTS.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FAGENTS.md -->","packages/sanity/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FREADME.md -->","packages/sanity/src/core/error/sentry/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Ferror%2Fsentry%2FREADME.md -->","packages/sanity/src/core/scheduled-publishing/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2FREADME.md -->","packages/sanity/src/core/scheduled-publishing/components/dateInputs/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2Fcomponents%2FdateInputs%2FREADME.md -->","packages/sanity/src/core/store/events/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fstore%2Fevents%2FREADME.md -->","packages/sanity/src/core/variants/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fvariants%2FREADME.md -->","packages/sanity/src/router/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Frouter%2FREADME.md -->"},"files":{".agents/skills/before-and-after/SKILL.md":"---\nname: before-and-after\ndescription: Add existing screenshots or screen recordings to a GitHub pull request as a before/after or preview block. Use when a PR needs visual media attached to its description. Browser navigation and capture belong to agent-browser.\n---\n\n# Add visual media to a PR\n\nUse `agent-browser` to create the screenshots or recordings. This skill only owns the GitHub PR attachment workflow.\n\n## Capture\n\n1. Load the version-matched core instructions with `agent-browser skills get core --full` and follow them for sessions, navigation, page state, screenshots, and recordings.\n2. If a Vercel URL is protected, load `agent-browser skills get protected-vercel-deployments --full`. Do not reproduce its authentication workflow here.\n3. Save media under the repository with paths that contain no whitespace, for example:\n\n   ```text\n   captures/desktop-before.png\n   captures/desktop-after.png\n   captures/mobile-before.png\n   captures/mobile-after.png\n   ```\n\nThe formatter supports PNG, JPEG, GIF, WebP, MP4, MOV, and WebM files. Use an `--after` file without a matching `--before` file for a net-new preview.\n\n### Screen recordings\n\n`agent-browser record start` creates a fresh browser context. It preserves cookies and local storage, but an origin-scoped header used to open a protected Vercel Preview may not carry into that new context. Before recording a protected Preview:\n\n1. Load `agent-browser skills get protected-vercel-deployments --full`.\n2. Start the recording context on a blank page.\n3. Apply the authentication method from that skill inside the recording context.\n4. Navigate to the Preview only after authentication is active, then trim the navigation lead-in if necessary.\n\nDo not assume that a page which worked before `record start` will remain authenticated after it.\n\nAgent-browser `0.35.2` and `0.36.0` capture at a hardcoded 10 fps with no CLI or environment override. Inspect the installed version and its recording reference rather than assuming this stays true in later releases. Preserve the source cadence when transcoding: changing the container to 30 or 60 fps only duplicates frames and does not make motion smoother. For animation evidence that requires a higher real frame rate, use a genuinely higher-cadence capture path instead of upsampling agent-browser output.\n\n### Equal-height image pairs\n\nGitHub vertically centers a shorter image inside a Markdown table cell. For full-page before/after screenshots, make both files the same pixel height so their top edges align:\n\n1. Open both pages at the same viewport and state.\n2. Read `document.documentElement.scrollHeight` in both sessions.\n3. Append bottom-only space to the shorter page until both scroll heights match, then take both `--full` screenshots.\n\nThe padding may be transparent or use the capture tool's default canvas. Never add space above the page. For component or section comparisons, capture the same scoped region instead of padding unrelated page content.\n\nUse raw `agent-browser eval` for this DOM-only adjustment; do not add an image-processing dependency to this skill. Confirm the resulting files have equal pixel dimensions before publishing.\n\n## Format\n\nPass one `--before` and `--after` pair for each comparison. Repeat `--label` to identify multiple pairs:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  --before captures/mobile-before.png \\\n  --after captures/mobile-after.png \\\n  --label Desktop \\\n  --label Mobile \\\n  > /tmp/before-and-after.md\n```\n\nFor an after-only preview:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --after captures/new-page.png \\\n  > /tmp/before-and-after.md\n```\n\nAdd `--attribution \"<name>\"` to prefix the block with a `> Before/after by <name>` line when the PR should credit who produced the evidence.\n\nImages render in tables. Local videos initially render on their own lines so `gh --attach` can upload them and expose their final attachment URLs. Before/after video tables use the two-step workflow below because `gh --attach` does not rewrite local references inside `<video src>` attributes.\n\n## Place the evidence\n\nRead the existing PR description before inserting a new marked block. Put visual evidence near the top, after the short opening context and an existing Preview or deployment-link section when present, but before implementation-heavy sections such as Details, Changes, Testing, or Notes.\n\nUse this reading order inside the visual evidence:\n\n1. Put the real before/after or Preview evidence that proves the PR first.\n2. Put supplemental formats, alternate states, or demonstrations after the primary evidence.\n3. Label anything that demonstrates this skill rather than the PR itself as a demo, and state material limitations beside it. For example, note the installed agent-browser recorder's current 10 fps limit when motion smoothness matters.\n\nHeadings are semantic hints, not required names. Never invent or rewrite prose merely to create an anchor, and never split a paragraph, list, table, code block, or other Markdown structure. If no safe anchor is clear, append the block rather than risking damage. If a marked block already exists, move or replace that whole block only; preserve every byte of unrelated prose.\n\nAfter publishing, open the rendered PR and confirm the primary evidence appears before supplemental demos and before the implementation details.\n\n## Publish\n\nPreserve the existing PR description and replace only this skill's marked block:\n\n```bash\nPR=123\ngh pr view \"$PR\" --json body --jq .body > /tmp/pr-body.md\n\nnode skill/scripts/format.mjs \\\n  --body-file /tmp/pr-body.md \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  > /tmp/pr-body-next.md\n\nATTACH_ARGS=()\nwhile IFS= read -r file; do\n  ATTACH_ARGS+=(--attach \"$file\")\ndone < <(\n  node skill/scripts/format.mjs \\\n    --attach-list \\\n    --before captures/desktop-before.png \\\n    --after captures/desktop-after.png\n)\n\ngh pr edit \"$PR\" --body-file /tmp/pr-body-next.md \"${ATTACH_ARGS[@]}\"\n```\n\nRun the formatter and `gh` from the same directory. `gh --attach` uploads the local files to GitHub and rewrites their matching local references in the PR body.\n\nAfter publishing, fetch or open the PR description and confirm that no `./captures/...` references remain inside the marked block and that the evidence appears in the intended reading order.\n\n### Publish a video table\n\nVideo comparisons are a first-class two-step publish operation:\n\n1. Upload the local videos in a temporary PR comment using the normal own-line output and `gh pr comment --attach`.\n2. Fetch that comment through `gh api` and collect the stable `https://github.com/user-attachments/assets/...` URLs in before/after order.\n3. Generate the final HTML table from those URLs and replace the marked PR block:\n\n   ```bash\n   node skill/scripts/format.mjs \\\n     --body-file /tmp/pr-body.md \\\n     --before-video-url https://github.com/user-attachments/assets/BEFORE_ID \\\n     --after-video-url https://github.com/user-attachments/assets/AFTER_ID \\\n     --label \"Desktop hero\" \\\n     > /tmp/pr-body-next.md\n\n   gh pr edit \"$PR\" --body-file /tmp/pr-body-next.md\n   ```\n\n4. Fetch the edited PR body before deleting the temporary comment. Confirm both final URLs are present and no local video paths remain, then delete the comment.\n5. Open the rendered PR and confirm both `<video>` elements are inside the comparison table, reach a playable ready state, and show controls.\n\nIf URL extraction, formatting, or PR verification fails, keep the temporary comment so its uploaded attachments remain recoverable and retry from the last successful phase. Use own-line videos as the simple fallback.\n\nDo not publish captures containing Vercel OIDC tokens, bypass secrets, authenticated query parameters, or browser state files.\n\n## Script contract\n\n`scripts/format.mjs` is intentionally the only bundled script. It:\n\n- formats existing local media;\n- formats final GitHub video attachment URLs as HTML comparison tables;\n- labels after-only media as `Preview`;\n- emits the exact attachment path list;\n- inserts or replaces `<!-- before-and-after:start/end -->` without changing other PR prose.\n\nIts arguments version with this skill and are not a public library API.\n",".agents/skills/code-review-and-quality/SKILL.md":"---\nname: code-review-and-quality\ndescription: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.\n---\n\n# Code Review and Quality\n\n## Overview\n\nMulti-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.\n\n**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.\n\n## When to Use\n\n- Before merging any PR or change\n- After completing a feature implementation\n- When another agent or model produced code you need to evaluate\n- When refactoring existing code\n- After any bug fix (review both the fix and the regression test)\n\n## The Five-Axis Review\n\nEvery review evaluates code across these dimensions:\n\n### 1. Correctness\n\nDoes the code do what it claims to do?\n\n- Does it match the spec or task requirements?\n- Are edge cases handled (null, empty, boundary values)?\n- Are error paths handled (not just the happy path)?\n- Does it pass all tests? Are the tests actually testing the right things?\n- Are there off-by-one errors, race conditions, or state inconsistencies?\n\n### 2. Readability & Simplicity\n\nCan another engineer (or agent) understand this code without the author explaining it?\n\n- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)\n- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?\n- Is the code organized logically (related code grouped, clear module boundaries)?\n- Are there any \"clever\" tricks that should be simplified?\n- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)\n- **Are abstractions earning their complexity?** (Don't generalize until the third use case)\n- Would comments help clarify non-obvious intent? (But don't comment obvious code.)\n- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?\n\n### 3. Architecture\n\nDoes the change fit the system's design?\n\n- Does it follow existing patterns or introduce a new one? If new, is it justified?\n- Does it maintain clean module boundaries?\n- Is there code duplication that should be shared?\n- Are dependencies flowing in the right direction (no circular dependencies)?\n- Is the abstraction level appropriate (not over-engineered, not too coupled)?\n\n### 4. Security\n\nFor detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?\n\n- Is user input validated and sanitized?\n- Are secrets kept out of code, logs, and version control?\n- Is authentication/authorization checked where needed?\n- Are SQL queries parameterized (no string concatenation)?\n- Are outputs encoded to prevent XSS?\n- Are dependencies from trusted sources with no known vulnerabilities?\n- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?\n- Are external data flows validated at system boundaries before use in logic or rendering?\n\n### 5. Performance\n\nFor detailed profiling and optimization, see `performance-optimization`. Does the change introduce performance problems?\n\n- Any N+1 query patterns?\n- Any unbounded loops or unconstrained data fetching?\n- Any synchronous operations that should be async?\n- Any unnecessary re-renders in UI components?\n- Any missing pagination on list endpoints?\n- Any large objects created in hot paths?\n\n## Change Sizing\n\nSmall, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:\n\n```\n~100 lines changed   → Good. Reviewable in one sitting.\n~300 lines changed   → Acceptable if it's a single logical change.\n~1000 lines changed  → Too large. Split it.\n```\n\n**What counts as \"one change\":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.\n\n**Splitting strategies when a change is too large:**\n\n| Strategy          | How                                                     | When                    |\n| ----------------- | ------------------------------------------------------- | ----------------------- |\n| **Stack**         | Submit a small change, start the next one based on it   | Sequential dependencies |\n| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns  |\n| **Horizontal**    | Create shared code/stubs first, then consumers          | Layered architecture    |\n| **Vertical**      | Break into smaller full-stack slices of the feature     | Feature work            |\n\n**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.\n\n**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.\n\n## Change Descriptions\n\nEvery change needs a description that stands alone in version control history.\n\n**First line:** Short, imperative, standalone. \"Delete the FizzBuzz RPC\" not \"Deleting the FizzBuzz RPC.\" Must be informative enough that someone searching history can understand the change without reading the diff.\n\n**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.\n\n**Anti-patterns:** \"Fix bug,\" \"Fix build,\" \"Add patch,\" \"Moving code from A to B,\" \"Phase 1,\" \"Add convenience functions.\"\n\n## Review Process\n\n### Step 1: Understand the Context\n\nBefore looking at code, understand the intent:\n\n```\n- What is this change trying to accomplish?\n- What spec or task does it implement?\n- What is the expected behavior change?\n```\n\n### Step 2: Review the Tests First\n\nTests reveal intent and coverage:\n\n```\n- Do tests exist for the change?\n- Do they test behavior (not implementation details)?\n- Are edge cases covered?\n- Do tests have descriptive names?\n- Would the tests catch a regression if the code changed?\n```\n\n### Step 3: Review the Implementation\n\nWalk through the code with the five axes in mind:\n\n```\nFor each file changed:\n1. Correctness: Does this code do what the test says it should?\n2. Readability: Can I understand this without help?\n3. Architecture: Does this fit the system?\n4. Security: Any vulnerabilities?\n5. Performance: Any bottlenecks?\n```\n\n### Step 4: Categorize Findings\n\nLabel every comment with its severity so the author knows what's required vs optional:\n\n| Prefix                        | Meaning            | Author Action                                           |\n| ----------------------------- | ------------------ | ------------------------------------------------------- |\n| _(no prefix)_                 | Required change    | Must address before merge                               |\n| **Critical:**                 | Blocks merge       | Security vulnerability, data loss, broken functionality |\n| **Nit:**                      | Minor, optional    | Author may ignore — formatting, style preferences       |\n| **Optional:** / **Consider:** | Suggestion         | Worth considering but not required                      |\n| **FYI**                       | Informational only | No action needed — context for future reference         |\n\nThis prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.\n\n### Step 5: Verify the Verification\n\nCheck the author's verification story:\n\n```\n- What tests were run?\n- Did the build pass?\n- Was the change tested manually?\n- Are there screenshots for UI changes?\n- Is there a before/after comparison?\n```\n\n## Multi-Model Review Pattern\n\nUse different models for different review perspectives:\n\n```\nModel A writes the code\n    │\n    ▼\nModel B reviews for correctness and architecture\n    │\n    ▼\nModel A addresses the feedback\n    │\n    ▼\nHuman makes the final call\n```\n\nThis catches issues that a single model might miss — different models have different blind spots.\n\n**Example prompt for a review agent:**\n\n```\nReview this code change for correctness, security, and adherence to\nour project conventions. The spec says [X]. The change should [Y].\nFlag any issues as Critical, Important, or Suggestion.\n```\n\n## Dead Code Hygiene\n\nAfter any refactoring or implementation change, check for orphaned code:\n\n1. Identify code that is now unreachable or unused\n2. List it explicitly\n3. **Ask before deleting:** \"Should I remove these now-unused elements: [list]?\"\n\nDon't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.\n\n```\nDEAD CODE IDENTIFIED:\n- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()\n- OldTaskCard component in src/components/ — replaced by TaskCard\n- LEGACY_API_URL constant in src/config.ts — no remaining references\n→ Safe to remove these?\n```\n\n## Review Speed\n\nSlow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.\n\n- **Respond within one business day** — this is the maximum, not the target\n- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day\n- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed\n- **Large changes:** Ask the author to split them rather than reviewing one massive changeset\n\n## Handling Disagreements\n\nWhen resolving review disputes, apply this hierarchy:\n\n1. **Technical facts and data** override opinions and preferences\n2. **Style guides** are the absolute authority on style matters\n3. **Software design** must be evaluated on engineering principles, not personal preference\n4. **Codebase consistency** is acceptable if it doesn't degrade overall health\n\n**Don't accept \"I'll clean it up later.\"** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.\n\n## Honesty in Review\n\nWhen reviewing code — whether written by you, another agent, or a human:\n\n- **Don't rubber-stamp.** \"LGTM\" without evidence of review helps no one.\n- **Don't soften real issues.** \"This might be a minor concern\" when it's a bug that will hit production is dishonest.\n- **Quantify problems when possible.** \"This N+1 query will add ~50ms per item in the list\" is better than \"this could be slow.\"\n- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.\n- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.\n\n## Dependency Discipline\n\nPart of code review is dependency review:\n\n**Before adding any dependency:**\n\n1. Does the existing stack solve this? (Often it does.)\n2. How large is the dependency? (Check bundle impact.)\n3. Is it actively maintained? (Check last commit, open issues.)\n4. Does it have known vulnerabilities? (`npm audit`)\n5. What's the license? (Must be compatible with the project.)\n\n**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.\n\n## The Review Checklist\n\n```markdown\n## Review: [PR/Change title]\n\n### Context\n\n- [ ] I understand what this change does and why\n\n### Correctness\n\n- [ ] Change matches spec/task requirements\n- [ ] Edge cases handled\n- [ ] Error paths handled\n- [ ] Tests cover the change adequately\n\n### Readability\n\n- [ ] Names are clear and consistent\n- [ ] Logic is straightforward\n- [ ] No unnecessary complexity\n\n### Architecture\n\n- [ ] Follows existing patterns\n- [ ] No unnecessary coupling or dependencies\n- [ ] Appropriate abstraction level\n\n### Security\n\n- [ ] No secrets in code\n- [ ] Input validated at boundaries\n- [ ] No injection vulnerabilities\n- [ ] Auth checks in place\n- [ ] External data sources treated as untrusted\n\n### Performance\n\n- [ ] No N+1 patterns\n- [ ] No unbounded operations\n- [ ] Pagination on list endpoints\n\n### Verification\n\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] Manual verification done (if applicable)\n\n### Verdict\n\n- [ ] **Approve** — Ready to merge\n- [ ] **Request changes** — Issues must be addressed\n```\n\n## See Also\n\n- For detailed security review guidance, see `references/security-checklist.md`\n- For performance review checks, see `references/performance-checklist.md`\n\n## Common Rationalizations\n\n| Rationalization                      | Reality                                                                                                                   |\n| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |\n| \"It works, that's good enough\"       | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds.                           |\n| \"I wrote it, so I know it's correct\" | Authors are blind to their own assumptions. Every change benefits from another set of eyes.                               |\n| \"We'll clean it up later\"            | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after.                      |\n| \"AI-generated code is probably fine\" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong.                                     |\n| \"The tests pass, so it's good\"       | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |\n\n## Red Flags\n\n- PRs merged without any review\n- Review that only checks if tests pass (ignoring other axes)\n- \"LGTM\" without evidence of actual review\n- Security-sensitive changes without security-focused review\n- Large PRs that are \"too big to review properly\" (split them)\n- No regression tests with bug fix PRs\n- Review comments without severity labels — makes it unclear what's required vs optional\n- Accepting \"I'll fix it later\" — it never happens\n\n## Verification\n\nAfter review is complete:\n\n- [ ] All Critical issues are resolved\n- [ ] All Important issues are resolved or explicitly deferred with justification\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] The verification story is documented (what changed, how it was verified)\n",".agents/skills/code-simplification/SKILL.md":"---\nname: code-simplification\ndescription: Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity.\n---\n\n# Code Simplification\n\n> Inspired by the [Claude Code Simplifier plugin](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/code-simplifier/agents/code-simplifier.md). Adapted here as a model-agnostic, process-driven skill for any AI coding agent.\n\n## Overview\n\nSimplify code by reducing complexity while preserving exact behavior. The goal is not fewer lines — it's code that is easier to read, understand, modify, and debug. Every simplification must pass a simple test: \"Would a new team member understand this faster than the original?\"\n\n## When to Use\n\n- After a feature is working and tests pass, but the implementation feels heavier than it needs to be\n- During code review when readability or complexity issues are flagged\n- When you encounter deeply nested logic, long functions, or unclear names\n- When refactoring code written under time pressure\n- When consolidating related logic scattered across files\n- After merging changes that introduced duplication or inconsistency\n\n**When NOT to use:**\n\n- Code is already clean and readable — don't simplify for the sake of it\n- You don't understand what the code does yet — comprehend before you simplify\n- The code is performance-critical and the \"simpler\" version would be measurably slower\n- You're about to rewrite the module entirely — simplifying throwaway code wastes effort\n\n## The Five Principles\n\n### 1. Preserve Behavior Exactly\n\nDon't change what the code does — only how it expresses it. All inputs, outputs, side effects, error behavior, and edge cases must remain identical. If you're not sure a simplification preserves behavior, don't make it.\n\n```\nASK BEFORE EVERY CHANGE:\n→ Does this produce the same output for every input?\n→ Does this maintain the same error behavior?\n→ Does this preserve the same side effects and ordering?\n→ Do all existing tests still pass without modification?\n```\n\n### 2. Follow Project Conventions\n\nSimplification means making code more consistent with the codebase, not imposing external preferences. Before simplifying:\n\n```\n1. Read CLAUDE.md / project conventions\n2. Study how neighboring code handles similar patterns\n3. Match the project's style for:\n   - Import ordering and module system\n   - Function declaration style\n   - Naming conventions\n   - Error handling patterns\n   - Type annotation depth\n```\n\nSimplification that breaks project consistency is not simplification — it's churn.\n\n### 3. Prefer Clarity Over Cleverness\n\nExplicit code is better than compact code when the compact version requires a mental pause to parse.\n\n```typescript\n// UNCLEAR: Dense ternary chain\nconst label = isNew ? 'New' : isUpdated ? 'Updated' : isArchived ? 'Archived' : 'Active'\n\n// CLEAR: Readable mapping\nfunction getStatusLabel(item: Item): string {\n  if (item.isNew) return 'New'\n  if (item.isUpdated) return 'Updated'\n  if (item.isArchived) return 'Archived'\n  return 'Active'\n}\n```\n\n```typescript\n// UNCLEAR: Chained reduces with inline logic\nconst result = items.reduce(\n  (acc, item) => ({\n    ...acc,\n    [item.id]: {...acc[item.id], count: (acc[item.id]?.count ?? 0) + 1},\n  }),\n  {},\n)\n\n// CLEAR: Named intermediate step\nconst countById = new Map<string, number>()\nfor (const item of items) {\n  countById.set(item.id, (countById.get(item.id) ?? 0) + 1)\n}\n```\n\n### 4. Maintain Balance\n\nSimplification has a failure mode: over-simplification. Watch for these traps:\n\n- **Inlining too aggressively** — removing a helper that gave a concept a name makes the call site harder to read\n- **Combining unrelated logic** — two simple functions merged into one complex function is not simpler\n- **Removing \"unnecessary\" abstraction** — some abstractions exist for extensibility or testability, not complexity\n- **Optimizing for line count** — fewer lines is not the goal; easier comprehension is\n\n### 5. Scope to What Changed\n\nDefault to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions.\n\n## The Simplification Process\n\n### Step 1: Understand Before Touching (Chesterton's Fence)\n\nBefore changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies.\n\n```\nBEFORE SIMPLIFYING, ANSWER:\n- What is this code's responsibility?\n- What calls it? What does it call?\n- What are the edge cases and error paths?\n- Are there tests that define the expected behavior?\n- Why might it have been written this way? (Performance? Platform constraint? Historical reason?)\n- Check git blame: what was the original context for this code?\n```\n\nIf you can't answer these, you're not ready to simplify. Read more context first.\n\n### Step 2: Identify Simplification Opportunities\n\nScan for these patterns — each one is a concrete signal, not a vague smell:\n\n**Structural complexity:**\n\n| Pattern                    | Signal                             | Simplification                                            |\n| -------------------------- | ---------------------------------- | --------------------------------------------------------- |\n| Deep nesting (3+ levels)   | Hard to follow control flow        | Extract conditions into guard clauses or helper functions |\n| Long functions (50+ lines) | Multiple responsibilities          | Split into focused functions with descriptive names       |\n| Nested ternaries           | Requires mental stack to parse     | Replace with if/else chains, switch, or lookup objects    |\n| Boolean parameter flags    | `doThing(true, false, true)`       | Replace with options objects or separate functions        |\n| Repeated conditionals      | Same `if` check in multiple places | Extract to a well-named predicate function                |\n\n**Naming and readability:**\n\n| Pattern                    | Signal                                         | Simplification                                                           |\n| -------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |\n| Generic names              | `data`, `result`, `temp`, `val`, `item`        | Rename to describe the content: `userProfile`, `validationErrors`        |\n| Abbreviated names          | `usr`, `cfg`, `btn`, `evt`                     | Use full words unless the abbreviation is universal (`id`, `url`, `api`) |\n| Misleading names           | Function named `get` that also mutates state   | Rename to reflect actual behavior                                        |\n| Comments explaining \"what\" | `// increment counter` above `count++`         | Delete the comment — the code is clear enough                            |\n| Comments explaining \"why\"  | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express                    |\n\n**Redundancy:**\n\n| Pattern                   | Signal                                                       | Simplification                                            |\n| ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |\n| Duplicated logic          | Same 5+ lines in multiple places                             | Extract to a shared function                              |\n| Dead code                 | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead)                 |\n| Unnecessary abstractions  | Wrapper that adds no value                                   | Inline the wrapper, call the underlying function directly |\n| Over-engineered patterns  | Factory-for-a-factory, strategy-with-one-strategy            | Replace with the simple direct approach                   |\n| Redundant type assertions | Casting to a type that's already inferred                    | Remove the assertion                                      |\n\n### Step 3: Apply Changes Incrementally\n\nMake one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them.\n\n```\nFOR EACH SIMPLIFICATION:\n1. Make the change\n2. Run the test suite\n3. If tests pass → commit (or continue to next simplification)\n4. If tests fail → revert and reconsider\n```\n\nAvoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it.\n\n**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review.\n\n### Step 4: Verify the Result\n\nAfter all simplifications, step back and evaluate the whole:\n\n```\nCOMPARE BEFORE AND AFTER:\n- Is the simplified version genuinely easier to understand?\n- Did you introduce any new patterns inconsistent with the codebase?\n- Is the diff clean and reviewable?\n- Would a teammate approve this change?\n```\n\nIf the \"simplified\" version is harder to understand or review, revert. Not every simplification attempt succeeds.\n\n## Language-Specific Guidance\n\n### TypeScript / JavaScript\n\n```typescript\n// SIMPLIFY: Unnecessary async wrapper\n// Before\nasync function getUser(id: string): Promise<User> {\n  return await userService.findById(id)\n}\n// After\nfunction getUser(id: string): Promise<User> {\n  return userService.findById(id)\n}\n\n// SIMPLIFY: Verbose conditional assignment\n// Before\nlet displayName: string\nif (user.nickname) {\n  displayName = user.nickname\n} else {\n  displayName = user.fullName\n}\n// After\nconst displayName = user.nickname || user.fullName\n\n// SIMPLIFY: Manual array building\n// Before\nconst activeUsers: User[] = []\nfor (const user of users) {\n  if (user.isActive) {\n    activeUsers.push(user)\n  }\n}\n// After\nconst activeUsers = users.filter((user) => user.isActive)\n\n// SIMPLIFY: Redundant boolean return\n// Before\nfunction isValid(input: string): boolean {\n  if (input.length > 0 && input.length < 100) {\n    return true\n  }\n  return false\n}\n// After\nfunction isValid(input: string): boolean {\n  return input.length > 0 && input.length < 100\n}\n```\n\n### Python\n\n```python\n# SIMPLIFY: Verbose dictionary building\n# Before\nresult = {}\nfor item in items:\n    result[item.id] = item.name\n# After\nresult = {item.id: item.name for item in items}\n\n# SIMPLIFY: Nested conditionals with early return\n# Before\ndef process(data):\n    if data is not None:\n        if data.is_valid():\n            if data.has_permission():\n                return do_work(data)\n            else:\n                raise PermissionError(\"No permission\")\n        else:\n            raise ValueError(\"Invalid data\")\n    else:\n        raise TypeError(\"Data is None\")\n# After\ndef process(data):\n    if data is None:\n        raise TypeError(\"Data is None\")\n    if not data.is_valid():\n        raise ValueError(\"Invalid data\")\n    if not data.has_permission():\n        raise PermissionError(\"No permission\")\n    return do_work(data)\n```\n\n### React / JSX\n\n```tsx\n// SIMPLIFY: Verbose conditional rendering\n// Before\nfunction UserBadge({user}: Props) {\n  if (user.isAdmin) {\n    return <Badge variant=\"admin\">Admin</Badge>\n  } else {\n    return <Badge variant=\"default\">User</Badge>\n  }\n}\n// After\nfunction UserBadge({user}: Props) {\n  const variant = user.isAdmin ? 'admin' : 'default'\n  const label = user.isAdmin ? 'Admin' : 'User'\n  return <Badge variant={variant}>{label}</Badge>\n}\n\n// SIMPLIFY: Prop drilling through intermediate components\n// Before — consider whether context or composition solves this better.\n// This is a judgment call — flag it, don't auto-refactor.\n```\n\n## Common Rationalizations\n\n| Rationalization                                      | Reality                                                                                                                                               |\n| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"It's working, no need to touch it\"                  | Working code that's hard to read will be hard to fix when it breaks. Simplifying now saves time on every future change.                               |\n| \"Fewer lines is always simpler\"                      | A 1-line nested ternary is not simpler than a 5-line if/else. Simplicity is about comprehension speed, not line count.                                |\n| \"I'll just quickly simplify this unrelated code too\" | Unscoped simplification creates noisy diffs and risks regressions in code you didn't intend to change. Stay focused.                                  |\n| \"The types make it self-documenting\"                 | Types document structure, not intent. A well-named function explains _why_ better than a type signature explains _what_.                              |\n| \"This abstraction might be useful later\"             | Don't preserve speculative abstractions. If it's not used now, it's complexity without value. Remove it and re-add when needed.                       |\n| \"The original author must have had a reason\"         | Maybe. Check git blame — apply Chesterton's Fence. But accumulated complexity often has no reason; it's just the residue of iteration under pressure. |\n| \"I'll refactor while adding this feature\"            | Separate refactoring from feature work. Mixed changes are harder to review, revert, and understand in history.                                        |\n\n## Red Flags\n\n- Simplification that requires modifying tests to pass (you likely changed behavior)\n- \"Simplified\" code that is longer and harder to follow than the original\n- Renaming things to match your preferences rather than project conventions\n- Removing error handling because \"it makes the code cleaner\"\n- Simplifying code you don't fully understand\n- Batching many simplifications into one large, hard-to-review commit\n- Refactoring code outside the scope of the current task without being asked\n\n## Verification\n\nAfter completing a simplification pass:\n\n- [ ] All existing tests pass without modification\n- [ ] Build succeeds with no new warnings\n- [ ] Linter/formatter passes (no style regressions)\n- [ ] Each simplification is a reviewable, incremental change\n- [ ] The diff is clean — no unrelated changes mixed in\n- [ ] Simplified code follows project conventions (checked against CLAUDE.md or equivalent)\n- [ ] No error handling was removed or weakened\n- [ ] No dead code was left behind (unused imports, unreachable branches)\n- [ ] A teammate or review agent would approve the change as a net improvement\n",".agents/skills/deslop/SKILL.md":"---\nname: deslop\ndescription: Remove AI-generated code slop and clean up code style\n---\n\n# Remove AI code slop\n\nCheck the diff against main and remove AI-generated slop introduced in the branch.\n\n## Focus Areas\n\n- Extra comments that are unnecessary or inconsistent with local style\n- Defensive checks or try/catch blocks that are abnormal for trusted code paths\n- Casts to `any` used only to bypass type issues\n- Deeply nested code that should be simplified with early returns\n- Other patterns inconsistent with the file and surrounding codebase\n\n## Guardrails\n\n- Keep behavior unchanged unless fixing a clear bug.\n- Prefer minimal, focused edits over broad rewrites.\n- Keep the final summary concise (1-3 sentences).\n",".agents/skills/find-skills/SKILL.md":"---\nname: find-skills\ndescription: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n---\n\n# Find Skills\n\nThis skill helps you discover and install skills from the open agent skills ecosystem.\n\n## When to Use This Skill\n\nUse this skill when the user:\n\n- Asks \"how do I do X\" where X might be a common task with an existing skill\n- Says \"find a skill for X\" or \"is there a skill for X\"\n- Asks \"can you do X\" where X is a specialized capability\n- Expresses interest in extending agent capabilities\n- Wants to search for tools, templates, or workflows\n- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)\n\n## What is the Skills CLI?\n\nThe Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.\n\n**Key commands:**\n\n- `npx skills find [query]` - Search for skills interactively or by keyword\n- `npx skills add <package>` - Install a skill from GitHub or other sources\n- `npx skills check` - Check for skill updates\n- `npx skills update` - Update all installed skills\n\n**Browse skills at:** https://skills.sh/\n\n## How to Help Users Find Skills\n\n### Step 1: Understand What They Need\n\nWhen a user asks for help with something, identify:\n\n1. The domain (e.g., React, testing, design, deployment)\n2. The specific task (e.g., writing tests, creating animations, reviewing PRs)\n3. Whether this is a common enough task that a skill likely exists\n\n### Step 2: Search for Skills\n\nRun the find command with a relevant query:\n\n```bash\nnpx skills find [query]\n```\n\nFor example:\n\n- User asks \"how do I make my React app faster?\" → `npx skills find react performance`\n- User asks \"can you help me with PR reviews?\" → `npx skills find pr review`\n- User asks \"I need to create a changelog\" → `npx skills find changelog`\n\nThe command will return results like:\n\n```\nInstall with npx skills add <owner/repo@skill>\n\nvercel-labs/agent-skills@vercel-react-best-practices\n└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 3: Present Options to the User\n\nWhen you find relevant skills, present them to the user with:\n\n1. The skill name and what it does\n2. The install command they can run\n3. A link to learn more at skills.sh\n\nExample response:\n\n```\nI found a skill that might help! The \"vercel-react-best-practices\" skill provides\nReact and Next.js performance optimization guidelines from Vercel Engineering.\n\nTo install it:\nnpx skills add vercel-labs/agent-skills@vercel-react-best-practices\n\nLearn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 4: Offer to Install\n\nIf the user wants to proceed, you can install the skill for them:\n\n```bash\nnpx skills add <owner/repo@skill> -g -y\n```\n\nThe `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.\n\n## Common Skill Categories\n\nWhen searching, consider these common categories:\n\n| Category        | Example Queries                          |\n| --------------- | ---------------------------------------- |\n| Web Development | react, nextjs, typescript, css, tailwind |\n| Testing         | testing, jest, playwright, e2e           |\n| DevOps          | deploy, docker, kubernetes, ci-cd        |\n| Documentation   | docs, readme, changelog, api-docs        |\n| Code Quality    | review, lint, refactor, best-practices   |\n| Design          | ui, ux, design-system, accessibility     |\n| Productivity    | workflow, automation, git                |\n\n## Tips for Effective Searches\n\n1. **Use specific keywords**: \"react testing\" is better than just \"testing\"\n2. **Try alternative terms**: If \"deploy\" doesn't work, try \"deployment\" or \"ci-cd\"\n3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`\n\n## When No Skills Are Found\n\nIf no relevant skills exist:\n\n1. Acknowledge that no existing skill was found\n2. Offer to help with the task directly using your general capabilities\n3. Suggest the user could create their own skill with `npx skills init`\n\nExample:\n\n```\nI searched for skills related to \"xyz\" but didn't find any matches.\nI can still help you with this task directly! Would you like me to proceed?\n\nIf this is something you do often, you could create your own skill:\nnpx skills init my-xyz-skill\n```\n",".agents/skills/grill-me/SKILL.md":"---\nname: grill-me\ndescription: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions \"grill me\".\n---\n\nInterview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.\n\nIf a question can be answered by exploring the codebase, explore the codebase instead.\n",".agents/skills/improve-codebase-architecture/REFERENCE.md":"# Reference\n\n## Dependency Categories\n\nWhen assessing a candidate for deepening, classify its dependencies:\n\n### 1. In-process\n\nPure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly.\n\n### 2. Local-substitutable\n\nDependencies that have local test stand-ins (e.g., PGLite for Postgres, in-memory filesystem). Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite.\n\n### 3. Remote but owned (Ports & Adapters)\n\nYour own services across a network boundary (microservices, internal APIs). Define a port (interface) at the module boundary. The deep module owns the logic; the transport is injected. Tests use an in-memory adapter. Production uses the real HTTP/gRPC/queue adapter.\n\nRecommendation shape: \"Define a shared interface (port), implement an HTTP adapter for production and an in-memory adapter for testing, so the logic can be tested as one deep module even though it's deployed across a network boundary.\"\n\n### 4. True external (Mock)\n\nThird-party services (Stripe, Twilio, etc.) you don't control. Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock implementation.\n\n## Testing Strategy\n\nThe core principle: **replace, don't layer.**\n\n- Old unit tests on shallow modules are waste once boundary tests exist — delete them\n- Write new tests at the deepened module's interface boundary\n- Tests assert on observable outcomes through the public interface, not internal state\n- Tests should survive internal refactors — they describe behavior, not implementation\n\n## Issue Template\n\n<issue-template>\n\n## Problem\n\nDescribe the architectural friction:\n\n- Which modules are shallow and tightly coupled\n- What integration risk exists in the seams between them\n- Why this makes the codebase harder to navigate and maintain\n\n## Proposed Interface\n\nThe chosen interface design:\n\n- Interface signature (types, methods, params)\n- Usage example showing how callers use it\n- What complexity it hides internally\n\n## Dependency Strategy\n\nWhich category applies and how dependencies are handled:\n\n- **In-process**: merged directly\n- **Local-substitutable**: tested with [specific stand-in]\n- **Ports & adapters**: port definition, production adapter, test adapter\n- **Mock**: mock boundary for external services\n\n## Testing Strategy\n\n- **New boundary tests to write**: describe the behaviors to verify at the interface\n- **Old tests to delete**: list the shallow module tests that become redundant\n- **Test environment needs**: any local stand-ins or adapters required\n\n## Implementation Recommendations\n\nDurable architectural guidance that is NOT coupled to current file paths:\n\n- What the module should own (responsibilities)\n- What it should hide (implementation details)\n- What it should expose (the interface contract)\n- How callers should migrate to the new interface\n\n</issue-template>\n",".agents/skills/improve-codebase-architecture/SKILL.md":"---\nname: improve-codebase-architecture\ndescription: Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable.\n---\n\n# Improve Codebase Architecture\n\nExplore a codebase like an AI would, surface architectural friction, discover opportunities for improving testability, and propose module-deepening refactors as GitHub issue RFCs.\n\nA **deep module** (John Ousterhout, \"A Philosophy of Software Design\") has a small interface hiding a large implementation. Deep modules are more testable, more AI-navigable, and let you test at the boundary instead of inside.\n\n## Process\n\n### 1. Explore the codebase\n\nUse the Agent tool with subagent_type=Explore to navigate the codebase naturally. Do NOT follow rigid heuristics — explore organically and note where you experience friction:\n\n- Where does understanding one concept require bouncing between many small files?\n- Where are modules so shallow that the interface is nearly as complex as the implementation?\n- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called?\n- Where do tightly-coupled modules create integration risk in the seams between them?\n- Which parts of the codebase are untested, or hard to test?\n\nThe friction you encounter IS the signal.\n\n### 2. Present candidates\n\nPresent a numbered list of deepening opportunities. For each candidate, show:\n\n- **Cluster**: Which modules/concepts are involved\n- **Why they're coupled**: Shared types, call patterns, co-ownership of a concept\n- **Dependency category**: See [REFERENCE.md](REFERENCE.md) for the four categories\n- **Test impact**: What existing tests would be replaced by boundary tests\n\nDo NOT propose interfaces yet. Ask the user: \"Which of these would you like to explore?\"\n\n### 3. User picks a candidate\n\n### 4. Frame the problem space\n\nBefore spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:\n\n- The constraints any new interface would need to satisfy\n- The dependencies it would need to rely on\n- A rough illustrative code sketch to make the constraints concrete — this is not a proposal, just a way to ground the constraints\n\nShow this to the user, then immediately proceed to Step 5. The user reads and thinks about the problem while the sub-agents work in parallel.\n\n### 5. Design multiple interfaces\n\nSpawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.\n\nPrompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category, what's being hidden). This brief is independent of the user-facing explanation in Step 4. Give each agent a different design constraint:\n\n- Agent 1: \"Minimize the interface — aim for 1-3 entry points max\"\n- Agent 2: \"Maximize flexibility — support many use cases and extension\"\n- Agent 3: \"Optimize for the most common caller — make the default case trivial\"\n- Agent 4 (if applicable): \"Design around the ports & adapters pattern for cross-boundary dependencies\"\n\nEach sub-agent outputs:\n\n1. Interface signature (types, methods, params)\n2. Usage example showing how callers use it\n3. What complexity it hides internally\n4. Dependency strategy (how deps are handled — see [REFERENCE.md](REFERENCE.md))\n5. Trade-offs\n\nPresent designs sequentially, then compare them in prose.\n\nAfter comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not just a menu.\n\n### 6. User picks an interface (or accepts recommendation)\n\n### 7. Create GitHub issue\n\nCreate a refactor RFC as a GitHub issue using `gh issue create`. Use the template in [REFERENCE.md](REFERENCE.md). Do NOT ask the user to review before creating — just create it and share the URL.\n",".agents/skills/migrate-styled-components-to-panda-css/SKILL.md":"---\nname: migrate-styled-components-to-panda-css\ndescription: Step-by-step procedure for migrating components in the Sanity monorepo off styled-components to Panda CSS (zero-runtime, build-time extracted CSS). Use when converting styled() components to .css.ts files, creating .css.ts files, handling CSS specificity against @sanity/ui, styling child elements with Panda, or when asked to migrate styling to Panda CSS.\n---\n\n# Migrate styled-components to Panda CSS\n\nThe repeatable procedure for converting this monorepo's styling from `styled-components` (the\nStudio's legacy styling library) to [Panda CSS](https://panda-css.com). Adapted from the\n`sanity-io/plugins` skill for vanilla-extract, which this repo used before Panda.\n\n**The infrastructure is already wired — never touch it during a component migration.**\n`packages/sanity` builds its `.css.ts` files into `lib/bundle.css` through the `panda` option of\n`@sanity/tsdown-config` in its `tsdown.config.ts` (`panda.config.ts` next to it), exposes the\n`./bundle.css` export, registers `@pandacss/vite` in its `vitest.config.mts`, and the dev\nstudios, Storybook and the browser suite point their own `@pandacss/vite` instance at the\npackage (`packages/sanity/panda.config.ts` is the repository's only Panda project). A migration\nPR only converts component styles.\n\n## Ground rules\n\n- **Identical visual output is the goal.** This is a refactor, not a redesign — every rule and\n  specificity outcome must be preserved. Copy CSS values verbatim; do not \"improve\" them.\n- **Small PRs.** Migrate one component (or one tightly-coupled cluster) per PR, conventional-commit\n  titled, e.g. `refactor(structure): migrate Scroller to Panda CSS`.\n- **Reference implementations:**\n  `packages/sanity/src/core/changeIndicators/ElementWithChangeBar.*` (data attributes for\n  parent-state selectors), `packages/sanity/src/structure/panes/document/document-layout/DocumentLayout.*`,\n  and for the dynamic shapes `packages/sanity/src/core/components/popoverDialog/PopoverContainer.*`\n  (responsive widths through per-breakpoint variables) and\n  `packages/sanity/src/core/form/inputs/PortableText/text/TextBlock.styles.*` (per-level variants,\n  private custom properties, theme-driven colors).\n\n## Step 1: Inventory\n\nClassify each usage before touching code:\n\n```bash\nrg \"from 'styled-components'\" packages/sanity/src <target-dir>\n```\n\n| Usage                                               | Migration target                                                                                                          |\n| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| Static `styled.div` / `styled(Primitive)`           | `css()` in a colocated `.css.ts` + thin wrapper component (or plain `className`)                                          |\n| Overrides of `@sanity/ui` primitives' own styles    | `css()` with the `&&` specificity trick (see below)                                                                       |\n| Theme reads (`({theme}) => ...`, `getTheme_v2`)     | `--card-*` CSS custom properties if one exists, else a named custom property set through inline `style` + `useTheme_v2()` |\n| Prop-driven variants (`$isInvalid`, `css` branches) | one `css()` per state + conditional `clsx()` composition (the modifier doubled with `&&`), or an object of `css()` calls  |\n| `keyframes` animations                              | Panda `keyframes()` from `#styled-system/css`                                                                             |\n| Descendant selectors (`& img`, third-party classes) | class on the child directly, or a nested `'& img'` / `'& .third-party'` selector on the parent class                      |\n| `createGlobalStyle`                                 | nested selectors under a local class (never leak outside the component)                                                   |\n| `*.styled.tsx` modules                              | `.css.ts` + thin wrappers; keep the `.styled.tsx` file when call sites need it                                            |\n| Computed inline `style={{}}` objects                | static parts into `css()`, changing values through custom properties (Shape C below)                                      |\n\n## Step 2: Migrate the code\n\nCreate a `ComponentName.css.ts` next to the component. Only `.css.ts` files are Panda sources\n(`include` in `panda.config.ts`): a `css()` call anywhere else is neither extracted into\n`bundle.css` nor rewritten to class names, so its styles never render. Import from\n`#styled-system/css` (the generated runtime, mapped by the package's `imports`; it exists after\n`pnpm install`, which runs `panda codegen`). The build rewrites every statically resolvable call\ninto its class names, so the published code carries no Panda runtime — keep the style objects\nliteral (no loops, no values computed from imports) and pass the module's own constants only.\n\n**Shape A — keep the component layer.** When the styled element is used like a component, replace\nit with a `css()` rule plus a thin wrapper keeping the same name and API, so call sites don't\nchange. Type with `ComponentProps<typeof Primitive>` (or `ComponentProps<'div'>`) — never\n`forwardRef`; `ref` is a regular prop on React 19 and flows through the spread. **Merge, don't\nclobber:** a fixed `className={...}` after `{...props}` silently drops a caller's `className` —\nmerge with `clsx` (already a dependency):\n\n```tsx\nimport {Card} from '@sanity/ui'\nimport {clsx} from 'clsx'\nimport {type ComponentProps} from 'react'\n\nimport {floatingCard} from './FloatingCard.css'\n\nexport function FloatingCard(props: ComponentProps<typeof Card>) {\n  const {className, ...rest} = props\n  return <Card {...rest} className={clsx(floatingCard, className)} />\n}\n```\n\nRenaming a `Component.styled.ts` to `.tsx` (or `.ts` → `.tsx`) keeps extension-less import paths\nat call sites unchanged.\n\n**Shape B — flatten single-use wrappers.** When the styled element was an internal, single-use\n`styled.div` with no meaningful API, delete it and put the class directly on the element:\n`<div className={paragraph}>{children}</div>`.\n\n**Shape C — dynamic values through CSS variables.** First check whether `@sanity/ui` already\nexposes the value as a CSS custom property — prefer e.g. `var(--card-bg-color)` over JS theme\nreads (see `ElementWithChangeBar.css.ts`). Otherwise keep static parts in `css()` and bridge only\nchanging values with a named custom property the wrapper sets inline, reading the theme with\n`useTheme_v2()` (alias it `useTheme_v2 as useThemeV2` to satisfy the camelCase lint):\n\n```ts\n// Wrapper.css.ts\nimport {css} from '#styled-system/css'\n\n/** Set inline by `Wrapper` from the theme */\nexport const paddingVar = '--wrapper-padding'\nexport const wrapper = css({paddingBottom: `var(${paddingVar})`})\n```\n\n```tsx\n<div className={wrapper} style={{[paddingVar]: `${space[4]}px`} as CSSProperties} />\n```\n\nName the property after the component and the theme key (`--wrapper-space-4`), since it is\nglobal to the page; one class serves every instance.\n\n### Specificity against @sanity/ui: the `&&` trick\n\n`@sanity/ui` v4 still uses styled-components, which injects its styles at runtime — **after** the\nextracted stylesheet, so an equal-specificity class loses. When overriding a primitive's own\nstyles (e.g. `styled(Card)` overriding Card's background), double the class:\n\n```ts\nexport const transparentCard = css({\n  '&&': {background: 'none'},\n})\n```\n\nWhere styled-components silently won specificity battles via CSSOM insertion order, reach for\n`&&` rather than `!important`. Plain-element styles (`styled.div`) need no trick.\n\n### The cascade model (why order is not yours to rely on)\n\nThree stylesheets meet on a migrated element:\n\n| Sheet                                     | `sanity build`                                      | `sanity dev`                                                                                                |\n| ----------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Panda (`bundle.css`)                      | one static file, imported first by the entry chunks | one stylesheet (`@repo/pandacss/panda.css`) filled by `@pandacss/vite`, imported first by the runtime entry |\n| `@sanity/ui` (styled-components, runtime) | `<style data-styled>` appended on first render      | same                                                                                                        |\n| `ui5` (`styles.css`)                      | static file                                         | static file                                                                                                 |\n\nPanda writes its rules into cascade layers; the studio ships them without any (`cascadeLayers:\nfalse` for `bundle.css`, the same pass for the dev stylesheet — see the \"Panda CSS\" section of\nAGENTS.md). CSS outside any layer beats every layered rule regardless of specificity, so layers\nwould hand the studio's hover, focus and selected states to any unlayered rule that also matches.\nUnlayered, the studio's rules cascade like the vanilla-extract output did: by specificity, then\nsource order. Do not write `@layer` in a `.css.ts`.\n\nConsequences:\n\n- **Beat `@sanity/ui`'s rules by specificity, not by order.** `@sanity/ui`'s styled-components\n  rules are injected at runtime, after the static stylesheets, and `ui5`'s component classes load\n  as static CSS too; order is theirs, so a rule of yours that must win over one of theirs has to\n  be more specific: `&&` (0,2,0) beats a plain component class (0,1,0). If the primitive rule you\n  override already has two classes or a pseudo-class chain (e.g. Card's\n  `&[data-as='button']:not(:disabled)[data-selected]`), count its specificity and exceed it —\n  `&&&`, or `&&[data-as='button']`. Read the rule in `node_modules/@sanity/ui/dist/*.js` when\n  unsure. `!important` stays banned.\n- **Panda emits one rule per declaration, not one per class.** `css({display: 'flex', opacity: 1})`\n  returns two class names, one per declaration, and `bundle.css` holds every distinct\n  `property: value` pair once, grouped by property with the values **sorted alphabetically**. So\n  when two of your classes set the same property on one element (`clsx(rootBox, muted && rootBoxMuted)`),\n  the value that sorts last wins — `opacity: 1` beats `opacity: 0.8` whatever the declaration\n  order. A modifier that must win doubles its class: `rootBoxMuted = css({'&&': {opacity: 0.8}})`.\n  The same holds for a `styled(LocalStyledThing)` chain, which styled-components ordered for you.\n- **Shorthands come before longhands.** `padding: 0` in one class and `paddingLeft: '4px'` in\n  another on the same element always resolve to the longhand, regardless of order.\n- **Parent-state selectors key on data attributes, never on an empty class.** A Panda class name\n  is a list of atomic names, so `` `${parent}:hover &` `` cannot be built from it, and a class\n  that exists only to be targeted is not an option either. Have the parent render its identity\n  and state as data attributes (`data-change-bar-wrapper=\"\"`, `data-focused={hasFocus ? '' :\n  undefined}`) and write `'[data-change-bar-wrapper][data-focused] &': {...}` or\n  `'[data-change-bar-wrapper]:hover &': {...}` on the child — see `ElementWithChangeBar.css.ts`.\n  Read a state attribute together with the identity attribute: generic names like `data-focused`\n  and `data-disabled` are rendered by other components too, and `:not([data-changed])` on its own\n  matches every ancestor. Attributes a library component already renders (`'[data-ui=\"Card\"] &'`)\n  work the same way.\n- **`@media` blocks are grouped.** Panda emits a file's media queries after the base rules,\n  which is what styled-components effectively did too. Media conditions cannot read custom\n  properties, so theme breakpoints are written as literals from the default theme (table\n  below) with a comment naming the theme key they mirror.\n\n#### What each `@sanity/ui` primitive sets on itself\n\nUse `&&` when the migrated rule overrides one of these; plain `css()` otherwise.\n\n| Primitive                                                                                         | Own declarations (via its props or defaults)                                                                                                                                                                                                                                                                  |\n| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Box`                                                                                             | `display` (block, or the `display` prop), `box-sizing`, `flex`/`flex-shrink`/`flex-grow` props, `margin*`, `padding*`, `height`, `overflow`, `column`/`row` grid props, `list-style: none` for `as=\"ul\"/\"ol\"`                                                                                                 |\n| `Flex`                                                                                            | Box + `display: flex`, `flex-direction`, `flex-wrap`, `align-items`, `justify-content`, `gap`                                                                                                                                                                                                                 |\n| `Stack`                                                                                           | Box + `display: grid`, `grid-template-columns`, `grid-auto-rows`, `gap`                                                                                                                                                                                                                                       |\n| `Grid`, `Inline`                                                                                  | Box + `display`, `gap`, `grid-*`, `align-items`                                                                                                                                                                                                                                                               |\n| `Card`                                                                                            | Box + `background-color`, `color`, `color-scheme`, every `--card-*` variable, `border`/`border-color` (`border` prop), `border-radius` (`radius`), `box-shadow` (`shadow`, focus ring), button/link hover/pressed/selected/disabled variants, `outline`, `font`, `text-align`, `appearance`, `width` (button) |\n| `Container`                                                                                       | Box + `max-width`, `margin: 0 auto`                                                                                                                                                                                                                                                                           |\n| `Text`, `Label`, `Heading`, `Code`                                                                | `font-family`, `font-size`, `font-weight`, `line-height`, `letter-spacing`, `color` (`--card-fg-color` / muted / accent), `text-align`; descendant rules for `& code`, `& a` (color `--card-link-color`, focus ring), `& strong`, `& svg`; the inner `span` handles `text-overflow`                           |\n| `Button`                                                                                          | Card-like colors per `mode`/`tone`, `padding`, `border-radius`, `font`, `box-shadow`, `cursor`, `&:disabled`, `&:not(:disabled):hover`, `& [data-ui='Text']`                                                                                                                                                  |\n| `TextInput`, `TextArea`, `Select`                                                                 | wrapper `position: relative`; the inner `input`/`textarea`/`select` gets font, color, padding, border via presentation `span`; `--input-*` variables                                                                                                                                                          |\n| `Skeleton`, `TextSkeleton`, `LabelSkeleton`                                                       | `background-color`, `border-radius`, `animation`, `width`, `height`                                                                                                                                                                                                                                           |\n| `Popover`, `Tooltip`, `Layer`, `Dialog`                                                           | `position`, `z-index`, `pointer-events`, `max-width`; Popover/Tooltip content Card colors                                                                                                                                                                                                                     |\n| `Badge`, `KBD`, `Spinner`, `Switch`, `Checkbox`, `Radio`, `Avatar`, `Tab`, `TreeItem`, `MenuItem` | fully self-styled — treat every declaration as an override (`&&`)                                                                                                                                                                                                                                             |\n\n#### Theme reads → CSS variables\n\n`getTheme_v2(theme)` inside a styled template and `useTheme_v2()` inside the wrapper read the\nsame context, including the nearest `Card`'s `tone` and `scheme`. Prefer a variable `@sanity/ui`\nalready publishes on the nearest Card (they follow tone and scheme for free):\n\n| Theme read                                                        | CSS variable                                                                           |\n| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `color.bg`, `color.fg`, `color.border`, `color.icon`              | `--card-bg-color`, `--card-fg-color`, `--card-border-color`, `--card-icon-color`       |\n| `color.muted.bg`, `color.muted.fg`, `color.accent.fg`             | `--card-muted-bg-color`, `--card-muted-fg-color`, `--card-accent-fg-color`             |\n| `color.focusRing`, `color.backdrop`                               | `--card-focus-ring-color`, `--card-backdrop-color`                                     |\n| `color.badge.<tone>.{bg,fg,dot,icon}`                             | `--card-badge-<tone>-{bg,fg,dot,icon}-color`                                           |\n| `color.avatar.<hue>.{bg,fg}`                                      | `--card-avatar-<hue>-{bg,fg}-color`                                                    |\n| `color.link.fg`, `color.code.{bg,fg}`, `color.kbd.{bg,fg,border}` | `--card-link-fg-color`, `--card-code-{bg,fg}-color`, `--card-kbd-{bg,fg,border}-color` |\n| `color.shadow.{outline,umbra,penumbra,ambient}`                   | `--card-shadow-{outline,umbra,penumbra,ambient}-color`                                 |\n| `color.skeleton.{from,to}`                                        | `--card-skeleton-color-{from,to}`                                                      |\n| `color.hairline.{soft,hard}`                                      | `--card-hairline-{soft,hard}-color`                                                    |\n| card focus ring box-shadow (`focusRingStyle(...)`)                | `--card-focus-ring-box-shadow` (set by Card/Button on `:focus-visible`)                |\n\nEverything else — `color.selectable.*`, `color.solid.*`, `color.input.*`, `color.button.*`,\n`color.syntax.*`, `color._dark`, `space[n]`, `radius[n]`, `font.*`, `container[n]`,\n`avatar.sizes[n]`, `input.*`, `shadow[n]` — goes through Shape C: a named custom property in the\n`.css.ts`, `useTheme_v2()` + an inline `style` object in the wrapper. Keep the same theme key\nnames in the property names (`--wrapper-space-4`, `--wrapper-radius-2`) so the intent survives. Do\nnot hardcode scale values: a studio may build its own theme with different `space`, `radius`, or\n`font` scales.\n\nOnly `@media` conditions get literals, mirrored from the default theme:\n\n| Key         | Default values                         |\n| ----------- | -------------------------------------- |\n| `media`     | `[360, 600, 900, 1200, 1800, 2400]` px |\n| `container` | `[320, 640, 960, 1280, 1600, 1920]` px |\n\nWrite them as `'@media screen and (min-width: 600px)': {...}` with a comment such as\n`// media[1]`.\n\n### Wrapper contract\n\nA wrapper that replaces an exported styled component must keep call sites working without edits:\n\n- Same export name, same prop names — including transient `$props`, which become regular props\n  the wrapper consumes (do not rename them; callers in other directories still pass them).\n- `className` and `style` **merge** (`clsx`, spread) — never clobber.\n- `ref` flows through props (React 19). No `forwardRef`, no `displayName`.\n- `.attrs({...})` values become JSX props on the primitive inside the wrapper; `forwardedAs`\n  becomes `as`.\n- A prop-driven `css` branch becomes one `css()` per branch plus `clsx(cond && cls)`, the branch\n  doubled with `&&` when it overrides the base class (see the cascade model); a prop-driven\n  **value** becomes a custom property set inline. Prefer an object of `css()` calls for a closed\n  set of values (`root.narrow`, `root.wide`).\n- Shared `css` mixins (an `export const focusRingStyles = css` tagged template) become a\n  `css.raw({...})` object in a `.css.ts`; importers compose it with `css(mixin, {...})`, which\n  merges the objects before emitting the class names.\n- A test that asserted a computed style of a migrated component (`toHaveStyle`) cannot pass in\n  jsdom anymore (no stylesheet is loaded there). Assert the state through a `data-*` attribute the\n  wrapper sets instead; add the attribute if none exists.\n\n### Child and descendant selectors\n\nA nested key with `&` is a selector where `&` stands for the class's own element, in any\nposition: `'& img'`, `'& > span'`, `'& .third-party-class'`, `'[data-ui=\"Card\"] &'`. In order of\npreference:\n\n1. Put a class directly on the child if you render it yourself.\n2. `'[data-parent][data-state] &': {...}` on the child class for parent-state-dependent rules,\n   keyed on data attributes the parent renders (see the cascade model).\n3. `'& img': {...}` on the parent class when you don't control the child (library-rendered DOM) —\n   always scoped under a local class.\n\n### Strict property values\n\n`panda.config.ts` sets `strictPropertyValues`, which restricts enumerable properties (`display`,\n`position`, `scrollbarWidth`, …) to their CSS keywords and catches typos. A `var()` on such a\nproperty needs Panda's escape hatch, square brackets around the value:\n`scrollbarWidth: '[var(--scrollbar-width)]'` emits `scrollbar-width: var(--scrollbar-width)`.\n\n## Step 3: Verify\n\n```bash\npnpm lint:fix                                  # oxfmt + oxlint (includes type checking)\npnpm build && pnpm vitest run --project=sanity <affected test paths>\n```\n\nCheck `packages/sanity/lib/bundle.css` after the build: every migrated rule must appear there.\nThe build warns about a `css()` call it could not resolve statically; such a call keeps Panda's\nruntime in the bundle and must be rewritten with literal values.\n\nVerify **visual fidelity** in the dev studio (`pnpm dev`, see AGENTS.md for cloud-agent auth):\nexercise the migrated component and compare against the pre-migration rendering — theme tones,\nspacing, hover/focus states, stacking. Jsdom tests must not assert on Panda class names or\ncomputed styles (no stylesheet is loaded there — see AGENTS.md); assert on `data-testid`. For\nhigh-traffic components, add a Chromatic story per the `sanity-visual-regression` skill so the\nmigration is sentinel-protected.\n\n## End state\n\nThe monorepo's own code no longer imports `styled-components`; `eslint/no-restricted-imports` in\n`.oxlintrc.json` bans the module everywhere (the global rule and every override that repeats it).\nThe dependency itself stays declared because `@sanity/ui@4` requires it as a peer and still\ninjects its own styles at runtime — which is exactly why the cascade rules above still matter for\nevery new `.css.ts`. Use this skill for new components and for plugin or example code that still\ncarries styled-components.\n\n## Checklist\n\n- [ ] `styled-components` import removed from the migrated component; emptied `.styled.tsx`\n      modules deleted\n- [ ] `.css.ts` colocated; CSS values copied verbatim; component layer preserved where call sites\n      need it; `className` merged, not clobbered\n- [ ] `&&` used where the original relied on styled-components injection order to beat @sanity/ui,\n      and on modifier classes that override a base class on the same element\n- [ ] No build config, `package.json`, or exports changes (infra is already wired)\n- [ ] `pnpm lint:fix`, `pnpm build`, and affected tests pass; rules present in `lib/bundle.css`\n- [ ] Visual fidelity verified against the pre-migration rendering\n",".agents/skills/performance-optimization/SKILL.md":"---\nname: performance-optimization\ndescription: Optimizes application performance. Use when performance requirements exist, when you suspect performance regressions, or when Core Web Vitals or load times need improvement. Use when profiling reveals bottlenecks that need fixing.\n---\n\n# Performance Optimization\n\n## Overview\n\nMeasure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.\n\n## When to Use\n\n- Performance requirements exist in the spec (load time budgets, response time SLAs)\n- Users or monitoring report slow behavior\n- Core Web Vitals scores are below thresholds\n- You suspect a change introduced a regression\n- Building features that handle large datasets or high traffic\n\n**When NOT to use:** Don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains.\n\n## Core Web Vitals Targets\n\n| Metric                              | Good    | Needs Improvement | Poor    |\n| ----------------------------------- | ------- | ----------------- | ------- |\n| **LCP** (Largest Contentful Paint)  | ≤ 2.5s  | ≤ 4.0s            | > 4.0s  |\n| **INP** (Interaction to Next Paint) | ≤ 200ms | ≤ 500ms           | > 500ms |\n| **CLS** (Cumulative Layout Shift)   | ≤ 0.1   | ≤ 0.25            | > 0.25  |\n\n## The Optimization Workflow\n\n```\n1. MEASURE  → Establish baseline with real data\n2. IDENTIFY → Find the actual bottleneck (not assumed)\n3. FIX      → Address the specific bottleneck\n4. VERIFY   → Measure again, confirm improvement\n5. GUARD    → Add monitoring or tests to prevent regression\n```\n\n### Step 1: Measure\n\nTwo complementary approaches — use both:\n\n- **Synthetic (Lighthouse, DevTools Performance tab):** Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.\n- **RUM (web-vitals library, CrUX):** Real user data in real conditions. Required to validate that a fix actually improved user experience.\n\n**Frontend:**\n\n```bash\n# Synthetic: Lighthouse in Chrome DevTools (or CI)\n# Chrome DevTools → Performance tab → Record\n# Chrome DevTools MCP → Performance trace\n\n# RUM: Web Vitals library in code\nimport { onLCP, onINP, onCLS } from 'web-vitals';\n\nonLCP(console.log);\nonINP(console.log);\nonCLS(console.log);\n```\n\n**Backend:**\n\n```bash\n# Response time logging\n# Application Performance Monitoring (APM)\n# Database query logging with timing\n\n# Simple timing\nconsole.time('db-query');\nconst result = await db.query(...);\nconsole.timeEnd('db-query');\n```\n\n### Where to Start Measuring\n\nUse the symptom to decide what to measure first:\n\n```\nWhat is slow?\n├── First page load\n│   ├── Large bundle? --> Measure bundle size, check code splitting\n│   ├── Slow server response? --> Measure TTFB in DevTools Network waterfall\n│   │   ├── DNS long? --> Add dns-prefetch / preconnect for known origins\n│   │   ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive\n│   │   └── Waiting (server) long? --> Profile backend, check queries and caching\n│   └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking\n├── Interaction feels sluggish\n│   ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)\n│   ├── Form input lag? --> Check re-renders, controlled component overhead\n│   └── Animation jank? --> Check layout thrashing, forced reflows\n├── Page after navigation\n│   ├── Data loading? --> Measure API response times, check for waterfalls\n│   └── Client rendering? --> Profile component render time, check for N+1 fetches\n└── Backend / API\n    ├── Single endpoint slow? --> Profile database queries, check indexes\n    ├── All endpoints slow? --> Check connection pool, memory, CPU\n    └── Intermittent slowness? --> Check for lock contention, GC pauses, external deps\n```\n\n### Step 2: Identify the Bottleneck\n\nCommon bottlenecks by category:\n\n**Frontend:**\n\n| Symptom           | Likely Cause                                                 | Investigation                         |\n| ----------------- | ------------------------------------------------------------ | ------------------------------------- |\n| Slow LCP          | Large images, render-blocking resources, slow server         | Check network waterfall, image sizes  |\n| High CLS          | Images without dimensions, late-loading content, font shifts | Check layout shift attribution        |\n| Poor INP          | Heavy JavaScript on main thread, large DOM updates           | Check long tasks in Performance trace |\n| Slow initial load | Large bundle, many network requests                          | Check bundle size, code splitting     |\n\n**Backend:**\n\n| Symptom            | Likely Cause                                         | Investigation                    |\n| ------------------ | ---------------------------------------------------- | -------------------------------- |\n| Slow API responses | N+1 queries, missing indexes, unoptimized queries    | Check database query log         |\n| Memory growth      | Leaked references, unbounded caches, large payloads  | Heap snapshot analysis           |\n| CPU spikes         | Synchronous heavy computation, regex backtracking    | CPU profiling                    |\n| High latency       | Missing caching, redundant computation, network hops | Trace requests through the stack |\n\n### Step 3: Fix Common Anti-Patterns\n\n#### N+1 Queries (Backend)\n\n```typescript\n// BAD: N+1 — one query per task for the owner\nconst tasks = await db.tasks.findMany()\nfor (const task of tasks) {\n  task.owner = await db.users.findUnique({where: {id: task.ownerId}})\n}\n\n// GOOD: Single query with join/include\nconst tasks = await db.tasks.findMany({\n  include: {owner: true},\n})\n```\n\n#### Unbounded Data Fetching\n\n```typescript\n// BAD: Fetching all records\nconst allTasks = await db.tasks.findMany()\n\n// GOOD: Paginated with limits\nconst tasks = await db.tasks.findMany({\n  take: 20,\n  skip: (page - 1) * 20,\n  orderBy: {createdAt: 'desc'},\n})\n```\n\n#### Missing Image Optimization (Frontend)\n\n```html\n<!-- BAD: No dimensions, no format optimization -->\n<img src=\"/hero.jpg\" />\n\n<!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority -->\n<!--\n  Two techniques combined:\n  - Art direction (media): different crop/composition per breakpoint\n  - Resolution switching (srcset + sizes): right file size per screen density\n-->\n<picture>\n  <!-- Mobile: portrait crop (8:10) -->\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/avif\"\n  />\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/webp\"\n  />\n  <!-- Desktop: landscape crop (2:1) -->\n  <source\n    srcset=\"/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/avif\"\n  />\n  <source\n    srcset=\"/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/webp\"\n  />\n  <img\n    src=\"/hero-desktop.jpg\"\n    width=\"1200\"\n    height=\"600\"\n    fetchpriority=\"high\"\n    alt=\"Hero image description\"\n  />\n</picture>\n\n<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->\n<img\n  src=\"/content.webp\"\n  width=\"800\"\n  height=\"400\"\n  loading=\"lazy\"\n  decoding=\"async\"\n  alt=\"Content image description\"\n/>\n```\n\n#### Unnecessary Re-renders (React)\n\n```tsx\n// BAD: Creates new object on every render, causing children to re-render\nfunction TaskList() {\n  return <TaskFilters options={{sortBy: 'date', order: 'desc'}} />\n}\n\n// GOOD: Stable reference\nconst DEFAULT_OPTIONS = {sortBy: 'date', order: 'desc'} as const\nfunction TaskList() {\n  return <TaskFilters options={DEFAULT_OPTIONS} />\n}\n\n// Use React.memo for expensive components\nconst TaskItem = React.memo(function TaskItem({task}: Props) {\n  return <div>{/* expensive render */}</div>\n})\n\n// Use useMemo for expensive computations\nfunction TaskStats({tasks}: Props) {\n  const stats = useMemo(() => calculateStats(tasks), [tasks])\n  return (\n    <div>\n      {stats.completed} / {stats.total}\n    </div>\n  )\n}\n```\n\n#### Large Bundle Size\n\n```typescript\n// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,\n// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.\n// Profile before changing import styles — the real gains come from splitting and lazy loading.\n\n// GOOD: Dynamic import for heavy, rarely-used features\nconst ChartLibrary = lazy(() => import('./ChartLibrary'));\n\n// GOOD: Route-level code splitting wrapped in Suspense\nconst SettingsPage = lazy(() => import('./pages/Settings'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spinner />}>\n      <SettingsPage />\n    </Suspense>\n  );\n}\n```\n\n#### Missing Caching (Backend)\n\n```typescript\n// Cache frequently-read, rarely-changed data\nconst CACHE_TTL = 5 * 60 * 1000 // 5 minutes\nlet cachedConfig: AppConfig | null = null\nlet cacheExpiry = 0\n\nasync function getAppConfig(): Promise<AppConfig> {\n  if (cachedConfig && Date.now() < cacheExpiry) {\n    return cachedConfig\n  }\n  cachedConfig = await db.config.findFirst()\n  cacheExpiry = Date.now() + CACHE_TTL\n  return cachedConfig\n}\n\n// HTTP caching headers for static assets\napp.use(\n  '/static',\n  express.static('public', {\n    maxAge: '1y', // Cache for 1 year\n    immutable: true, // Never revalidate (use content hashing in filenames)\n  }),\n)\n\n// Cache-Control for API responses\nres.set('Cache-Control', 'public, max-age=300') // 5 minutes\n```\n\n## Performance Budget\n\nSet budgets and enforce them:\n\n```\nJavaScript bundle: < 200KB gzipped (initial load)\nCSS: < 50KB gzipped\nImages: < 200KB per image (above the fold)\nFonts: < 100KB total\nAPI response time: < 200ms (p95)\nTime to Interactive: < 3.5s on 4G\nLighthouse Performance score: ≥ 90\n```\n\n**Enforce in CI:**\n\n```bash\n# Bundle size check\nnpx bundlesize --config bundlesize.config.json\n\n# Lighthouse CI\nnpx lhci autorun\n```\n\n## See Also\n\nFor detailed performance checklists, optimization commands, and anti-pattern reference, see `references/performance-checklist.md`.\n\n## Common Rationalizations\n\n| Rationalization                     | Reality                                                                                |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| \"We'll optimize later\"              | Performance debt compounds. Fix obvious anti-patterns now, defer micro-optimizations.  |\n| \"It's fast on my machine\"           | Your machine isn't the user's. Profile on representative hardware and networks.        |\n| \"This optimization is obvious\"      | If you didn't measure, you don't know. Profile first.                                  |\n| \"Users won't notice 100ms\"          | Research shows 100ms delays impact conversion rates. Users notice more than you think. |\n| \"The framework handles performance\" | Frameworks prevent some issues but can't fix N+1 queries or oversized bundles.         |\n\n## Red Flags\n\n- Optimization without profiling data to justify it\n- N+1 query patterns in data fetching\n- List endpoints without pagination\n- Images without dimensions, lazy loading, or responsive sizes\n- Bundle size growing without review\n- No performance monitoring in production\n- `React.memo` and `useMemo` everywhere (overusing is as bad as underusing)\n\n## Verification\n\nAfter any performance-related change:\n\n- [ ] Before and after measurements exist (specific numbers)\n- [ ] The specific bottleneck is identified and addressed\n- [ ] Core Web Vitals are within \"Good\" thresholds\n- [ ] Bundle size hasn't increased significantly\n- [ ] No N+1 queries in new data fetching code\n- [ ] Performance budget passes in CI (if configured)\n- [ ] Existing tests still pass (optimization didn't break behavior)\n",".agents/skills/playwright-best-practices/LICENSE.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FLICENSE.md -->",".agents/skills/playwright-best-practices/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FREADME.md -->",".agents/skills/playwright-best-practices/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FSKILL.md -->",".agents/skills/playwright-best-practices/advanced/authentication-flows.md":"# Complex Authentication Flow Patterns\n\n## Table of Contents\n\n1. [Email Verification Flows](#email-verification-flows)\n2. [Password Reset](#password-reset)\n3. [Session Timeout](#session-timeout)\n4. [Remember Me Persistence](#remember-me-persistence)\n5. [Logout Patterns](#logout-patterns)\n6. [Tips](#tips)\n7. [Related](#related)\n\n> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).\n\n---\n\n## Email Verification Flows\n\n### Capturing Verification Tokens\n\nIntercept API responses to capture verification tokens for testing:\n\n```typescript\ntest('completes registration with email verification', async ({page}) => {\n  let capturedToken = ''\n\n  await page.route('**/api/auth/register', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    capturedToken = body.verificationToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Name').fill('New User')\n  await page.getByLabel('Email').fill('newuser@test.com')\n  await page.getByLabel('Password', {exact: true}).fill('SecurePass!')\n  await page.getByLabel('Confirm password').fill('SecurePass!')\n  await page.getByRole('button', {name: 'Create account'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  expect(capturedToken).toBeTruthy()\n  await page.goto(`/verify?token=${capturedToken}`)\n\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n### Fully Mocked Verification\n\n```typescript\ntest('verifies email with mocked endpoints', async ({page}) => {\n  const mockToken = 'test-verification-abc123'\n\n  await page.route('**/api/auth/register', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Verification sent', verificationToken: mockToken}),\n    })\n  })\n\n  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({verified: true}),\n    })\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password', {exact: true}).fill('Password123!')\n  await page.getByRole('button', {name: 'Sign up'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  await page.goto(`/verify?token=${mockToken}`)\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n---\n\n## Password Reset\n\n### Complete Reset Flow\n\n```typescript\ntest('resets password through email link', async ({page}) => {\n  let resetToken = ''\n\n  await page.route('**/api/auth/forgot-password', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    resetToken = body.resetToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/forgot-password')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByRole('button', {name: 'Send link'}).click()\n\n  await expect(page.getByText('Reset email sent')).toBeVisible()\n\n  expect(resetToken).toBeTruthy()\n  await page.goto(`/reset-password?token=${resetToken}`)\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPassword456!')\n  await page.getByLabel('Confirm password').fill('NewPassword456!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText('Password updated')).toBeVisible()\n})\n```\n\n### Expired Token Handling\n\n```typescript\ntest('shows error for expired reset token', async ({page}) => {\n  await page.goto('/reset-password?token=expired-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPass!')\n  await page.getByLabel('Confirm password').fill('NewPass!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i)\n})\n```\n\n### Password Strength Validation\n\n```typescript\ntest('enforces password requirements on reset', async ({page}) => {\n  await page.goto('/reset-password?token=valid-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('weak')\n  await page.getByLabel('Confirm password').fill('weak')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()\n})\n```\n\n---\n\n## Session Timeout\n\n### Detecting Expired Sessions\n\n```typescript\ntest('redirects to signin after session expires', async ({page, context}) => {\n  await page.goto('/signin')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByLabel('Password').fill('Password!')\n  await page.getByRole('button', {name: 'Sign in'}).click()\n  await expect(page).toHaveURL('/home')\n\n  const cookies = await context.cookies()\n  const sessionCookie = cookies.find((c) => c.name.includes('session'))\n\n  if (sessionCookie) {\n    await context.clearCookies({name: sessionCookie.name})\n  }\n\n  await page.goto('/profile')\n  await expect(page).toHaveURL(/\\/signin/)\n  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible()\n})\n```\n\n### Session Extension Warning\n\n```typescript\ntest('shows warning before session expires', async ({page}) => {\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByText(/session.*expir/i)).toBeVisible({timeout: 10000})\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible()\n})\n```\n\n### Session Extension Action\n\n```typescript\ntest('extends session when user clicks extend', async ({page}) => {\n  let sessionExtended = false\n\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.route('**/api/auth/refresh', async (route) => {\n    sessionExtended = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 3600}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible({\n    timeout: 10000,\n  })\n  await page.getByRole('button', {name: /extend|stay signed in/i}).click()\n\n  expect(sessionExtended).toBe(true)\n  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()\n})\n```\n\n---\n\n## Remember Me Persistence\n\n### Persistent Session\n\n```typescript\ntest('persists session with remember me enabled', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  await page1.getByLabel('Keep me signed in').check()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  const state = await ctx1.storageState()\n  await ctx1.close()\n\n  const ctx2 = await browser.newContext({storageState: state})\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n  await expect(page2).toHaveURL('/home')\n  await expect(page2.getByText('Welcome')).toBeVisible()\n\n  await ctx2.close()\n})\n```\n\n### Session-Only Login\n\n```typescript\ntest('session-only login does not persist across browser restarts', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  // Leave \"Remember me\" unchecked\n  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  // Only keep persistent cookies (filter out session cookies)\n  const cookies = await ctx1.cookies()\n  await ctx1.close()\n\n  const persistentCookies = cookies.filter((c) => c.expires > 0)\n  const ctx2 = await browser.newContext()\n  await ctx2.addCookies(persistentCookies)\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n\n  // Should redirect to login since session was not persisted\n  await expect(page2).toHaveURL(/\\/signin/)\n\n  await ctx2.close()\n})\n```\n\n---\n\n## Logout Patterns\n\n### Standard Logout with Session Cleanup\n\n```typescript\ntest.use({storageState: '.auth/user.json'})\n\ntest('logs out and clears session', async ({page, context}) => {\n  await page.goto('/home')\n\n  await page.getByRole('button', {name: /account|menu/i}).click()\n  await page.getByRole('menuitem', {name: 'Sign out'}).click()\n\n  await expect(page).toHaveURL('/signin')\n\n  const cookies = await context.cookies()\n  const sessionCookies = cookies.filter(\n    (c) => c.name.includes('session') || c.name.includes('token'),\n  )\n  expect(sessionCookies).toHaveLength(0)\n\n  await page.goto('/home')\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n### Logout from All Devices\n\n```typescript\ntest('logs out from all devices', async ({page}) => {\n  let logoutAllCalled = false\n\n  await page.route('**/api/auth/logout-all', async (route) => {\n    logoutAllCalled = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Logged out everywhere'}),\n    })\n  })\n\n  await page.goto('/settings/security')\n\n  await page.getByRole('button', {name: 'Sign out everywhere'}).click()\n  await page.getByRole('dialog').getByRole('button', {name: 'Confirm'}).click()\n\n  expect(logoutAllCalled).toBe(true)\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n---\n\n## Tips\n\n1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests\n2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens\n3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared\n4. **Test the full flow end-to-end** — Password reset should verify login with new password works\n\n---\n\n## Related\n\n- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures\n- [third-party.md](./third-party.md) — Mocking external auth providers\n",".agents/skills/playwright-best-practices/advanced/authentication.md":"# Authentication Testing\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.\n\n## Quick Reference\n\n```typescript\n// Storage state reuse — the #1 pattern for fast auth\nawait page.goto('/login')\nawait page.getByLabel('Username').fill('testuser@example.com')\nawait page.getByLabel('Password').fill('secretPass123')\nawait page.getByRole('button', {name: 'Log in'}).click()\nawait page.context().storageState({path: '.auth/session.json'})\n\n// Reuse in config — every test starts authenticated\n{\n  use: {\n    storageState: '.auth/session.json'\n  }\n}\n\n// API login — skip the UI entirely\nconst context = await browser.newContext()\nconst response = await context.request.post('/api/auth/login', {\n  data: {email: 'testuser@example.com', password: 'secretPass123'},\n})\nawait context.storageState({path: '.auth/session.json'})\n```\n\n## Patterns\n\n### Storage State Reuse\n\n**Use when**: You need authenticated tests and want to avoid logging in before every test.\n**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.\n\n`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.\n\n```typescript\n// scripts/generate-auth.ts — run once to generate the state file\nimport {chromium} from '@playwright/test'\n\nasync function generateAuthState() {\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto('http://localhost:4000/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\ngenerateAuthState()\n```\n\n```typescript\n// playwright.config.ts — load saved state for all tests\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\n```typescript\n// tests/home.spec.ts — test starts already logged in\nimport {test, expect} from '@playwright/test'\n\ntest('authenticated user sees home page', async ({page}) => {\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n### Global Setup Authentication\n\n**Use when**: You want to authenticate once before the entire test suite runs.\n**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.\n\n```typescript\n// global-setup.ts\nimport {chromium, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto(`${baseURL}/login`)\n  await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('**/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\nAdd `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.\n\n### Per-Worker Authentication\n\n**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.\n**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.\n\n> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.\n\n```typescript\n// fixtures/auth.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedContext: BrowserContext\n}\n\nexport const test = base.extend<{}, AuthFixtures>({\n  authenticatedContext: [\n    async ({browser}, use) => {\n      const context = await browser.newContext()\n      const page = await context.newPage()\n\n      await page.goto('/login')\n      await page.getByLabel('Username').fill(`worker-${test.info().parallelIndex}@example.com`)\n      await page.getByLabel('Password').fill('secretPass123')\n      await page.getByRole('button', {name: 'Log in'}).click()\n      await page.waitForURL('/home')\n      await page.close()\n\n      await use(context)\n      await context.close()\n    },\n    {scope: 'worker'},\n  ],\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/settings.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('update display name', async ({authenticatedContext}) => {\n  const page = await authenticatedContext.newPage()\n  await page.goto('/settings/profile')\n  await page.getByLabel('Display name').fill('Updated Name')\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expect(page.getByText('Profile saved')).toBeVisible()\n})\n```\n\n### Multiple Roles\n\n**Use when**: Your app has role-based access control and you need to test different permission levels.\n**Avoid when**: Your app has a single user role.\n\n```typescript\n// global-setup.ts — authenticate all roles\nimport {chromium, type FullConfig} from '@playwright/test'\n\nconst accounts = [\n  {\n    role: 'admin',\n    email: 'admin@example.com',\n    password: process.env.ADMIN_PASSWORD!,\n  },\n  {\n    role: 'member',\n    email: 'member@example.com',\n    password: process.env.MEMBER_PASSWORD!,\n  },\n  {\n    role: 'guest',\n    email: 'guest@example.com',\n    password: process.env.GUEST_PASSWORD!,\n  },\n]\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  for (const {role, email, password} of accounts) {\n    const browser = await chromium.launch()\n    const context = await browser.newContext()\n    const page = await context.newPage()\n\n    await page.goto(`${baseURL}/login`)\n    await page.getByLabel('Username').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('**/home')\n\n    await context.storageState({path: `.auth/${role}.json`})\n    await browser.close()\n  }\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts — one project per role\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  projects: [\n    {\n      name: 'admin',\n      use: {storageState: '.auth/admin.json'},\n      testMatch: '**/*.admin.spec.ts',\n    },\n    {\n      name: 'member',\n      use: {storageState: '.auth/member.json'},\n      testMatch: '**/*.member.spec.ts',\n    },\n    {\n      name: 'guest',\n      use: {storageState: '.auth/guest.json'},\n      testMatch: '**/*.guest.spec.ts',\n    },\n    {\n      name: 'anonymous',\n      use: {storageState: {cookies: [], origins: []}},\n      testMatch: '**/*.anon.spec.ts',\n    },\n  ],\n})\n```\n\n```typescript\n// tests/admin-panel.admin.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('admin can access user management', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByRole('heading', {name: 'User Management'})).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Remove user'})).toBeEnabled()\n})\n```\n\n```typescript\n// tests/admin-panel.guest.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('guest cannot access admin panel', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n})\n```\n\n**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth.ts — role-based fixture\nimport {test as base, type Page} from '@playwright/test'\n\ntype RoleFixtures = {\n  loginAs: (role: 'admin' | 'member' | 'guest') => Promise<Page>\n}\n\nexport const test = base.extend<RoleFixtures>({\n  loginAs: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (role) => {\n      const statePath = `.auth/${role}.json`\n      if (!fs.existsSync(statePath)) {\n        throw new Error(`Auth state for role \"${role}\" not found at ${statePath}`)\n      }\n      const context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/role-comparison.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('admin sees remove button, guest does not', async ({loginAs}) => {\n  const adminPage = await loginAs('admin')\n  await adminPage.goto('/admin/users')\n  await expect(adminPage.getByRole('button', {name: 'Remove user'})).toBeVisible()\n\n  const guestPage = await loginAs('guest')\n  await guestPage.goto('/admin/users')\n  await expect(guestPage.getByText('Access denied')).toBeVisible()\n})\n```\n\n### OAuth/SSO Mocking\n\n**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.\n**Avoid when**: You have a dedicated test tenant on the OAuth provider.\n\nA typical OAuth flow works like this:\n\n1. User clicks \"Sign in with Provider\" → browser navigates to `https://accounts.provider.com/authorize?...`\n2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)\n3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page\n\nIn tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.\n\nFor cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.\n\n```typescript\n// tests/oauth-login.spec.ts — mock the callback route\nimport {test, expect} from '@playwright/test'\n\ntest('login via mocked OAuth flow', async ({page}) => {\n  await page.route('https://accounts.provider.com/**', async (route) => {\n    const callbackUrl = new URL('http://localhost:4000/auth/callback')\n    callbackUrl.searchParams.set('code', 'mock-auth-code-xyz')\n    callbackUrl.searchParams.set('state', 'expected-state-value')\n    await route.fulfill({\n      status: 302,\n      headers: {location: callbackUrl.toString()},\n    })\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Provider'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n```typescript\n// tests/oauth-login.spec.ts — API-based session injection\nimport {test, expect} from '@playwright/test'\n\ntest('bypass OAuth entirely via API session injection', async ({page}) => {\n  // Call a test-only endpoint that creates a session without OAuth\n  const response = await page.request.post('/api/test/create-session', {\n    data: {\n      email: 'oauth-user@example.com',\n      provider: 'provider',\n      role: 'member',\n    },\n  })\n  expect(response.ok()).toBeTruthy()\n\n  await page.context().storageState({path: '.auth/oauth-user.json'})\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.\n\n### MFA Handling\n\n**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).\n**Avoid when**: MFA is optional and you can disable it for test accounts.\n\n**Strategy 1**: Generate real TOTP codes from a shared secret.\n\n```typescript\n// helpers/totp.ts\nimport * as OTPAuth from 'otpauth'\n\nexport function generateTOTP(secret: string): string {\n  const totp = new OTPAuth.TOTP({\n    secret: OTPAuth.Secret.fromBase32(secret),\n    digits: 6,\n    period: 30,\n    algorithm: 'SHA1',\n  })\n  return totp.generate()\n}\n```\n\n```typescript\n// tests/mfa-login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {generateTOTP} from '../helpers/totp'\n\ntest('login with TOTP two-factor auth', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('mfa-user@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n\n  await expect(page.getByText('Enter your authentication code')).toBeVisible()\n\n  const code = generateTOTP(process.env.MFA_TOTP_SECRET!)\n  await page.getByLabel('Authentication code').fill(code)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.\n\n**Strategy 3**: Disable MFA for test accounts at the infrastructure level.\n\n### Session Refresh\n\n**Use when**: Your tokens expire during long test runs.\n**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth-with-refresh.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<AuthFixtures>({\n  authenticatedPage: async ({browser}, use) => {\n    const statePath = '.auth/session.json'\n\n    let context: BrowserContext\n    if (fs.existsSync(statePath)) {\n      context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n\n      const response = await page.request.get('/api/auth/me')\n      if (response.ok()) {\n        await use(page)\n        await context.close()\n        return\n      }\n      await context.close()\n    }\n\n    context = await browser.newContext()\n    const page = await context.newPage()\n    await page.goto('/login')\n    await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n    await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('/home')\n\n    await context.storageState({path: statePath})\n\n    await use(page)\n    await context.close()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Login Page Object\n\n**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.\n**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.\n\n```typescript\n// page-objects/LoginPage.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly usernameInput: Locator\n  readonly passwordInput: Locator\n  readonly loginButton: Locator\n  readonly errorMessage: Locator\n  readonly forgotPasswordLink: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.usernameInput = page.getByLabel('Username')\n    this.passwordInput = page.getByLabel('Password')\n    this.loginButton = page.getByRole('button', {name: 'Log in'})\n    this.errorMessage = page.getByRole('alert')\n    this.forgotPasswordLink = page.getByRole('link', {\n      name: 'Forgot password',\n    })\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n    await expect(this.loginButton).toBeVisible()\n  }\n\n  async login(username: string, password: string) {\n    await this.usernameInput.fill(username)\n    await this.passwordInput.fill(password)\n    await this.loginButton.click()\n  }\n\n  async loginAndWaitForHome(username: string, password: string) {\n    await this.login(username, password)\n    await this.page.waitForURL('/home')\n  }\n\n  async expectError(message: string | RegExp) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n\n  async expectFieldError(field: 'username' | 'password', message: string) {\n    const input = field === 'username' ? this.usernameInput : this.passwordInput\n    await expect(input).toHaveAttribute('aria-invalid', 'true')\n    const errorId = await input.getAttribute('aria-describedby')\n    if (errorId) {\n      await expect(this.page.locator(`#${errorId}`)).toContainText(message)\n    }\n  }\n}\n```\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../page-objects/LoginPage'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('login page', () => {\n  let loginPage: LoginPage\n\n  test.beforeEach(async ({page}) => {\n    loginPage = new LoginPage(page)\n    await loginPage.goto()\n  })\n\n  test('successful login redirects to home', async ({page}) => {\n    await loginPage.loginAndWaitForHome('testuser@example.com', 'secretPass123')\n    await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  })\n\n  test('wrong password shows error', async () => {\n    await loginPage.login('testuser@example.com', 'wrong-password')\n    await loginPage.expectError('Invalid username or password')\n  })\n\n  test('empty fields show validation errors', async () => {\n    await loginPage.loginButton.click()\n    await loginPage.expectFieldError('username', 'Username is required')\n  })\n\n  test('forgot password link navigates correctly', async ({page}) => {\n    await loginPage.forgotPasswordLink.click()\n    await page.waitForURL('/forgot-password')\n    await expect(page.getByRole('heading', {name: 'Reset password'})).toBeVisible()\n  })\n})\n```\n\n### API-Based Login\n\n**Use when**: You want the fastest possible authentication without any browser interaction.\n**Avoid when**: You are specifically testing the login UI.\n\nAPI login is typically 5-10x faster than UI login.\n\n```typescript\n// global-setup.ts — API-based login (fastest)\nimport {request, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  const requestContext = await request.newContext({baseURL})\n\n  const response = await requestContext.post('/api/auth/login', {\n    data: {\n      email: process.env.TEST_USER_EMAIL!,\n      password: process.env.TEST_USER_PASSWORD!,\n    },\n  })\n\n  if (!response.ok()) {\n    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)\n  }\n\n  await requestContext.storageState({path: '.auth/session.json'})\n  await requestContext.dispose()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// fixtures/api-auth.ts — fixture version for per-test authentication\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend({\n  authenticatedPage: async ({browser, playwright}, use) => {\n    const apiContext = await playwright.request.newContext({\n      baseURL: 'http://localhost:4000',\n    })\n\n    await apiContext.post('/api/auth/login', {\n      data: {\n        email: 'testuser@example.com',\n        password: 'secretPass123',\n      },\n    })\n\n    const state = await apiContext.storageState()\n    const context = await browser.newContext({storageState: state})\n    const page = await context.newPage()\n\n    await use(page)\n\n    await context.close()\n    await apiContext.dispose()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Unauthenticated Tests\n\n**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.\n**Avoid when**: The test requires a logged-in user.\n\nWhen your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.\n\n```typescript\n// tests/public-pages.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('unauthenticated access', () => {\n  test('homepage is accessible without login', async ({page}) => {\n    await page.goto('/')\n    await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()\n    await expect(page.getByRole('link', {name: 'Log in'})).toBeVisible()\n  })\n\n  test('protected route redirects to login', async ({page}) => {\n    await page.goto('/home')\n    await page.waitForURL('**/login**')\n    expect(page.url()).toContain('redirect=%2Fhome')\n  })\n\n  test('expired session shows re-login prompt', async ({page, context}) => {\n    await page.goto('/home')\n    await context.clearCookies()\n\n    await page.goto('/settings')\n    await page.waitForURL('**/login**')\n    await expect(page.getByText('Your session has expired')).toBeVisible()\n  })\n\n  test('signup flow creates account', async ({page}) => {\n    await page.goto('/signup')\n    await page.getByLabel('Name').fill('New User')\n    await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`)\n    await page.getByLabel('Password', {exact: true}).fill('secretPass123')\n    await page.getByLabel('Confirm password').fill('secretPass123')\n    await page.getByRole('button', {name: 'Create account'}).click()\n\n    await page.waitForURL('/onboarding')\n    await expect(page.getByText('Welcome, New User')).toBeVisible()\n  })\n})\n```\n\n## Decision Guide\n\n| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                 |\n| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |\n| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project                               |\n| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data          |\n| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/member/guest roles                               |\n| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` |\n| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI                           |\n| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes or use a test-mode bypass             |\n| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use                       |\n| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles                                 |\n| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth                                     |\n\n### UI Login vs API Login vs Storage State\n\n```text\nNeed to test the login page itself?\n├── Yes → UI login with LoginPage POM, no storageState\n└── No → Do you have a login API endpoint?\n    ├── Yes → API login in global setup, save storageState (fastest)\n    └── No → UI login in global setup, save storageState\n              └── Tokens expire quickly?\n                  ├── Yes → Add session refresh fixture\n                  └── No → Standard storageState reuse is fine\n```\n\n## Anti-Patterns\n\n| Don't Do This                                                             | Problem                                     | Do This Instead                                                           |\n| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |\n| Log in via UI before every test                                           | Adds 2-5 seconds per test                   | Use `storageState` to skip login entirely                                 |\n| Share a single auth state file across parallel workers that mutate state  | Race conditions                             | Use per-worker fixtures with `{ scope: 'worker' }`                        |\n| Hardcode credentials in test files                                        | Security risk                               | Use environment variables and `.env` files                                |\n| Ignore token expiration                                                   | Tests fail intermittently with 401 errors   | Add a session validity check in your auth fixture                         |\n| Hit real OAuth providers in CI                                            | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection                      |\n| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay                             | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |\n| Store `.auth/*.json` files in git                                         | Tokens in version control                   | Add `.auth/` to `.gitignore`                                              |\n| Create one \"god\" test account with all permissions                        | Cannot test role-based access control       | Create separate accounts per role                                         |\n| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated        | Pass `storageState` when creating the context                             |\n| Test MFA by disabling it everywhere                                       | You never test the MFA flow                 | Use TOTP generation for at least one test                                 |\n\n## Troubleshooting\n\n### Global setup fails with \"Target page, context or browser has been closed\"\n\n**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.\n\n**Fix**:\n\n- Add `await page.waitForURL()` after the login action\n- Check that `baseURL` in your config matches the actual server URL and protocol\n- Add error handling to global setup:\n\n```typescript\nconst response = await page.waitForResponse('**/api/auth/**')\nif (!response.ok()) {\n  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)\n}\n```\n\n### Tests fail with 401 Unauthorized after running for a while\n\n**Cause**: The session token saved in `storageState` has expired.\n\n**Fix**:\n\n- Use the session refresh fixture pattern\n- Increase token expiry in test environment configuration\n- Switch to API-based login in a worker-scoped fixture\n\n### `storageState` file is empty or contains no cookies\n\n**Cause**: `storageState()` was called before the login response set cookies.\n\n**Fix**:\n\n- Wait for the post-login page to load: `await page.waitForURL('/home')`\n- Verify cookies exist before saving:\n\n```typescript\nconst cookies = await context.cookies()\nif (cookies.length === 0) {\n  throw new Error('No cookies found after login')\n}\nawait context.storageState({path: '.auth/session.json'})\n```\n\n### Different browsers get different cookies\n\n**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.\n\n**Fix**:\n\n- Generate separate auth state files per browser project\n- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:\n\n```typescript\nprojects: [\n  {\n    name: 'chromium',\n    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },\n  },\n  {\n    name: 'firefox',\n    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },\n  },\n],\n```\n\n### Parallel tests interfere with each other's sessions\n\n**Cause**: Multiple workers share the same test account and one worker's actions affect others.\n\n**Fix**:\n\n- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`\n- Use the per-worker authentication fixture pattern\n- Make tests idempotent\n\n### OAuth mock does not work — still redirects to real provider\n\n**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.\n\n**Fix**:\n\n- Register route handlers before any navigation: call `page.route()` before `page.goto()`\n- Log the actual redirect URL to verify the pattern:\n\n```typescript\npage.on('request', (req) => {\n  if (req.url().includes('oauth') || req.url().includes('accounts.provider')) {\n    console.log('OAuth request:', req.url())\n  }\n})\n```\n\n## Related\n\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown\n- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration\n- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies\n- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking\n- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login\n- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness\n",".agents/skills/playwright-best-practices/advanced/clock-mocking.md":"# Date, Time & Clock Mocking\n\n## Table of Contents\n\n1. [Clock API Basics](#clock-api-basics)\n2. [Fixed Time Testing](#fixed-time-testing)\n3. [Time Advancement](#time-advancement)\n4. [Timezone Testing](#timezone-testing)\n5. [Timer Mocking](#timer-mocking)\n\n## Clock API Basics\n\n### Install Clock\n\n```typescript\ntest('mock current time', async ({page}) => {\n  // Install clock before navigating\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  await page.goto('/dashboard')\n\n  // Page sees January 15, 2025 as current date\n  await expect(page.getByText('January 15, 2025')).toBeVisible()\n})\n```\n\n### Clock with Fixture\n\n```typescript\n// fixtures/clock.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClockFixtures = {\n  mockTime: (date: Date | string) => Promise<void>\n}\n\nexport const test = base.extend<ClockFixtures>({\n  mockTime: async ({page}, use) => {\n    await use(async (date) => {\n      const time = typeof date === 'string' ? new Date(date) : date\n      await page.clock.install({time})\n    })\n  },\n})\n\n// Usage\ntest('subscription expiry', async ({page, mockTime}) => {\n  await mockTime('2025-12-31T23:59:00')\n  await page.goto('/subscription')\n\n  await expect(page.getByText('Expires today')).toBeVisible()\n})\n```\n\n## Fixed Time Testing\n\n### Test Date-Dependent Features\n\n```typescript\ntest('show holiday banner in December', async ({page}) => {\n  await page.clock.install({time: new Date('2025-12-20T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeVisible()\n})\n\ntest('no holiday banner in January', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeHidden()\n})\n```\n\n### Test Relative Time Display\n\n```typescript\ntest('shows relative time correctly', async ({page}) => {\n  // Fix time to control \"posted 2 hours ago\" text\n  await page.clock.install({time: new Date('2025-06-15T14:00:00')})\n\n  // Mock API to return post with known timestamp\n  await page.route('**/api/posts/1', (route) =>\n    route.fulfill({\n      json: {\n        id: 1,\n        title: 'Test Post',\n        createdAt: '2025-06-15T12:00:00Z', // 2 hours before mock time\n      },\n    }),\n  )\n\n  await page.goto('/posts/1')\n\n  await expect(page.getByText('2 hours ago')).toBeVisible()\n})\n```\n\n### Test Date Boundaries\n\n```typescript\ntest.describe('end of month billing', () => {\n  test('shows billing on last day of month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-31T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('Payment due today')).toBeVisible()\n  })\n\n  test('shows days remaining mid-month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('16 days until payment')).toBeVisible()\n  })\n})\n```\n\n## Time Advancement\n\n### Advance Time Manually\n\n```typescript\ntest('session timeout warning', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/dashboard')\n\n  // Advance 25 minutes (session timeout at 30 min)\n  await page.clock.fastForward('25:00')\n\n  await expect(page.getByText('Session expires in 5 minutes')).toBeVisible()\n\n  // Advance 5 more minutes\n  await page.clock.fastForward('05:00')\n\n  await expect(page.getByText('Session expired')).toBeVisible()\n})\n```\n\n### Pause and Resume Time\n\n```typescript\ntest('countdown timer', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/sale')\n\n  // Initial state\n  await expect(page.getByText('Sale ends in 2:00:00')).toBeVisible()\n\n  // Advance 1 hour\n  await page.clock.fastForward('01:00:00')\n\n  await expect(page.getByText('Sale ends in 1:00:00')).toBeVisible()\n\n  // Advance past end\n  await page.clock.fastForward('01:00:01')\n\n  await expect(page.getByText('Sale ended')).toBeVisible()\n})\n```\n\n### Run Pending Timers\n\n```typescript\ntest('debounced search', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/search')\n\n  await page.getByLabel('Search').fill('playwright')\n\n  // Search is debounced by 300ms, won't fire yet\n  await expect(page.getByTestId('search-results')).toBeHidden()\n\n  // Fast forward past debounce\n  await page.clock.fastForward(300)\n\n  // Now search should execute\n  await expect(page.getByTestId('search-results')).toBeVisible()\n})\n```\n\n## Timezone Testing\n\n### Test Different Timezones\n\n```typescript\ntest.describe('timezone display', () => {\n  test('shows correct time in PST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'America/Los_Angeles',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 9 AM PST\n    await expect(page.getByText('9:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n\n  test('shows correct time in JST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'Asia/Tokyo',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 2 AM next day JST\n    await expect(page.getByText('2:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n})\n```\n\n### Timezone Fixture\n\n```typescript\n// fixtures/timezone.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype TimezoneFixtures = {\n  pageInTimezone: (timezone: string) => Promise<Page>\n}\n\nexport const test = base.extend<TimezoneFixtures>({\n  pageInTimezone: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (timezone) => {\n      const context = await browser.newContext({timezoneId: timezone})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    // Cleanup\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n```\n\n## Timer Mocking\n\n### Mock setInterval\n\n```typescript\ntest('auto-refresh data', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  let apiCalls = 0\n  await page.route('**/api/data', (route) => {\n    apiCalls++\n    route.fulfill({json: {value: apiCalls}})\n  })\n\n  await page.goto('/live-data') // Sets up 30s refresh interval\n\n  expect(apiCalls).toBe(1) // Initial load\n\n  // Advance 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(2) // First refresh\n\n  // Advance another 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(3) // Second refresh\n})\n```\n\n### Mock setTimeout Chains\n\n```typescript\ntest('notification queue', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/notifications')\n\n  // Trigger 3 notifications that show sequentially\n  await page.getByRole('button', {name: 'Show All'}).click()\n\n  // First notification appears immediately\n  await expect(page.getByText('Notification 1')).toBeVisible()\n\n  // Second appears after 2 seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 2')).toBeVisible()\n\n  // Third appears after 2 more seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 3')).toBeVisible()\n})\n```\n\n### Test Animation Frames\n\n```typescript\ntest('animation completes', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/animation-demo')\n\n  await page.getByRole('button', {name: 'Animate'}).click()\n\n  // Animation runs for 500ms\n  const element = page.getByTestId('animated-box')\n  await expect(element).toHaveCSS('opacity', '0')\n\n  // Fast forward through animation\n  await page.clock.fastForward(500)\n\n  await expect(element).toHaveCSS('opacity', '1')\n})\n```\n\n## Best Practices\n\n### Always Install Clock Before Navigation\n\n```typescript\n// Good\ntest('date test', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15')})\n  await page.goto('/') // Page loads with mocked time\n})\n\n// Bad - time already captured by page\ntest('date test', async ({page}) => {\n  await page.goto('/')\n  await page.clock.install({time: new Date('2025-01-15')}) // Too late!\n})\n```\n\n### Use ISO Strings for Clarity\n\n```typescript\n// Good - explicit timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00Z')})\n\n// Ambiguous - uses local timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00')})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                             | Problem                         | Solution                               |\n| ---------------------------------------- | ------------------------------- | -------------------------------------- |\n| Installing clock after navigation        | Page already captured real time | Install clock before `goto()`          |\n| Hardcoded relative dates                 | Tests break over time           | Use fixed dates with clock mock        |\n| Not accounting for timezone              | Tests fail in different regions | Use explicit UTC times or set timezone |\n| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers    | Use `fastForward` instead              |\n\n## Related References\n\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures\n",".agents/skills/playwright-best-practices/advanced/mobile-testing.md":"# Mobile & Responsive Testing\n\n## Table of Contents\n\n1. [Device Emulation](#device-emulation)\n2. [Touch Gestures](#touch-gestures)\n3. [Viewport Testing](#viewport-testing)\n4. [Mobile-Specific UI](#mobile-specific-ui)\n5. [Responsive Breakpoints](#responsive-breakpoints)\n\n## Device Emulation\n\n### Use Built-in Devices\n\n```typescript\nimport {test, devices} from '@playwright/test'\n\n// Configure in playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'Desktop Chrome', use: {...devices['Desktop Chrome']}},\n    {name: 'Mobile Safari', use: {...devices['iPhone 14']}},\n    {name: 'Mobile Chrome', use: {...devices['Pixel 7']}},\n    {name: 'Tablet', use: {...devices['iPad Pro 11']}},\n  ],\n})\n```\n\n### Custom Device Configuration\n\n```typescript\ntest.use({\n  viewport: {width: 390, height: 844},\n  deviceScaleFactor: 3,\n  isMobile: true,\n  hasTouch: true,\n  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',\n})\n\ntest('custom mobile device', async ({page}) => {\n  await page.goto('/')\n  // Test runs with custom device settings\n})\n```\n\n### Test Across Multiple Devices\n\n```typescript\nconst mobileDevices = ['iPhone 14', 'Pixel 7', 'Galaxy S21']\n\nfor (const deviceName of mobileDevices) {\n  test(`checkout on ${deviceName}`, async ({browser}) => {\n    const device = devices[deviceName]\n    const context = await browser.newContext({...device})\n    const page = await context.newPage()\n\n    await page.goto('/checkout')\n    await expect(page.getByRole('button', {name: 'Pay'})).toBeVisible()\n\n    await context.close()\n  })\n}\n```\n\n## Touch Gestures\n\n### Tap\n\n```typescript\ntest.use({hasTouch: true})\n\ntest('tap to interact', async ({page}) => {\n  await page.goto('/gallery')\n\n  // Tap is like click but for touch devices\n  await page.getByRole('img', {name: 'Photo 1'}).tap()\n\n  await expect(page.getByRole('dialog')).toBeVisible()\n})\n```\n\n### Swipe\n\n```typescript\ntest('swipe carousel', async ({page}) => {\n  await page.goto('/carousel')\n\n  const carousel = page.getByTestId('carousel')\n  const box = await carousel.boundingBox()\n\n  if (box) {\n    // Swipe left\n    await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2)\n    await page.mouse.move(box.x + 50, box.y + box.height / 2)\n\n    // Or use drag\n    await carousel.dragTo(carousel, {\n      sourcePosition: {x: box.width - 50, y: box.height / 2},\n      targetPosition: {x: 50, y: box.height / 2},\n    })\n  }\n\n  await expect(page.getByText('Slide 2')).toBeVisible()\n})\n```\n\n### Swipe Fixture\n\n```typescript\n// fixtures/touch.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype TouchFixtures = {\n  swipe: (element: Locator, direction: 'left' | 'right' | 'up' | 'down') => Promise<void>\n}\n\nexport const test = base.extend<TouchFixtures>({\n  swipe: async ({page}, use) => {\n    await use(async (element, direction) => {\n      const box = await element.boundingBox()\n      if (!box) throw new Error('Element not visible')\n\n      const centerX = box.x + box.width / 2\n      const centerY = box.y + box.height / 2\n      const distance = 100\n\n      const moves = {\n        left: {\n          startX: centerX + distance,\n          endX: centerX - distance,\n          y: centerY,\n        },\n        right: {\n          startX: centerX - distance,\n          endX: centerX + distance,\n          y: centerY,\n        },\n        up: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY + distance,\n          endY: centerY - distance,\n        },\n        down: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY - distance,\n          endY: centerY + distance,\n        },\n      }\n\n      const move = moves[direction]\n      await page.touchscreen.tap(move.startX, move.startY ?? move.y)\n      await page.mouse.move(move.endX, move.endY ?? move.y, {steps: 10})\n      await page.mouse.up()\n    })\n  },\n})\n\n// Usage\ntest('swipe to delete', async ({page, swipe}) => {\n  await page.goto('/inbox')\n\n  const message = page.getByTestId('message-1')\n  await swipe(message, 'left')\n\n  await expect(page.getByRole('button', {name: 'Delete'})).toBeVisible()\n})\n```\n\n### Long Press\n\n```typescript\ntest('long press for context menu', async ({page}) => {\n  await page.goto('/files')\n\n  const file = page.getByText('document.pdf')\n  const box = await file.boundingBox()\n\n  if (box) {\n    // Touch down\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2)\n\n    // Hold for 500ms\n    await page.waitForTimeout(500)\n\n    // Context menu should appear\n    await expect(page.getByRole('menu')).toBeVisible()\n  }\n})\n```\n\n### Pinch Zoom\n\n```typescript\ntest('pinch to zoom image', async ({page}) => {\n  await page.goto('/map')\n\n  // Pinch zoom requires two touch points\n  // Playwright doesn't have native pinch support, so we simulate via evaluate\n  await page.evaluate(() => {\n    const element = document.querySelector('#map')\n    if (element) {\n      // Simulate wheel event as fallback for zoom\n      element.dispatchEvent(\n        new WheelEvent('wheel', {\n          deltaY: -100, // Negative = zoom in\n          ctrlKey: true, // Ctrl+wheel = pinch on many apps\n        }),\n      )\n    }\n  })\n\n  // Or trigger the app's zoom function directly\n  await page.evaluate(() => {\n    ;(window as any).mapInstance?.setZoom(15)\n  })\n})\n```\n\n## Viewport Testing\n\n### Test Different Sizes\n\n```typescript\nconst viewports = [\n  {name: 'mobile', width: 375, height: 667},\n  {name: 'tablet', width: 768, height: 1024},\n  {name: 'desktop', width: 1920, height: 1080},\n]\n\nfor (const {name, width, height} of viewports) {\n  test(`navigation on ${name}`, async ({page}) => {\n    await page.setViewportSize({width, height})\n    await page.goto('/')\n\n    if (width < 768) {\n      // Mobile: should have hamburger menu\n      await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n    } else {\n      // Desktop: should have visible nav links\n      await expect(page.getByRole('link', {name: 'Products'})).toBeVisible()\n    }\n  })\n}\n```\n\n### Dynamic Viewport Changes\n\n```typescript\ntest('responsive layout change', async ({page}) => {\n  await page.setViewportSize({width: 1200, height: 800})\n  await page.goto('/dashboard')\n\n  // Desktop: sidebar visible\n  await expect(page.getByRole('complementary')).toBeVisible()\n\n  // Resize to mobile\n  await page.setViewportSize({width: 375, height: 667})\n\n  // Mobile: sidebar hidden, hamburger visible\n  await expect(page.getByRole('complementary')).toBeHidden()\n  await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n})\n```\n\n## Mobile-Specific UI\n\n### Hamburger Menu\n\n```typescript\ntest('mobile navigation', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/')\n\n  // Open hamburger menu\n  await page.getByRole('button', {name: 'Menu'}).click()\n\n  // Navigation drawer should appear\n  const nav = page.getByRole('navigation')\n  await expect(nav).toBeVisible()\n\n  // Navigate via mobile menu\n  await nav.getByRole('link', {name: 'Products'}).click()\n\n  await expect(page).toHaveURL('/products')\n  // Menu should close after navigation\n  await expect(nav).toBeHidden()\n})\n```\n\n### Bottom Sheet\n\n```typescript\ntest('bottom sheet interaction', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/product/123')\n\n  await page.getByRole('button', {name: 'Add to Cart'}).click()\n\n  // Bottom sheet appears\n  const sheet = page.getByRole('dialog')\n  await expect(sheet).toBeVisible()\n\n  // Select options\n  await sheet.getByRole('combobox', {name: 'Size'}).selectOption('Large')\n  await sheet.getByRole('button', {name: 'Confirm'}).click()\n\n  await expect(page.getByText('Added to cart')).toBeVisible()\n})\n```\n\n### Pull to Refresh\n\n```typescript\ntest('pull to refresh', async ({page}) => {\n  await page.goto('/feed')\n\n  const feed = page.getByTestId('feed')\n  const initialFirstItem = await feed.locator('> *').first().textContent()\n\n  // Simulate pull down\n  const box = await feed.boundingBox()\n  if (box) {\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + 50)\n    await page.mouse.move(box.x + box.width / 2, box.y + 200, {steps: 20})\n    await page.mouse.up()\n  }\n\n  // Wait for refresh\n  await expect(page.getByTestId('loading')).toBeVisible()\n  await expect(page.getByTestId('loading')).toBeHidden()\n\n  // Content should be updated (in a real app)\n})\n```\n\n## Responsive Breakpoints\n\n### Test All Breakpoints\n\n```typescript\nconst breakpoints = {\n  'xs': 320,\n  'sm': 640,\n  'md': 768,\n  'lg': 1024,\n  'xl': 1280,\n  '2xl': 1536,\n}\n\ntest.describe('responsive header', () => {\n  for (const [name, width] of Object.entries(breakpoints)) {\n    test(`header at ${name} (${width}px)`, async ({page}) => {\n      await page.setViewportSize({width, height: 800})\n      await page.goto('/')\n\n      if (width < 768) {\n        await expect(page.getByTestId('mobile-menu-button')).toBeVisible()\n        await expect(page.getByTestId('desktop-nav')).toBeHidden()\n      } else {\n        await expect(page.getByTestId('mobile-menu-button')).toBeHidden()\n        await expect(page.getByTestId('desktop-nav')).toBeVisible()\n      }\n    })\n  }\n})\n```\n\n### Visual Regression at Breakpoints\n\n```typescript\ntest.describe('visual regression', () => {\n  const sizes = [\n    {width: 375, height: 667, name: 'mobile'},\n    {width: 768, height: 1024, name: 'tablet'},\n    {width: 1440, height: 900, name: 'desktop'},\n  ]\n\n  for (const {width, height, name} of sizes) {\n    test(`homepage at ${name}`, async ({page}) => {\n      await page.setViewportSize({width, height})\n      await page.goto('/')\n\n      await expect(page).toHaveScreenshot(`homepage-${name}.png`)\n    })\n  }\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                   | Solution                         |\n| --------------------------- | ------------------------- | -------------------------------- |\n| Only testing one viewport   | Misses responsive bugs    | Test multiple breakpoints        |\n| Ignoring touch events       | Features broken on mobile | Test tap, swipe, long press      |\n| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()`     |\n| Not testing orientation     | Landscape bugs missed     | Test both portrait and landscape |\n\n## Related References\n\n- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing\n- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors\n- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)\n- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements\n",".agents/skills/playwright-best-practices/advanced/multi-context.md":"# Multi-Tab, Window & Popup Testing\n\nThis file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).\n\n## Table of Contents\n\n1. [Popup Handling](#popup-handling)\n2. [New Tab Navigation](#new-tab-navigation)\n3. [OAuth Flows](#oauth-flows)\n4. [Multiple Windows](#multiple-windows)\n5. [Tab Coordination](#tab-coordination)\n\n## Popup Handling\n\n### Basic Popup\n\n```typescript\ntest('handle popup window', async ({page}) => {\n  await page.goto('/')\n\n  // Start waiting for popup before triggering it\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Open Support Chat'}).click()\n  const popup = await popupPromise\n\n  // Wait for popup to load\n  await popup.waitForLoadState()\n\n  // Interact with popup\n  await popup.getByLabel('Message').fill('Need help')\n  await popup.getByRole('button', {name: 'Send'}).click()\n\n  await expect(popup.getByText('Message sent')).toBeVisible()\n\n  // Close popup\n  await popup.close()\n})\n```\n\n### Popup with Authentication\n\n```typescript\ntest('popup login flow', async ({page}) => {\n  await page.goto('/dashboard')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Connect Account'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Complete login in popup\n  await popup.getByLabel('Email').fill('user@example.com')\n  await popup.getByLabel('Password').fill('password123')\n  await popup.getByRole('button', {name: 'Log In'}).click()\n\n  // Popup should close automatically after auth\n  await popup.waitForEvent('close')\n\n  // Main page should reflect connected state\n  await expect(page.getByText('Account connected')).toBeVisible()\n})\n```\n\n### Handle Blocked Popups\n\n```typescript\ntest('handle popup blocker', async ({page}) => {\n  await page.goto('/share')\n\n  // Listen for console messages about blocked popup\n  page.on('console', (msg) => {\n    if (msg.text().includes('popup blocked')) {\n      console.log('Popup was blocked')\n    }\n  })\n\n  const popupPromise = page.waitForEvent('popup').catch(() => null)\n  await page.getByRole('button', {name: 'Share to Twitter'}).click()\n  const popup = await popupPromise\n\n  if (!popup) {\n    // Popup blocked - app should show fallback\n    await expect(page.getByText('Copy share link instead')).toBeVisible()\n  }\n})\n```\n\n## New Tab Navigation\n\n### Link Opens in New Tab\n\n```typescript\ntest('external link opens in new tab', async ({page, context}) => {\n  await page.goto('/resources')\n\n  // Wait for new page in context\n  const pagePromise = context.waitForEvent('page')\n  await page.getByRole('link', {name: 'Documentation'}).click()\n  const newPage = await pagePromise\n\n  await newPage.waitForLoadState()\n\n  expect(newPage.url()).toContain('docs.example.com')\n  await expect(newPage.getByRole('heading', {level: 1})).toBeVisible()\n\n  // Original page still there\n  expect(page.url()).toContain('/resources')\n\n  await newPage.close()\n})\n```\n\n### Intercept New Tab\n\n```typescript\ntest('prevent new tab for testing', async ({page}) => {\n  await page.goto('/links')\n\n  // Remove target=\"_blank\" to keep navigation in same tab\n  await page.evaluate(() => {\n    document.querySelectorAll('a[target=\"_blank\"]').forEach((a) => {\n      a.removeAttribute('target')\n    })\n  })\n\n  // Now link opens in same tab\n  await page.getByRole('link', {name: 'External Site'}).click()\n\n  // Can test the destination page\n  await expect(page).toHaveURL(/external-site\\.com/)\n})\n```\n\n## OAuth Flows\n\n### Google OAuth Popup\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  await page.goto('/login')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Handle Google's OAuth flow\n  await popup.getByLabel('Email or phone').fill('test@gmail.com')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  await popup.getByLabel('Enter your password').fill('password')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for redirect back and popup close\n  await popup.waitForEvent('close')\n\n  // Verify logged in on main page\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### Mock OAuth (Recommended)\n\n```typescript\ntest('mock OAuth flow', async ({page, context}) => {\n  // Mock the OAuth callback instead of real flow\n  await page.route('**/auth/callback**', async (route) => {\n    // Simulate successful OAuth\n    const url = new URL(route.request().url())\n    url.searchParams.set('code', 'mock-auth-code')\n    await route.fulfill({\n      status: 302,\n      headers: {Location: '/dashboard'},\n    })\n  })\n\n  // Mock token exchange\n  await page.route('**/api/auth/token', (route) =>\n    route.fulfill({\n      json: {\n        access_token: 'mock-token',\n        user: {name: 'Test User', email: 'test@example.com'},\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // Should redirect to dashboard without actual OAuth\n  await expect(page).toHaveURL('/dashboard')\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.\n\n## Multiple Windows\n\n### Test Across Multiple Windows\n\n```typescript\ntest('sync between windows', async ({context}) => {\n  // Open two pages\n  const page1 = await context.newPage()\n  const page2 = await context.newPage()\n\n  await page1.goto('/dashboard')\n  await page2.goto('/dashboard')\n\n  // Make change in first window\n  await page1.getByRole('button', {name: 'Add Item'}).click()\n  await page1.getByLabel('Name').fill('New Item')\n  await page1.getByRole('button', {name: 'Save'}).click()\n\n  // Should sync to second window (if app supports real-time sync)\n  await expect(page2.getByText('New Item')).toBeVisible({timeout: 10000})\n})\n```\n\n### Different Users in Different Windows\n\n> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.\n\n## Tab Coordination\n\n### Switch Between Tabs\n\n```typescript\ntest('manage multiple tabs', async ({context}) => {\n  const page1 = await context.newPage()\n  await page1.goto('/editor')\n\n  const page2 = await context.newPage()\n  await page2.goto('/preview')\n\n  // Edit in first tab\n  await page1.bringToFront()\n  await page1.getByLabel('Content').fill('Hello World')\n\n  // Check preview in second tab\n  await page2.bringToFront()\n  await page2.reload() // If preview needs refresh\n  await expect(page2.getByText('Hello World')).toBeVisible()\n})\n```\n\n### Close All Tabs Except One\n\n```typescript\ntest('cleanup tabs after test', async ({context}) => {\n  const mainPage = await context.newPage()\n  await mainPage.goto('/')\n\n  // Open several popups during test\n  for (let i = 0; i < 3; i++) {\n    const popup = await context.newPage()\n    await popup.goto(`/popup/${i}`)\n  }\n\n  // Close all except main page\n  for (const page of context.pages()) {\n    if (page !== mainPage) {\n      await page.close()\n    }\n  }\n\n  expect(context.pages()).toHaveLength(1)\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern            | Problem                        | Solution                                   |\n| ----------------------- | ------------------------------ | ------------------------------------------ |\n| Not waiting for popup   | Race condition                 | Use `waitForEvent(\"popup\")` before trigger |\n| Testing real OAuth      | Slow, flaky, needs credentials | Mock OAuth endpoints                       |\n| Assuming popup opens    | May be blocked                 | Handle both open and blocked cases         |\n| Not closing extra pages | Resource leak                  | Close pages in cleanup                     |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth\n",".agents/skills/playwright-best-practices/advanced/multi-user.md":"# Multi-User & Collaboration Testing\n\n## Table of Contents\n\n1. [Multiple Browser Contexts](#multiple-browser-contexts)\n2. [Real-Time Collaboration](#real-time-collaboration)\n3. [Role-Based Testing](#role-based-testing)\n4. [Concurrent Actions](#concurrent-actions)\n5. [Chat & Messaging](#chat--messaging)\n\n## Multiple Browser Contexts\n\n### Two Users in Same Test\n\n```typescript\ntest(\"two users see each other's changes\", async ({browser}) => {\n  // Create two isolated contexts (like two browsers)\n  const userAContext = await browser.newContext()\n  const userBContext = await browser.newContext()\n\n  const userAPage = await userAContext.newPage()\n  const userBPage = await userBContext.newPage()\n\n  // Both users go to the same document\n  await userAPage.goto('/doc/shared-123')\n  await userBPage.goto('/doc/shared-123')\n\n  // User A types\n  await userAPage.getByLabel('Content').fill('Hello from User A')\n\n  // User B should see the change\n  await expect(userBPage.getByText('Hello from User A')).toBeVisible()\n\n  // Cleanup\n  await userAContext.close()\n  await userBContext.close()\n})\n```\n\n### Multiple Users with Auth States\n\n```typescript\ntest('admin and user interaction', async ({browser}) => {\n  // Load different auth states\n  const adminContext = await browser.newContext({\n    storageState: '.auth/admin.json',\n  })\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n\n  const adminPage = await adminContext.newPage()\n  const userPage = await userContext.newPage()\n\n  // User submits request\n  await userPage.goto('/support')\n  await userPage.getByLabel('Message').fill('Need help!')\n  await userPage.getByRole('button', {name: 'Submit'}).click()\n\n  // Admin sees and responds\n  await adminPage.goto('/admin/tickets')\n  await expect(adminPage.getByText('Need help!')).toBeVisible()\n  await adminPage.getByRole('button', {name: 'Reply'}).click()\n  await adminPage.getByLabel('Response').fill('How can I help?')\n  await adminPage.getByRole('button', {name: 'Send'}).click()\n\n  // User sees response\n  await expect(userPage.getByText('How can I help?')).toBeVisible()\n\n  await adminContext.close()\n  await userContext.close()\n})\n```\n\n### Multi-User Fixture\n\n```typescript\n// fixtures/multi-user.fixture.ts\nimport {test as base, Browser, BrowserContext, Page} from '@playwright/test'\n\ntype UserSession = {\n  context: BrowserContext\n  page: Page\n}\n\ntype MultiUserFixtures = {\n  createUser: (authState?: string) => Promise<UserSession>\n}\n\nexport const test = base.extend<MultiUserFixtures>({\n  createUser: async ({browser}, use) => {\n    const sessions: UserSession[] = []\n\n    await use(async (authState) => {\n      const context = await browser.newContext({\n        storageState: authState,\n      })\n      const page = await context.newPage()\n      sessions.push({context, page})\n      return {context, page}\n    })\n\n    // Cleanup all sessions\n    for (const session of sessions) {\n      await session.context.close()\n    }\n  },\n})\n\n// Usage\ntest('3 users collaborate', async ({createUser}) => {\n  const alice = await createUser('.auth/alice.json')\n  const bob = await createUser('.auth/bob.json')\n  const charlie = await createUser('.auth/charlie.json')\n\n  // All navigate to same room\n  await alice.page.goto('/room/123')\n  await bob.page.goto('/room/123')\n  await charlie.page.goto('/room/123')\n\n  // Test interactions...\n})\n```\n\n## Real-Time Collaboration\n\n### Collaborative Document\n\n```typescript\ntest('real-time collaborative editing', async ({browser}) => {\n  const user1 = await browser.newContext()\n  const user2 = await browser.newContext()\n\n  const page1 = await user1.newPage()\n  const page2 = await user2.newPage()\n\n  await page1.goto('/docs/shared')\n  await page2.goto('/docs/shared')\n\n  // User 1 types at the beginning\n  const editor1 = page1.getByRole('textbox')\n  await editor1.click()\n  await editor1.press('Home')\n  await editor1.type('User 1: ')\n\n  // User 2 types at the end\n  const editor2 = page2.getByRole('textbox')\n  await editor2.click()\n  await editor2.press('End')\n  await editor2.type(' - User 2')\n\n  // Both should see combined result\n  await expect(page1.getByRole('textbox')).toContainText('User 1:')\n  await expect(page1.getByRole('textbox')).toContainText('- User 2')\n  await expect(page2.getByRole('textbox')).toContainText('User 1:')\n  await expect(page2.getByRole('textbox')).toContainText('- User 2')\n\n  await user1.close()\n  await user2.close()\n})\n```\n\n### Cursor Presence\n\n```typescript\ntest('shows other user cursors', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Mock to identify users\n  await page1.route('**/api/me', (route) => route.fulfill({json: {id: 'user-1', name: 'Alice'}}))\n  await page2.route('**/api/me', (route) => route.fulfill({json: {id: 'user-2', name: 'Bob'}}))\n\n  await page1.goto('/whiteboard/123')\n  await page2.goto('/whiteboard/123')\n\n  // Move cursor on page1\n  await page1.mouse.move(200, 200)\n\n  // Page2 should see Alice's cursor\n  await expect(page2.getByTestId('cursor-user-1')).toBeVisible()\n  await expect(page2.getByText('Alice')).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Role-Based Testing\n\n### Test RBAC\n\n```typescript\nconst roles = [\n  {role: 'admin', canDelete: true, canEdit: true, canView: true},\n  {role: 'editor', canDelete: false, canEdit: true, canView: true},\n  {role: 'viewer', canDelete: false, canEdit: false, canView: true},\n]\n\nfor (const {role, canDelete, canEdit, canView} of roles) {\n  test(`${role} permissions`, async ({browser}) => {\n    const context = await browser.newContext({\n      storageState: `.auth/${role}.json`,\n    })\n    const page = await context.newPage()\n\n    await page.goto('/document/123')\n\n    // Check view permission\n    if (canView) {\n      await expect(page.getByTestId('content')).toBeVisible()\n    } else {\n      await expect(page.getByText('Access denied')).toBeVisible()\n    }\n\n    // Check edit permission\n    const editButton = page.getByRole('button', {name: 'Edit'})\n    if (canEdit) {\n      await expect(editButton).toBeEnabled()\n    } else {\n      await expect(editButton).toBeDisabled()\n    }\n\n    // Check delete permission\n    const deleteButton = page.getByRole('button', {name: 'Delete'})\n    if (canDelete) {\n      await expect(deleteButton).toBeVisible()\n    } else {\n      await expect(deleteButton).toBeHidden()\n    }\n\n    await context.close()\n  })\n}\n```\n\n### Permission Escalation Test\n\n```typescript\ntest('cannot access admin routes as user', async ({browser}) => {\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n  const page = await userContext.newPage()\n\n  // Try to access admin page directly\n  await page.goto('/admin/users')\n\n  // Should redirect or show error\n  await expect(page).not.toHaveURL('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n\n  await userContext.close()\n})\n```\n\n## Concurrent Actions\n\n### Race Condition Testing\n\n```typescript\ntest('handles concurrent edits', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  await page1.goto('/item/123')\n  await page2.goto('/item/123')\n\n  // Both click edit at the same time\n  await Promise.all([\n    page1.getByRole('button', {name: 'Edit'}).click(),\n    page2.getByRole('button', {name: 'Edit'}).click(),\n  ])\n\n  // Both try to save different values\n  await page1.getByLabel('Name').fill('Value from User 1')\n  await page2.getByLabel('Name').fill('Value from User 2')\n\n  await Promise.all([\n    page1.getByRole('button', {name: 'Save'}).click(),\n    page2.getByRole('button', {name: 'Save'}).click(),\n  ])\n\n  // One should succeed, one should get conflict error\n  const page1HasConflict = await page1.getByText('Conflict').isVisible()\n  const page2HasConflict = await page2.getByText('Conflict').isVisible()\n\n  // Exactly one should have conflict\n  expect(page1HasConflict || page2HasConflict).toBe(true)\n  expect(page1HasConflict && page2HasConflict).toBe(false)\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n### Optimistic Locking Test\n\n```typescript\ntest('optimistic locking prevents overwrites', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Both load the same version\n  await page1.goto('/record/123')\n  await page2.goto('/record/123')\n\n  // User 1 edits and saves first\n  await page1.getByRole('button', {name: 'Edit'}).click()\n  await page1.getByLabel('Value').fill('Updated by User 1')\n  await page1.getByRole('button', {name: 'Save'}).click()\n  await expect(page1.getByText('Saved')).toBeVisible()\n\n  // User 2 tries to save with stale version\n  await page2.getByRole('button', {name: 'Edit'}).click()\n  await page2.getByLabel('Value').fill('Updated by User 2')\n  await page2.getByRole('button', {name: 'Save'}).click()\n\n  // Should fail with version conflict\n  await expect(page2.getByText('Someone else modified this')).toBeVisible()\n  await expect(page2.getByRole('button', {name: 'Reload'})).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Chat & Messaging\n\n### Real-Time Chat\n\n```typescript\ntest('chat messages sync between users', async ({browser}) => {\n  const aliceCtx = await browser.newContext()\n  const bobCtx = await browser.newContext()\n\n  const alicePage = await aliceCtx.newPage()\n  const bobPage = await bobCtx.newPage()\n\n  // Setup user identities\n  await alicePage.route('**/api/me', (r) => r.fulfill({json: {name: 'Alice'}}))\n  await bobPage.route('**/api/me', (r) => r.fulfill({json: {name: 'Bob'}}))\n\n  await alicePage.goto('/chat/room-1')\n  await bobPage.goto('/chat/room-1')\n\n  // Alice sends message\n  await alicePage.getByLabel('Message').fill('Hi Bob!')\n  await alicePage.getByRole('button', {name: 'Send'}).click()\n\n  // Bob sees it\n  await expect(bobPage.getByText('Alice: Hi Bob!')).toBeVisible()\n\n  // Bob replies\n  await bobPage.getByLabel('Message').fill('Hey Alice!')\n  await bobPage.getByRole('button', {name: 'Send'}).click()\n\n  // Alice sees it\n  await expect(alicePage.getByText('Bob: Hey Alice!')).toBeVisible()\n\n  await aliceCtx.close()\n  await bobCtx.close()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                       | Solution                     |\n| ----------------------------- | ----------------------------- | ---------------------------- |\n| Sharing context between users | State leaks, not isolated     | Create separate contexts     |\n| Not closing contexts          | Memory leak, browser overload | Always close in cleanup      |\n| Hardcoded timing for sync     | Flaky tests                   | Use `expect().toBeVisible()` |\n| Testing only single user      | Misses collaboration bugs     | Test multi-user scenarios    |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n",".agents/skills/playwright-best-practices/advanced/network-advanced.md":"# Advanced Network Interception\n\n## Table of Contents\n\n1. [Request Modification](#request-modification)\n2. [GraphQL Mocking](#graphql-mocking)\n3. [HAR Recording & Playback](#har-recording--playback)\n4. [Conditional Mocking](#conditional-mocking)\n5. [Network Throttling](#network-throttling)\n\n## Request Modification\n\n### Modify Request Headers\n\n```typescript\ntest('add auth header to requests', async ({page}) => {\n  await page.route('**/api/**', (route) => {\n    const headers = {\n      ...route.request().headers(),\n      'Authorization': 'Bearer test-token',\n      'X-Test-Header': 'test-value',\n    }\n    route.continue({headers})\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### Modify Request Body\n\n```typescript\ntest('modify POST body', async ({page}) => {\n  await page.route('**/api/orders', async (route) => {\n    if (route.request().method() === 'POST') {\n      const postData = route.request().postDataJSON()\n\n      // Add test metadata\n      const modifiedData = {\n        ...postData,\n        testMode: true,\n        testTimestamp: Date.now(),\n      }\n\n      await route.continue({\n        postData: JSON.stringify(modifiedData),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n})\n```\n\n### Transform Response\n\n```typescript\ntest('modify API response', async ({page}) => {\n  await page.route('**/api/products', async (route) => {\n    // Fetch real response\n    const response = await route.fetch()\n    const json = await response.json()\n\n    // Modify response\n    const modified = json.map((product: any) => ({\n      ...product,\n      price: product.price * 0.9, // 10% discount\n      testMode: true,\n    }))\n\n    await route.fulfill({\n      response,\n      json: modified,\n    })\n  })\n\n  await page.goto('/products')\n})\n```\n\n## GraphQL Mocking\n\n### Mock by Operation Name\n\n```typescript\ntest('mock GraphQL query', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'GetUser') {\n      return route.fulfill({\n        json: {\n          data: {\n            user: {\n              id: '1',\n              name: 'Test User',\n              email: 'test@example.com',\n            },\n          },\n        },\n      })\n    }\n\n    if (postData.operationName === 'GetProducts') {\n      return route.fulfill({\n        json: {\n          data: {\n            products: [\n              {id: '1', name: 'Product A', price: 29.99},\n              {id: '2', name: 'Product B', price: 49.99},\n            ],\n          },\n        },\n      })\n    }\n\n    // Pass through unmocked operations\n    return route.continue()\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### GraphQL Mock Fixture\n\n```typescript\n// fixtures/graphql.fixture.ts\ntype GraphQLMock = {\n  operation: string\n  variables?: Record<string, any>\n  response: {data?: any; errors?: any[]}\n}\n\ntype GraphQLFixtures = {\n  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>\n}\n\nexport const test = base.extend<GraphQLFixtures>({\n  mockGraphQL: async ({page}, use) => {\n    await use(async (mocks) => {\n      await page.route('**/graphql', async (route) => {\n        const postData = route.request().postDataJSON()\n\n        const mock = mocks.find((m) => {\n          if (m.operation !== postData.operationName) return false\n\n          // Optionally match variables\n          if (m.variables) {\n            return JSON.stringify(m.variables) === JSON.stringify(postData.variables)\n          }\n          return true\n        })\n\n        if (mock) {\n          return route.fulfill({json: mock.response})\n        }\n\n        return route.continue()\n      })\n    })\n  },\n})\n\n// Usage\ntest('dashboard with mocked GraphQL', async ({page, mockGraphQL}) => {\n  await mockGraphQL([\n    {\n      operation: 'GetDashboardStats',\n      response: {\n        data: {stats: {users: 100, revenue: 50000}},\n      },\n    },\n    {\n      operation: 'GetUser',\n      variables: {id: '1'},\n      response: {\n        data: {user: {id: '1', name: 'John'}},\n      },\n    },\n  ])\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('100 users')).toBeVisible()\n})\n```\n\n### Mock GraphQL Mutations\n\n```typescript\ntest('mock GraphQL mutation', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'CreateOrder') {\n      const {input} = postData.variables\n\n      return route.fulfill({\n        json: {\n          data: {\n            createOrder: {\n              id: 'order-123',\n              status: 'PENDING',\n              items: input.items,\n              total: input.items.reduce(\n                (sum: number, item: any) => sum + item.price * item.quantity,\n                0,\n              ),\n            },\n          },\n        },\n      })\n    }\n\n    return route.continue()\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  await expect(page.getByText('Order #order-123')).toBeVisible()\n})\n```\n\n## HAR Recording & Playback\n\n### Record HAR File\n\n```typescript\n// Record network traffic\ntest('record HAR', async ({page, context}) => {\n  // Start recording\n  await context.routeFromHAR('./recordings/checkout.har', {\n    update: true, // Create/update HAR file\n    url: '**/api/**',\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  // HAR file is saved automatically\n})\n```\n\n### Playback HAR File\n\n```typescript\n// Use recorded HAR for offline testing\ntest('playback HAR', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/checkout.har', {\n    url: '**/api/**',\n    update: false, // Don't update, just playback\n  })\n\n  await page.goto('/checkout')\n\n  // All API calls served from HAR file\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### HAR with Fallback\n\n```typescript\ntest('HAR with live fallback', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/api.har', {\n    url: '**/api/**',\n    update: false,\n    notFound: 'fallback', // Use real network if not in HAR\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n## Conditional Mocking\n\n### Mock Based on Request Body\n\n```typescript\ntest('conditional mock by body', async ({page}) => {\n  await page.route('**/api/search', async (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.query === 'error') {\n      return route.fulfill({\n        status: 500,\n        json: {error: 'Search failed'},\n      })\n    }\n\n    if (body.query === 'empty') {\n      return route.fulfill({\n        json: {results: []},\n      })\n    }\n\n    // Default response\n    return route.fulfill({\n      json: {\n        results: [{id: 1, title: `Result for: ${body.query}`}],\n      },\n    })\n  })\n\n  await page.goto('/search')\n\n  // Test different scenarios\n  await page.getByLabel('Search').fill('error')\n  await page.getByLabel('Search').press('Enter')\n  await expect(page.getByText('Search failed')).toBeVisible()\n})\n```\n\n### Mock Nth Request\n\n```typescript\ntest('different response on retry', async ({page}) => {\n  let callCount = 0\n\n  await page.route('**/api/status', (route) => {\n    callCount++\n\n    if (callCount < 3) {\n      return route.fulfill({\n        status: 503,\n        json: {error: 'Service unavailable'},\n      })\n    }\n\n    // Succeed on 3rd attempt\n    return route.fulfill({\n      json: {status: 'ok'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // App should retry and eventually succeed\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n### Mock with Delay\n\n```typescript\ntest('slow network simulation', async ({page}) => {\n  await page.route('**/api/data', async (route) => {\n    // Simulate 2 second delay\n    await new Promise((resolve) => setTimeout(resolve, 2000))\n\n    return route.fulfill({\n      json: {data: 'loaded'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Loading state should appear\n  await expect(page.getByText('Loading...')).toBeVisible()\n\n  // Then data appears\n  await expect(page.getByText('loaded')).toBeVisible()\n})\n```\n\n## Network Throttling\n\n### Slow 3G Simulation\n\n```typescript\ntest('slow network experience', async ({page, context}) => {\n  // Create CDP session for network throttling\n  const client = await context.newCDPSession(page)\n\n  await client.send('Network.emulateNetworkConditions', {\n    offline: false,\n    downloadThroughput: (500 * 1024) / 8, // 500 Kbps\n    uploadThroughput: (500 * 1024) / 8,\n    latency: 400, // 400ms\n  })\n\n  await page.goto('/')\n\n  // Test loading states appear\n  await expect(page.getByTestId('skeleton-loader')).toBeVisible()\n})\n```\n\n### Offline Mode\n\nUse `context.setOffline(true/false)` to simulate network connectivity changes.\n\n> **For comprehensive offline testing patterns:**\n>\n> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)\n> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)\n\n### Network Throttling Fixture\n\n```typescript\n// fixtures/network.fixture.ts\ntype NetworkCondition = 'slow3g' | 'fast3g' | 'offline'\n\nconst conditions = {\n  slow3g: {downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000},\n  fast3g: {downloadThroughput: 180000, uploadThroughput: 75000, latency: 150},\n}\n\ntype NetworkFixtures = {\n  setNetworkCondition: (condition: NetworkCondition) => Promise<void>\n}\n\nexport const test = base.extend<NetworkFixtures>({\n  setNetworkCondition: async ({page, context}, use) => {\n    const client = await context.newCDPSession(page)\n\n    await use(async (condition) => {\n      if (condition === 'offline') {\n        await context.setOffline(true)\n      } else {\n        await client.send('Network.emulateNetworkConditions', {\n          offline: false,\n          ...conditions[condition],\n        })\n      }\n    })\n\n    // Reset\n    await context.setOffline(false)\n  },\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                         |\n| ------------------------ | ------------------------------ | -------------------------------- |\n| Mocking all requests     | Tests don't reflect reality    | Mock only what's necessary       |\n| No cleanup of routes     | Routes persist across tests    | Use fixtures with cleanup        |\n| Ignoring request method  | Mock applies to wrong requests | Check `route.request().method()` |\n| Hardcoded mock responses | Brittle, hard to maintain      | Use factories for mock data      |\n\n## Related References\n\n- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n",".agents/skills/playwright-best-practices/advanced/third-party.md":"# Third-Party Service Mocking\n\n## Table of Contents\n\n1. [OAuth/SSO Mocking](#oauthsso-mocking)\n2. [Payment Gateway Mocking](#payment-gateway-mocking)\n3. [Email Verification](#email-verification)\n4. [SMS Verification](#sms-verification)\n5. [Analytics & Tracking](#analytics--tracking)\n\n## OAuth/SSO Mocking\n\n### Mock Google OAuth\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  // Mock the OAuth callback\n  await page.route('**/auth/google/callback**', (route) => {\n    const url = new URL(route.request().url())\n    // Simulate successful OAuth by redirecting with token\n    route.fulfill({\n      status: 302,\n      headers: {\n        Location: '/dashboard?token=mock-jwt-token',\n      },\n    })\n  })\n\n  // Mock the token verification endpoint\n  await page.route('**/api/auth/verify', (route) =>\n    route.fulfill({\n      json: {\n        valid: true,\n        user: {\n          id: '123',\n          email: 'test@gmail.com',\n          name: 'Test User',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n```typescript\n// fixtures/oauth.fixture.ts\ntype OAuthProvider = 'google' | 'github' | 'microsoft'\n\ntype OAuthUser = {\n  id: string\n  email: string\n  name: string\n  avatar?: string\n}\n\ntype OAuthFixtures = {\n  mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>\n}\n\nexport const test = base.extend<OAuthFixtures>({\n  mockOAuth: async ({page}, use) => {\n    await use(async (provider, user) => {\n      // Mock callback redirect\n      await page.route(`**/auth/${provider}/callback**`, (route) =>\n        route.fulfill({\n          status: 302,\n          headers: {Location: `/auth/success?provider=${provider}`},\n        }),\n      )\n\n      // Mock session/user endpoint\n      await page.route('**/api/auth/session', (route) =>\n        route.fulfill({\n          json: {user, provider, authenticated: true},\n        }),\n      )\n\n      // Mock user info endpoint\n      await page.route('**/api/me', (route) => route.fulfill({json: user}))\n    })\n  },\n})\n\n// Usage\ntest('login with GitHub', async ({page, mockOAuth}) => {\n  await mockOAuth('github', {\n    id: 'gh-123',\n    email: 'dev@github.com',\n    name: 'GitHub User',\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with GitHub'}).click()\n\n  await expect(page.getByText('Welcome, GitHub User')).toBeVisible()\n})\n```\n\n### Mock SAML SSO\n\n```typescript\ntest('SAML SSO login', async ({page}) => {\n  // Mock SAML assertion consumer service\n  await page.route('**/saml/acs', async (route) => {\n    route.fulfill({\n      status: 302,\n      headers: {\n        'Location': '/dashboard',\n        'Set-Cookie': 'session=mock-saml-session; Path=/; HttpOnly',\n      },\n    })\n  })\n\n  // Mock session validation\n  await page.route('**/api/session', (route) =>\n    route.fulfill({\n      json: {\n        user: {email: 'user@company.com', name: 'SSO User'},\n        provider: 'saml',\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'SSO Login'}).click()\n\n  await expect(page).toHaveURL('/dashboard')\n})\n```\n\n## Payment Gateway Mocking\n\n### Mock Stripe\n\n```typescript\ntest('Stripe checkout', async ({page}) => {\n  // Mock Stripe.js\n  await page.addInitScript(() => {\n    ;(window as any).Stripe = () => ({\n      elements: () => ({\n        create: () => ({\n          mount: () => {},\n          on: () => {},\n          destroy: () => {},\n        }),\n      }),\n      confirmCardPayment: async () => ({\n        paymentIntent: {status: 'succeeded', id: 'pi_mock_123'},\n      }),\n      createPaymentMethod: async () => ({\n        paymentMethod: {id: 'pm_mock_123'},\n      }),\n    })\n  })\n\n  // Mock backend payment endpoint\n  await page.route('**/api/create-payment-intent', (route) =>\n    route.fulfill({\n      json: {clientSecret: 'pi_mock_123_secret_mock'},\n    }),\n  )\n\n  await page.route('**/api/confirm-payment', (route) =>\n    route.fulfill({\n      json: {success: true, orderId: 'order-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay $99.99'}).click()\n\n  await expect(page.getByText('Payment successful')).toBeVisible()\n})\n```\n\n### Mock PayPal\n\n```typescript\ntest('PayPal checkout', async ({page}) => {\n  // Mock PayPal SDK\n  await page.addInitScript(() => {\n    ;(window as any).paypal = {\n      Buttons: () => ({\n        render: () => Promise.resolve(),\n        isEligible: () => true,\n      }),\n      FUNDING: {PAYPAL: 'paypal', CARD: 'card'},\n    }\n  })\n\n  // Mock PayPal order creation\n  await page.route('**/api/paypal/create-order', (route) =>\n    route.fulfill({\n      json: {orderId: 'PAYPAL-ORDER-123'},\n    }),\n  )\n\n  // Mock PayPal capture\n  await page.route('**/api/paypal/capture', (route) =>\n    route.fulfill({\n      json: {success: true, transactionId: 'TXN-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n\n  // Simulate PayPal approval callback\n  await page.evaluate(() => {\n    ;(window as any).onPayPalApprove?.({orderID: 'PAYPAL-ORDER-123'})\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Payment Fixture\n\n```typescript\n// fixtures/payment.fixture.ts\ntype PaymentFixtures = {\n  mockStripe: (options?: {failPayment?: boolean}) => Promise<void>\n}\n\nexport const test = base.extend<PaymentFixtures>({\n  mockStripe: async ({page}, use) => {\n    await use(async (options = {}) => {\n      await page.addInitScript(\n        ([shouldFail]) => {\n          ;(window as any).Stripe = () => ({\n            elements: () => ({\n              create: () => ({\n                mount: () => {},\n                on: (event: string, handler: Function) => {\n                  if (event === 'ready') setTimeout(handler, 100)\n                },\n                destroy: () => {},\n              }),\n            }),\n            confirmCardPayment: async () => {\n              if (shouldFail) {\n                return {error: {message: 'Card declined'}}\n              }\n              return {paymentIntent: {status: 'succeeded'}}\n            },\n          })\n        },\n        [options.failPayment],\n      )\n    })\n  },\n})\n\n// Usage\ntest('handles declined card', async ({page, mockStripe}) => {\n  await mockStripe({failPayment: true})\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay'}).click()\n\n  await expect(page.getByText('Card declined')).toBeVisible()\n})\n```\n\n## Email Verification\n\n### Mock Email API\n\n```typescript\ntest('email verification flow', async ({page, request}) => {\n  let verificationToken: string\n\n  // Capture the verification email\n  await page.route('**/api/send-verification', async (route) => {\n    const body = route.request().postDataJSON()\n    verificationToken = `mock-token-${Date.now()}`\n\n    // Don't actually send email, just store token\n    route.fulfill({\n      json: {sent: true, messageId: 'msg-123'},\n    })\n  })\n\n  // Mock token verification\n  await page.route('**/api/verify-email**', (route) => {\n    const url = new URL(route.request().url())\n    const token = url.searchParams.get('token')\n\n    if (token === verificationToken) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid token'}})\n    }\n  })\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  await expect(page.getByText('Check your email')).toBeVisible()\n\n  // Simulate clicking email link\n  await page.goto(`/verify?token=${verificationToken}`)\n\n  await expect(page.getByText('Email verified')).toBeVisible()\n})\n```\n\n### Use Mailinator/Temp Mail\n\n```typescript\n// fixtures/email.fixture.ts\ntype EmailFixtures = {\n  getVerificationEmail: (inbox: string) => Promise<{link: string}>\n}\n\nexport const test = base.extend<EmailFixtures>({\n  getVerificationEmail: async ({request}, use) => {\n    await use(async (inbox) => {\n      // Poll Mailinator API for new email\n      const response = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const messages = await response.json()\n      const latest = messages.msgs[0]\n\n      // Get full message\n      const msgResponse = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const message = await msgResponse.json()\n\n      // Extract verification link from HTML\n      const linkMatch = message.parts[0].body.match(/href=\"([^\"]*verify[^\"]*)\"/)\n      return {link: linkMatch?.[1] || ''}\n    })\n  },\n})\n```\n\n## SMS Verification\n\n### Mock SMS API\n\n```typescript\ntest('SMS verification', async ({page}) => {\n  let smsCode: string\n\n  // Capture SMS send\n  await page.route('**/api/send-sms', (route) => {\n    smsCode = Math.random().toString().slice(2, 8) // 6-digit code\n\n    route.fulfill({\n      json: {sent: true, messageId: 'sms-123'},\n    })\n  })\n\n  // Mock code verification\n  await page.route('**/api/verify-sms', (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.code === smsCode) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid code'}})\n    }\n  })\n\n  await page.goto('/verify-phone')\n  await page.getByLabel('Phone').fill('+1234567890')\n  await page.getByRole('button', {name: 'Send Code'}).click()\n\n  // Enter the code\n  await page.getByLabel('Verification Code').fill(smsCode)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await expect(page.getByText('Phone verified')).toBeVisible()\n})\n```\n\n## Analytics & Tracking\n\n### Block Analytics in Tests\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Block all analytics/tracking\n  await page.route(\n    /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,\n    (route) => route.abort(),\n  )\n})\n```\n\n### Mock Analytics for Verification\n\n```typescript\ntest('tracks purchase event', async ({page}) => {\n  const analyticsEvents: any[] = []\n\n  // Capture analytics calls\n  await page.route('**/api/analytics/**', (route) => {\n    analyticsEvents.push(route.request().postDataJSON())\n    route.fulfill({status: 200})\n  })\n\n  // Mock analytics SDK\n  await page.addInitScript(() => {\n    ;(window as any).analytics = {\n      track: (event: string, props: any) => {\n        fetch('/api/analytics/track', {\n          method: 'POST',\n          body: JSON.stringify({event, props}),\n        })\n      },\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n\n  // Verify analytics event was sent\n  expect(analyticsEvents).toContainEqual(\n    expect.objectContaining({\n      event: 'Purchase Completed',\n      props: expect.objectContaining({amount: expect.any(Number)}),\n    }),\n  )\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern              | Problem                        | Solution                |\n| ------------------------- | ------------------------------ | ----------------------- |\n| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints    |\n| Real payment processing   | Charges real money, slow       | Use test mode or mock   |\n| Waiting for real emails   | Very slow, unreliable          | Mock email API          |\n| Not mocking analytics     | Pollutes analytics data        | Block or mock analytics |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n",".agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md":"# Organizing Reusable Test Code\n\n## Table of Contents\n\n1. [Pattern Comparison](#pattern-comparison)\n2. [Selection Flowchart](#selection-flowchart)\n3. [Page Objects](#page-objects)\n4. [Custom Fixtures](#custom-fixtures)\n5. [Helper Functions](#helper-functions)\n6. [Combined Project Structure](#combined-project-structure)\n7. [Anti-Patterns](#anti-patterns)\n\nUse all three patterns together. Most projects benefit from a hybrid approach:\n\n- **Page objects** for UI interaction (pages/components with 5+ interactions)\n- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)\n- **Helper functions** for stateless utilities (generate data, format values, simple waits)\n\nIf only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.\n\n## Pattern Comparison\n\n| Aspect            | Page Objects                            | Custom Fixtures                            | Helper Functions                  |\n| ----------------- | --------------------------------------- | ------------------------------------------ | --------------------------------- |\n| **Purpose**       | Encapsulate UI interactions             | Provide resources with setup/teardown      | Stateless utilities               |\n| **Lifecycle**     | Manual (constructor/methods)            | Built-in (`use()` with automatic teardown) | None                              |\n| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures                   | Call other functions              |\n| **Best for**      | Pages with many reused interactions     | Resources needing setup AND teardown       | Simple logic with no side effects |\n\n## Selection Flowchart\n\n```text\nWhat kind of reusable code?\n|\n+-- Interacts with browser page/component?\n|   |\n|   +-- Has 5+ interactions (fill, click, navigate, assert)?\n|   |   +-- YES: Used in 3+ test files?\n|   |   |   +-- YES --> PAGE OBJECT\n|   |   |   +-- NO --> Inline or small helper\n|   |   +-- NO --> HELPER FUNCTION\n|   |\n|   +-- Needs setup before AND cleanup after test?\n|       +-- YES --> CUSTOM FIXTURE\n|       +-- NO --> PAGE OBJECT method or HELPER\n|\n+-- Manages resource with lifecycle (create/destroy)?\n|   +-- Examples: auth state, DB connection, API client, test user\n|   +-- YES --> CUSTOM FIXTURE (always)\n|\n+-- Stateless utility? (no browser, no side effects)\n|   +-- Examples: random email, format date, build URL, parse response\n|   +-- YES --> HELPER FUNCTION\n|\n+-- Not sure?\n    +-- Start with HELPER FUNCTION\n    +-- Promote to PAGE OBJECT when interactions grow\n    +-- Promote to FIXTURE when lifecycle needed\n```\n\n## Page Objects\n\nBest for pages/components with 5+ interactions appearing in 3+ test files.\n\n```typescript\n// page-objects/booking.page.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class BookingPage {\n  readonly page: Page\n  readonly dateField: Locator\n  readonly guestCount: Locator\n  readonly roomType: Locator\n  readonly reserveBtn: Locator\n  readonly totalPrice: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.dateField = page.getByLabel('Check-in date')\n    this.guestCount = page.getByLabel('Guests')\n    this.roomType = page.getByLabel('Room type')\n    this.reserveBtn = page.getByRole('button', {name: 'Reserve'})\n    this.totalPrice = page.getByTestId('total-price')\n  }\n\n  async goto() {\n    await this.page.goto('/booking')\n  }\n\n  async fillDetails(opts: {date: string; guests: number; room: string}) {\n    await this.dateField.fill(opts.date)\n    await this.guestCount.fill(String(opts.guests))\n    await this.roomType.selectOption(opts.room)\n  }\n\n  async reserve() {\n    await this.reserveBtn.click()\n    await this.page.waitForURL('**/confirmation')\n  }\n\n  async expectPrice(amount: string) {\n    await expect(this.totalPrice).toHaveText(amount)\n  }\n}\n```\n\n```typescript\n// tests/booking/reservation.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {BookingPage} from '../page-objects/booking.page'\n\ntest('complete reservation with standard room', async ({page}) => {\n  const booking = new BookingPage(page)\n  await booking.goto()\n  await booking.fillDetails({date: '2026-03-15', guests: 2, room: 'standard'})\n  await booking.reserve()\n  await expect(page.getByText('Reservation confirmed')).toBeVisible()\n})\n```\n\n**Page object principles:**\n\n- One class per logical page/component, not per URL\n- Constructor takes `Page`\n- Locators as `readonly` properties in constructor\n- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks\n- Navigation methods (`goto`) belong on the page object\n\n## Custom Fixtures\n\nBest for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.\n\n```typescript\n// fixtures/base.fixture.ts\nimport {test as base, expect} from '@playwright/test'\n\nimport {generateMember} from '../helpers/data'\nimport {BookingPage} from '../page-objects/booking.page'\n\ntype Fixtures = {\n  bookingPage: BookingPage\n  member: {email: string; password: string; id: string}\n  loggedInPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<Fixtures>({\n  bookingPage: async ({page}, use) => {\n    await use(new BookingPage(page))\n  },\n\n  member: async ({request}, use) => {\n    const data = generateMember()\n    const res = await request.post('/api/test/members', {data})\n    const member = await res.json()\n    await use(member)\n    await request.delete(`/api/test/members/${member.id}`)\n  },\n\n  loggedInPage: async ({page, member}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(member.email)\n    await page.getByLabel('Password').fill(member.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await expect(page).toHaveURL('/dashboard')\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/dashboard/overview.spec.ts\nimport {test, expect} from '../../fixtures/base.fixture'\n\ntest('member sees dashboard widgets', async ({loggedInPage}) => {\n  await expect(loggedInPage.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible()\n})\n\ntest('new member sees welcome prompt', async ({loggedInPage, member}) => {\n  await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible()\n})\n```\n\n**Fixture principles:**\n\n- Use `test.extend()` — never module-level variables\n- `use()` callback separates setup from teardown\n- Teardown runs even if test fails\n- Fixtures compose: one can depend on another\n- Fixtures are lazy: created only when requested\n- Wrap page objects in fixtures for lifecycle management\n\n## Helper Functions\n\nBest for stateless utilities — generating test data, formatting values, building URLs, parsing responses.\n\n```typescript\n// helpers/data.ts\nimport {randomUUID} from 'node:crypto'\n\nexport function generateEmail(prefix = 'user'): string {\n  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`\n}\n\nexport function generateMember(overrides: Partial<Member> = {}): Member {\n  return {\n    email: generateEmail(),\n    password: 'SecurePass456!',\n    name: 'Test Member',\n    ...overrides,\n  }\n}\n\ninterface Member {\n  email: string\n  password: string\n  name: string\n}\n\nexport function formatPrice(cents: number): string {\n  return `$${(cents / 100).toFixed(2)}`\n}\n```\n\n```typescript\n// helpers/assertions.ts\nimport {type Page, expect} from '@playwright/test'\n\nexport async function expectNotification(page: Page, message: string): Promise<void> {\n  const notification = page.getByRole('alert').filter({hasText: message})\n  await expect(notification).toBeVisible()\n  await expect(notification).toBeHidden({timeout: 10000})\n}\n```\n\n```typescript\n// tests/settings/account.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {expectNotification} from '../../helpers/assertions'\nimport {generateEmail} from '../../helpers/data'\n\ntest('update account email', async ({page}) => {\n  const newEmail = generateEmail('updated')\n  await page.goto('/settings/account')\n  await page.getByLabel('Email').fill(newEmail)\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expectNotification(page, 'Account updated')\n  await expect(page.getByLabel('Email')).toHaveValue(newEmail)\n})\n```\n\n**Helper principles:**\n\n- Pure functions with no side effects\n- No browser state — take `page` as parameter if needed\n- Promote to fixture if setup/teardown needed\n- Promote to page object if many page interactions grow\n- Keep small and focused\n\n## Combined Project Structure\n\n```text\ntests/\n+-- fixtures/\n|   +-- auth.fixture.ts\n|   +-- db.fixture.ts\n|   +-- base.fixture.ts\n+-- page-objects/\n|   +-- login.page.ts\n|   +-- booking.page.ts\n|   +-- components/\n|       +-- data-table.component.ts\n+-- helpers/\n|   +-- data.ts\n|   +-- assertions.ts\n+-- e2e/\n|   +-- auth/\n|   |   +-- login.spec.ts\n|   +-- booking/\n|       +-- reservation.spec.ts\nplaywright.config.ts\n```\n\n**Layer responsibilities:**\n\n| Layer            | Pattern         | Responsibility                                      |\n| ---------------- | --------------- | --------------------------------------------------- |\n| **Test file**    | `test()`        | Describes behavior, orchestrates layers             |\n| **Fixtures**     | `test.extend()` | Resource lifecycle — setup, provide, teardown       |\n| **Page objects** | Classes         | UI interaction — navigation, actions, locators      |\n| **Helpers**      | Functions       | Utilities — data generation, formatting, assertions |\n\n## Anti-Patterns\n\n### Page object managing resources\n\n```typescript\n// BAD: page object handling API calls and database\nclass LoginPage {\n  async createUser() {\n    /* API call */\n  }\n  async deleteUser() {\n    /* API call */\n  }\n  async signIn(email: string, password: string) {\n    /* UI */\n  }\n}\n```\n\nResource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.\n\n### Locator-only page objects\n\n```typescript\n// BAD: no methods, just locators\nclass LoginPage {\n  emailInput = this.page.getByLabel('Email')\n  passwordInput = this.page.getByLabel('Password')\n  submitBtn = this.page.getByRole('button', {name: 'Sign in'})\n  constructor(private page: Page) {}\n}\n```\n\nAdd intent-revealing methods or skip the page object entirely.\n\n### Monolithic fixtures\n\n```typescript\n// BAD: one fixture doing everything\ntest.extend({\n  everything: async ({page, request}, use) => {\n    const user = await createUser(request)\n    const products = await seedProducts(request, 50)\n    await setupPayment(request, user.id)\n    await page.goto('/dashboard')\n    await use({user, products, page})\n    // massive teardown...\n  },\n})\n```\n\nBreak into small, composable fixtures. Each fixture does one thing.\n\n### Helpers with side effects\n\n```typescript\n// BAD: module-level state\nlet createdUserId: string\n\nexport async function createTestUser(request: APIRequestContext) {\n  const res = await request.post('/api/users', {data: {email: 'test@example.com'}})\n  const user = await res.json()\n  createdUserId = user.id // shared across tests!\n  return user\n}\n```\n\nModule-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.\n\n### Over-abstracting simple operations\n\n```typescript\n// BAD: helper for one-liner\nexport async function clickButton(page: Page, name: string) {\n  await page.getByRole('button', {name}).click()\n}\n```\n\nOnly abstract when there is real duplication (3+ usages) or complexity (5+ interactions).\n",".agents/skills/playwright-best-practices/architecture/test-architecture.md":"# Choosing Test Types: E2E, Component, or API\n\n## Table of Contents\n\n1. [Decision Matrix](#decision-matrix)\n2. [API Tests](#api-tests)\n3. [Component Tests](#component-tests)\n4. [E2E Tests](#e2e-tests)\n5. [Layering Test Types](#layering-test-types)\n6. [Common Mistakes](#common-mistakes)\n7. [Related](#related)\n\n> **When to use**: Deciding which test type to write for a feature. Ask: \"What's the cheapest test that gives confidence this works?\"\n\n## Decision Matrix\n\n| Scenario                    | Recommended Type | Rationale                                     |\n| --------------------------- | ---------------- | --------------------------------------------- |\n| Login / auth flow           | E2E              | Cross-page, cookies, redirects, session state |\n| Form submission             | Component        | Isolated validation logic, error states       |\n| CRUD operations             | API              | Data integrity matters more than UI           |\n| Search with results UI      | Component + API  | API for query logic; component for rendering  |\n| Cross-page navigation       | E2E              | Routing, history, deep linking                |\n| API error handling          | API              | Status codes, error shapes, edge cases        |\n| UI error feedback           | Component        | Toast, banner, inline error rendering         |\n| Accessibility               | Component        | ARIA roles, keyboard nav per-component        |\n| Responsive layout           | Component        | Viewport-specific rendering without full app  |\n| API contract validation     | API              | Response shapes, headers, auth                |\n| WebSocket/real-time         | E2E              | Requires full browser environment             |\n| Payment / checkout          | E2E              | Multi-step, third-party iframes               |\n| Onboarding wizard           | E2E              | Multi-step, state persists across pages       |\n| Widget behavior             | Component        | Toggle, accordion, date picker, modal         |\n| Permissions / authorization | API              | Role-based access is backend logic            |\n\n## API Tests\n\n**Ideal for**:\n\n- CRUD operations (create, read, update, delete)\n- Input validation and error responses (400, 422)\n- Permission and authorization checks\n- Data integrity and business rules\n- API contract verification\n- Edge cases expensive to reproduce through UI\n- Test data setup/teardown for E2E tests\n\n**Avoid for**:\n\n- Testing how errors display to users\n- Browser-specific behavior (cookies, redirects)\n- Visual layout or responsive design\n- Flows requiring JavaScript execution or DOM interaction\n- Third-party iframe interactions\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Products API', () => {\n  let token: string\n\n  test.beforeAll(async ({request}) => {\n    const res = await request.post('/api/auth/token', {\n      data: {email: 'manager@shop.io', password: 'mgr-secret'},\n    })\n    token = (await res.json()).accessToken\n  })\n\n  test('creates product with valid payload', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Widget Pro', sku: 'WGT-100', price: 29.99},\n    })\n\n    expect(res.status()).toBe(201)\n    const product = await res.json()\n    expect(product).toMatchObject({name: 'Widget Pro', sku: 'WGT-100'})\n    expect(product).toHaveProperty('id')\n  })\n\n  test('rejects duplicate SKU with 409', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Duplicate', sku: 'WGT-100', price: 19.99},\n    })\n\n    expect(res.status()).toBe(409)\n    expect((await res.json()).message).toContain('already exists')\n  })\n\n  test('returns 422 for missing required fields', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Incomplete'},\n    })\n\n    expect(res.status()).toBe(422)\n    const err = await res.json()\n    expect(err.errors).toContainEqual(expect.objectContaining({field: 'sku'}))\n  })\n\n  test('staff role cannot delete products', async ({request}) => {\n    const staffLogin = await request.post('/api/auth/token', {\n      data: {email: 'staff@shop.io', password: 'staff-pass'},\n    })\n    const staffToken = (await staffLogin.json()).accessToken\n\n    const res = await request.delete('/api/products/123', {\n      headers: {Authorization: `Bearer ${staffToken}`},\n    })\n\n    expect(res.status()).toBe(403)\n  })\n\n  test('lists products with pagination', async ({request}) => {\n    const res = await request.get('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      params: {page: '1', limit: '20'},\n    })\n\n    expect(res.status()).toBe(200)\n    const body = await res.json()\n    expect(body.items).toBeInstanceOf(Array)\n    expect(body.items.length).toBeLessThanOrEqual(20)\n    expect(body).toHaveProperty('totalCount')\n  })\n})\n```\n\n## Component Tests\n\n**Ideal for**:\n\n- Form validation (required fields, format rules, error messages)\n- Interactive widgets (modals, dropdowns, accordions, date pickers)\n- Conditional rendering (show/hide, loading states, empty states)\n- Accessibility per-component (ARIA attributes, keyboard navigation)\n- Responsive layout at different viewports\n- Visual states (hover, focus, disabled, selected)\n\n**Avoid for**:\n\n- Testing routing or navigation between pages\n- Flows requiring real cookies, sessions, or server-side state\n- Data persistence or API contract validation\n- Third-party iframe interactions\n- Anything requiring multiple pages or browser contexts\n\n```typescript\nimport { test, expect } from \"@playwright/experimental-ct-react\";\nimport { ContactForm } from \"../src/components/ContactForm\";\n\ntest.describe(\"ContactForm component\", () => {\n  test(\"displays validation errors on empty submit\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Name is required\")).toBeVisible();\n    await expect(component.getByText(\"Email is required\")).toBeVisible();\n  });\n\n  test(\"rejects malformed email\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"invalid-email\");\n    await component.getByLabel(\"Message\").fill(\"Hello\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Enter a valid email\")).toBeVisible();\n  });\n\n  test(\"invokes onSubmit with form data\", async ({ mount }) => {\n    const submissions: Array<{ name: string; email: string; message: string }> =\n      [];\n    const component = await mount(\n      <ContactForm onSubmit={(data) => submissions.push(data)} />\n    );\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"alex@company.org\");\n    await component.getByLabel(\"Message\").fill(\"Inquiry about pricing\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    expect(submissions).toHaveLength(1);\n    expect(submissions[0]).toEqual({\n      name: \"Alex\",\n      email: \"alex@company.org\",\n      message: \"Inquiry about pricing\",\n    });\n  });\n\n  test(\"disables button during submission\", async ({ mount }) => {\n    const component = await mount(\n      <ContactForm onSubmit={() => {}} submitting={true} />\n    );\n\n    await expect(\n      component.getByRole(\"button\", { name: \"Sending...\" })\n    ).toBeDisabled();\n  });\n\n  test(\"associates labels with inputs for accessibility\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Name\" })\n    ).toBeVisible();\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Email\" })\n    ).toBeVisible();\n  });\n});\n```\n\n## E2E Tests\n\n**Ideal for**:\n\n- Critical user flows that generate revenue (checkout, signup)\n- Authentication flows (login, SSO, MFA, password reset)\n- Multi-page workflows where state carries across navigation\n- Flows involving third-party iframes (payment widgets)\n- Smoke tests validating the entire stack\n- Real-time collaboration requiring multiple browser contexts\n\n**Avoid for**:\n\n- Testing every form validation permutation\n- CRUD operations where UI is a thin wrapper\n- Verifying individual component states\n- Testing API response shapes or error codes\n- Responsive layout at every breakpoint\n- Edge cases that only affect the backend\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('subscription flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.request.post('/api/test/seed-account', {\n      data: {plan: 'free', email: 'subscriber@demo.io'},\n    })\n    await page.goto('/account/upgrade')\n  })\n\n  test('upgrades to premium plan', async ({page}) => {\n    await test.step('select plan', async () => {\n      await expect(page.getByRole('heading', {name: 'Choose Your Plan'})).toBeVisible()\n      await page.getByRole('button', {name: 'Select Premium'}).click()\n    })\n\n    await test.step('enter billing details', async () => {\n      await page.getByLabel('Cardholder name').fill('Sam Johnson')\n      await page.getByLabel('Billing address').fill('456 Oak Ave')\n      await page.getByLabel('City').fill('Seattle')\n      await page.getByRole('combobox', {name: 'State'}).selectOption('WA')\n      await page.getByLabel('Postal code').fill('98101')\n      await page.getByRole('button', {name: 'Continue'}).click()\n    })\n\n    await test.step('complete payment', async () => {\n      const paymentFrame = page.frameLocator('iframe[title=\"Secure Payment\"]')\n      await paymentFrame.getByLabel('Card number').fill('5555555555554444')\n      await paymentFrame.getByLabel('Expiry').fill('09/29')\n      await paymentFrame.getByLabel('CVV').fill('456')\n      await page.getByRole('button', {name: 'Subscribe now'}).click()\n    })\n\n    await test.step('verify success', async () => {\n      await page.waitForURL('**/account/subscription/success**')\n      await expect(page.getByRole('heading', {name: 'Welcome to Premium'})).toBeVisible()\n      await expect(page.getByText(/Subscription #\\d+/)).toBeVisible()\n    })\n  })\n})\n```\n\n## Layering Test Types\n\nEffective test suites combine all three types. Example for an \"inventory management\" feature:\n\n### API Layer (60% of tests)\n\nCover every backend logic permutation. Cheap to run and maintain.\n\n```\ntests/api/inventory.spec.ts\n  - creates item with valid data (201)\n  - rejects duplicate SKU (409)\n  - rejects invalid quantity format (422)\n  - rejects missing required fields (422)\n  - warehouse-staff cannot delete items (403)\n  - unauthenticated request returns 401\n  - lists items with pagination\n  - filters items by category\n  - updates item stock level\n  - archives an item\n  - prevents archiving items with pending orders\n```\n\n### Component Layer (30% of tests)\n\nCover every visual state and interaction.\n\n```\ntests/components/InventoryForm.spec.tsx\n  - shows validation errors on empty submit\n  - shows inline error for invalid SKU format\n  - disables submit while saving\n  - calls onSubmit with form data\n  - resets form after successful save\n\ntests/components/InventoryTable.spec.tsx\n  - renders item rows from props\n  - shows empty state when no items\n  - handles archive confirmation modal\n  - sorts by column header click\n  - shows stock level badges with correct colors\n```\n\n### E2E Layer (10% of tests)\n\nCover only critical paths proving full stack works.\n\n```\ntests/e2e/inventory.spec.ts\n  - manager creates item and sees it in list\n  - manager updates item stock level\n  - warehouse-staff cannot access admin settings\n```\n\n### Execution Profile\n\nFor this feature:\n\n- **11 API tests** — ~2 seconds total, no browser\n- **10 component tests** — ~5 seconds total, real browser but no server\n- **3 E2E tests** — ~15 seconds total, full stack\n\nTotal: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).\n\n## Common Mistakes\n\n| Anti-Pattern                              | Problem                                                  | Better Approach                                                |\n| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |\n| E2E for every validation rule             | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display  |\n| No API tests, all E2E                     | Slow suite, flaky from UI timing, hard to diagnose       | API tests for data/logic, E2E for critical paths only          |\n| Component tests mocking everything        | Tests pass but app broken because mocks drift            | Mock only external boundaries; API tests verify real contracts |\n| Same assertion in API, component, AND E2E | Triple maintenance cost                                  | Each layer tests what it uniquely verifies                     |\n| E2E creating test data via UI             | 2-minute test where 90 seconds is setup                  | Seed via API in `beforeEach`, test actual flow                 |\n| Testing third-party behavior              | Testing that Stripe validates cards (Stripe's job)       | Mock Stripe; trust their contract                              |\n| Skipping API layer                        | Can't tell if bug is frontend or backend                 | API tests isolate backend; component tests isolate frontend    |\n| One giant E2E for entire feature          | 5-minute test failing somewhere with no clear cause      | Focused E2E per critical path; use `test.step()`               |\n\n## Related\n\n- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming\n- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing\n- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests\n- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`\n- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services\n- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic\n",".agents/skills/playwright-best-practices/architecture/when-to-mock.md":"# Mocking Strategy: Real vs Mock Services\n\n## Table of Contents\n\n1. [Core Principle](#core-principle)\n2. [Decision Matrix](#decision-matrix)\n3. [Decision Flowchart](#decision-flowchart)\n4. [Mocking Techniques](#mocking-techniques)\n5. [Real Service Strategies](#real-service-strategies)\n6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)\n7. [Validating Mock Accuracy](#validating-mock-accuracy)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.\n\n## Core Principle\n\n**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.\n\n## Decision Matrix\n\n| Scenario                          | Mock?                    | Strategy                                        |\n| --------------------------------- | ------------------------ | ----------------------------------------------- |\n| Your own REST/GraphQL API         | Never                    | Hit real API against staging or local dev       |\n| Your database (through your API)  | Never                    | Seed via API or fixtures                        |\n| Authentication (your auth system) | Mostly no                | Use `storageState` to skip login in most tests  |\n| Stripe / payment gateway          | Always                   | `route.fulfill()` with expected responses       |\n| SendGrid / email service          | Always                   | Mock the API call, verify request payload       |\n| OAuth providers (Google, GitHub)  | Always                   | Mock token exchange, test your callback handler |\n| Analytics (Segment, Mixpanel)     | Always                   | `route.abort()` or `route.fulfill()`            |\n| Maps / geocoding APIs             | Always                   | Mock with static responses                      |\n| Feature flags (LaunchDarkly)      | Usually                  | Mock to force specific flag states              |\n| CDN / static assets               | Never                    | Let them load normally                          |\n| Flaky external dependency         | CI: mock, local: real    | Conditional mocking based on environment        |\n| Slow external dependency          | Dev: mock, nightly: real | Separate test projects in config                |\n\n## Decision Flowchart\n\n```text\nIs this service part of YOUR codebase?\n├── YES → Do NOT mock. Test the real integration.\n│   ├── Is it slow? → Optimize the service, not the test.\n│   └── Is it flaky? → Fix the service. Flaky infra is a bug.\n└── NO → It's a third-party service.\n    ├── Is it paid per call? → ALWAYS mock.\n    ├── Is it rate-limited? → ALWAYS mock.\n    ├── Is it slow or unreliable? → ALWAYS mock.\n    └── Is it a complex multi-step flow? → Mock with HAR recording.\n```\n\n## Mocking Techniques\n\n### Blocking Unwanted Requests\n\nBlock third-party scripts that slow tests and add no coverage:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {\n    route.abort()\n  })\n})\n\ntest('dashboard renders without tracking scripts', async ({page}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n})\n```\n\n### Full Mock (route.fulfill)\n\nCompletely replace a third-party API response:\n\n```typescript\ntest('order flow with mocked payment service', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        transactionId: 'txn_mock_abc',\n        status: 'completed',\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n\ntest('display error on payment decline', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 402,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        error: {code: 'insufficient_funds', message: 'Card declined.'},\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByRole('alert')).toContainText('Card declined')\n})\n```\n\n### Partial Mock (Modify Responses)\n\nLet the real API call happen but tweak the response:\n\n```typescript\ntest('display low inventory warning', async ({page}) => {\n  await page.route('**/api/inventory/*', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.quantity = 1\n    data.lowStock = true\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/products/widget-pro')\n  await expect(page.getByText('Only 1 remaining')).toBeVisible()\n})\n\ntest('inject test notification into real response', async ({page}) => {\n  await page.route('**/api/alerts', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.items.push({\n      id: 'test-alert',\n      text: 'Report generated',\n      category: 'info',\n    })\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/home')\n  await expect(page.getByText('Report generated')).toBeVisible()\n})\n```\n\n### Record and Replay (HAR Files)\n\nFor complex API sequences (OAuth flows, multi-step wizards):\n\n**Recording:**\n\n```typescript\ntest('capture API traffic for admin panel', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: true,\n  })\n\n  await page.goto('/admin')\n  await page.getByRole('tab', {name: 'Reports'}).click()\n  await page.getByRole('tab', {name: 'Settings'}).click()\n})\n```\n\n**Replaying:**\n\n```typescript\ntest('admin panel loads with recorded data', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: false,\n  })\n\n  await page.goto('/admin')\n  await expect(page.getByRole('heading', {name: 'Reports'})).toBeVisible()\n})\n```\n\n**HAR maintenance:**\n\n- Record against a known-good staging environment\n- Commit `.har` files to version control\n- Re-record when APIs change\n- Scope HAR to specific URL patterns\n\n## Real Service Strategies\n\n### Local Dev Server\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 30_000,\n  },\n  use: {\n    baseURL: 'http://localhost:3000',\n  },\n})\n```\n\n### Staging Environment\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    baseURL: process.env.CI ? 'https://staging.example.com' : 'http://localhost:3000',\n  },\n})\n```\n\n### Test Containers\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'docker compose -f docker-compose.test.yml up --wait',\n    url: 'http://localhost:3000/health',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n  globalTeardown: './tests/global-teardown.ts',\n})\n```\n\n```typescript\n// tests/global-teardown.ts\nimport {execSync} from 'child_process'\n\nexport default function globalTeardown() {\n  if (process.env.CI) {\n    execSync('docker compose -f docker-compose.test.yml down -v')\n  }\n}\n```\n\n## Hybrid Approach: Fixture-Based Mock Control\n\nCreate fixtures that let individual tests opt into mocking specific services:\n\n```typescript\n// tests/fixtures/service-mocks.ts\nimport {test as base} from '@playwright/test'\n\ntype MockConfig = {\n  mockPayments: boolean\n  mockNotifications: boolean\n  mockAnalytics: boolean\n}\n\nexport const test = base.extend<MockConfig>({\n  mockPayments: [true, {option: true}],\n  mockNotifications: [true, {option: true}],\n  mockAnalytics: [true, {option: true}],\n\n  page: async ({page, mockPayments, mockNotifications, mockAnalytics}, use) => {\n    if (mockPayments) {\n      await page.route('**/api/billing/**', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({status: 'paid', id: 'inv_mock_789'}),\n        })\n      })\n    }\n\n    if (mockNotifications) {\n      await page.route('**/api/notify', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({delivered: true}),\n        })\n      })\n    }\n\n    if (mockAnalytics) {\n      await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {\n        route.abort()\n      })\n    }\n\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/billing.spec.ts\nimport {test, expect} from './fixtures/service-mocks'\n\ntest('subscription renewal sends notification', async ({page}) => {\n  await page.goto('/account/billing')\n  await page.getByRole('button', {name: 'Renew Now'}).click()\n  await expect(page.getByText('Subscription renewed')).toBeVisible()\n})\n\ntest.describe('integration suite', () => {\n  test.use({mockPayments: false})\n\n  test('real billing flow against test gateway', async ({page}) => {\n    await page.goto('/account/billing')\n    await page.getByRole('button', {name: 'Renew Now'}).click()\n    await expect(page.getByText('Subscription renewed')).toBeVisible()\n  })\n})\n```\n\n### Environment-Based Test Projects\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'ci-fast',\n      testMatch: '**/*.spec.ts',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'nightly-full',\n      testMatch: '**/*.integration.spec.ts',\n      use: {baseURL: 'https://staging.example.com'},\n      timeout: 120_000,\n    },\n  ],\n})\n```\n\n## Validating Mock Accuracy\n\nGuard against mock drift from real APIs:\n\n```typescript\ntest.describe('contract validation', () => {\n  test('billing mock matches real API shape', async ({request}) => {\n    const realResponse = await request.post('/api/billing/charge', {\n      data: {amount: 5000, currency: 'usd'},\n    })\n    const realBody = await realResponse.json()\n\n    const mockBody = {\n      status: 'paid',\n      id: 'inv_mock_789',\n    }\n\n    expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())\n\n    for (const key of Object.keys(mockBody)) {\n      expect(typeof mockBody[key]).toBe(typeof realBody[key])\n    }\n  })\n})\n```\n\n## Anti-Patterns\n\n| Don't Do This                             | Problem                                                       | Do This Instead                                               |\n| ----------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |\n| Mock your own API                         | Tests pass, app breaks. Zero integration coverage.            | Hit your real API. Mock only third-party services.            |\n| Mock everything for speed                 | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries.                                |\n| Never mock anything                       | Tests are slow, flaky, fail when third parties have outages.  | Mock third-party services.                                    |\n| Use outdated mocks                        | Mock returns different shape than real API.                   | Run contract validation tests. Re-record HAR files regularly. |\n| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation.                          | Use `page.route()` which intercepts at network layer.         |\n| Copy-paste mocks across files             | One API change requires updating many files.                  | Centralize mocks in fixtures.                                 |\n| Block all network and whitelist           | Extremely brittle. Every new endpoint requires update.        | Allow all by default. Selectively mock third-party services.  |\n",".agents/skills/playwright-best-practices/browser-apis/browser-apis.md":"# Browser APIs: Geolocation, Permissions & More\n\n## Table of Contents\n\n1. [Geolocation](#geolocation)\n2. [Permissions](#permissions)\n3. [Clipboard](#clipboard)\n4. [Notifications](#notifications)\n5. [Camera & Microphone](#camera--microphone)\n\n## Geolocation\n\n### Mock Location\n\n```typescript\ntest('shows nearby stores', async ({context}) => {\n  // Grant permission and set location\n  await context.grantPermissions(['geolocation'])\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194}) // San Francisco\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('San Francisco')).toBeVisible()\n})\n```\n\n### Geolocation Fixture\n\n```typescript\n// fixtures/geolocation.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype Coordinates = {latitude: number; longitude: number; accuracy?: number}\n\ntype GeoFixtures = {\n  setLocation: (coords: Coordinates) => Promise<void>\n}\n\nexport const test = base.extend<GeoFixtures>({\n  setLocation: async ({context}, use) => {\n    await context.grantPermissions(['geolocation'])\n\n    await use(async (coords) => {\n      await context.setGeolocation({\n        latitude: coords.latitude,\n        longitude: coords.longitude,\n        accuracy: coords.accuracy ?? 100,\n      })\n    })\n  },\n})\n\n// Usage\ntest('delivery zone check', async ({page, setLocation}) => {\n  await setLocation({latitude: 40.7128, longitude: -74.006}) // NYC\n\n  await page.goto('/delivery')\n\n  await expect(page.getByText('Delivery available')).toBeVisible()\n})\n```\n\n### Test Location Changes\n\n```typescript\ntest('tracks location updates', async ({context}) => {\n  await context.grantPermissions(['geolocation'])\n\n  const page = await context.newPage()\n  await page.goto('/tracking')\n\n  // Initial location\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194})\n  await page.getByRole('button', {name: 'Start Tracking'}).click()\n\n  await expect(page.getByTestId('location')).toContainText('37.7749')\n\n  // Move to new location\n  await context.setGeolocation({latitude: 37.8044, longitude: -122.2712})\n\n  // Trigger location update\n  await page.evaluate(() => {\n    navigator.geolocation.getCurrentPosition(() => {})\n  })\n\n  await expect(page.getByTestId('location')).toContainText('37.8044')\n})\n```\n\n### Test Geolocation Denial\n\n```typescript\ntest('handles location denied', async ({browser}) => {\n  // Create context without geolocation permission\n  const context = await browser.newContext({\n    permissions: [], // No permissions\n  })\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('Location access denied')).toBeVisible()\n  await expect(page.getByLabel('Enter ZIP code')).toBeVisible()\n\n  await context.close()\n})\n```\n\n## Permissions\n\n### Grant Permissions\n\n```typescript\ntest('notifications with permission', async ({context}) => {\n  await context.grantPermissions(['notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/alerts')\n\n  // Notification API should work\n  const permission = await page.evaluate(() => Notification.permission)\n  expect(permission).toBe('granted')\n})\n```\n\n### Test Permission Denied\n\n```typescript\ntest('handles notification permission denied', async ({browser}) => {\n  const context = await browser.newContext({\n    permissions: [], // Deny all\n  })\n\n  const page = await context.newPage()\n  await page.goto('/notifications')\n\n  await page.getByRole('button', {name: 'Enable Notifications'}).click()\n\n  await expect(page.getByText('Please enable notifications')).toBeVisible()\n\n  await context.close()\n})\n```\n\n### Multiple Permissions\n\n```typescript\ntest('video call with permissions', async ({context}) => {\n  await context.grantPermissions(['camera', 'microphone', 'notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/video-call')\n\n  // All permissions should be granted\n  const permissions = await page.evaluate(async () => ({\n    camera: await navigator.permissions.query({\n      name: 'camera' as PermissionName,\n    }),\n    microphone: await navigator.permissions.query({\n      name: 'microphone' as PermissionName,\n    }),\n  }))\n\n  expect(permissions.camera.state).toBe('granted')\n  expect(permissions.microphone.state).toBe('granted')\n})\n```\n\n## Clipboard\n\n### Test Copy to Clipboard\n\n```typescript\ntest('copy button works', async ({page, context}) => {\n  // Grant clipboard permissions\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/share')\n\n  await page.getByRole('button', {name: 'Copy Link'}).click()\n\n  // Read clipboard content\n  const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())\n\n  expect(clipboardContent).toContain('https://example.com/share/')\n})\n```\n\n### Test Paste from Clipboard\n\n```typescript\ntest('paste from clipboard', async ({page, context}) => {\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/editor')\n\n  // Write to clipboard\n  await page.evaluate(() => navigator.clipboard.writeText('Pasted content'))\n\n  // Trigger paste\n  await page.getByLabel('Content').focus()\n  await page.keyboard.press('Control+V')\n\n  await expect(page.getByLabel('Content')).toHaveValue('Pasted content')\n})\n```\n\n### Clipboard Fixture\n\n```typescript\n// fixtures/clipboard.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClipboardFixtures = {\n  clipboard: {\n    write: (text: string) => Promise<void>\n    read: () => Promise<string>\n  }\n}\n\nexport const test = base.extend<ClipboardFixtures>({\n  clipboard: async ({page, context}, use) => {\n    await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n    await use({\n      write: async (text) => {\n        await page.evaluate((t) => navigator.clipboard.writeText(t), text)\n      },\n      read: async () => {\n        return page.evaluate(() => navigator.clipboard.readText())\n      },\n    })\n  },\n})\n```\n\n## Notifications\n\n### Mock Notification API\n\n```typescript\ntest('shows browser notification', async ({page}) => {\n  const notifications: any[] = []\n\n  // Mock Notification constructor\n  await page.addInitScript(() => {\n    ;(window as any).__notifications = []\n    ;(window as any).Notification = class {\n      constructor(title: string, options?: NotificationOptions) {\n        ;(window as any).__notifications.push({title, ...options})\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/alerts')\n  await page.getByRole('button', {name: 'Notify Me'}).click()\n\n  // Check notification was created\n  const created = await page.evaluate(() => (window as any).__notifications)\n  expect(created).toHaveLength(1)\n  expect(created[0].title).toBe('New Alert')\n})\n```\n\n### Test Notification Click\n\n```typescript\ntest('notification click handler', async ({page}) => {\n  await page.addInitScript(() => {\n    ;(window as any).Notification = class {\n      onclick: (() => void) | null = null\n      constructor(title: string) {\n        // Simulate click after creation\n        setTimeout(() => this.onclick?.(), 100)\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/messages')\n  await page.evaluate(() => {\n    new Notification('New Message')\n  })\n\n  // Should navigate to messages when notification clicked\n  await expect(page).toHaveURL(/\\/messages/)\n})\n```\n\n## Camera & Microphone\n\n### Mock Media Devices\n\n```typescript\ntest('video preview works', async ({page, context}) => {\n  await context.grantPermissions(['camera'])\n\n  // Mock getUserMedia\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      canvas.width = 640\n      canvas.height = 480\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/video-settings')\n  await page.getByRole('button', {name: 'Start Camera'}).click()\n\n  await expect(page.getByTestId('video-preview')).toBeVisible()\n})\n```\n\n### Test Media Device Selection\n\n```typescript\ntest('switch camera', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.enumerateDevices = async () =>\n      [\n        {\n          deviceId: 'cam1',\n          kind: 'videoinput',\n          label: 'Front Camera',\n          groupId: '1',\n        },\n        {\n          deviceId: 'cam2',\n          kind: 'videoinput',\n          label: 'Back Camera',\n          groupId: '2',\n        },\n      ] as MediaDeviceInfo[]\n\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/camera')\n\n  // Should show camera options\n  await expect(page.getByRole('combobox', {name: 'Camera'})).toBeVisible()\n  await expect(page.getByText('Front Camera')).toBeVisible()\n  await expect(page.getByText('Back Camera')).toBeVisible()\n})\n```\n\n### Test Media Errors\n\n```typescript\ntest('handles camera access error', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      throw new DOMException('Permission denied', 'NotAllowedError')\n    }\n  })\n\n  await page.goto('/video-call')\n  await page.getByRole('button', {name: 'Join Call'}).click()\n\n  await expect(page.getByText('Camera access denied')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Join Audio Only'})).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                           | Solution                            |\n| ----------------------------- | --------------------------------- | ----------------------------------- |\n| Not granting permissions      | Tests fail with permission errors | Use `context.grantPermissions()`    |\n| Testing real geolocation      | Flaky, environment-dependent      | Mock with `setGeolocation()`        |\n| Not testing permission denial | Misses error handling             | Test both granted and denied states |\n| Using real camera/mic         | CI has no devices                 | Mock `getUserMedia`                 |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures\n- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation\n",".agents/skills/playwright-best-practices/browser-apis/iframes.md":"# iFrame Testing\n\n## Table of Contents\n\n1. [Basic iFrame Access](#basic-iframe-access)\n2. [Cross-Origin iFrames](#cross-origin-iframes)\n3. [Nested iFrames](#nested-iframes)\n4. [Dynamic iFrames](#dynamic-iframes)\n5. [iFrame Navigation](#iframe-navigation)\n6. [Common Patterns](#common-patterns)\n\n## Basic iFrame Access\n\n### Using frameLocator\n\n```typescript\n// Access iframe by selector\nconst frame = page.frameLocator('iframe#payment')\nawait frame.getByRole('button', {name: 'Pay'}).click()\n\n// Access by name attribute\nconst namedFrame = page.frameLocator('iframe[name=\"checkout\"]')\nawait namedFrame.getByLabel('Card number').fill('4242424242424242')\n\n// Access by title\nconst titledFrame = page.frameLocator('iframe[title=\"Payment Form\"]')\n\n// Access by src (partial match)\nconst srcFrame = page.frameLocator('iframe[src*=\"stripe.com\"]')\n```\n\n### Frame vs FrameLocator\n\n```typescript\n// frameLocator - for locator-based operations (recommended)\nconst frameLocator = page.frameLocator('#my-iframe')\nawait frameLocator.getByRole('button').click()\n\n// frame() - for Frame object operations (navigation, evaluation)\nconst frame = page.frame({name: 'my-frame'})\nif (frame) {\n  await frame.goto('https://example.com')\n  const title = await frame.title()\n}\n\n// Get all frames\nconst frames = page.frames()\nfor (const f of frames) {\n  console.log('Frame URL:', f.url())\n}\n```\n\n### Waiting for iFrame Content\n\n```typescript\n// Wait for iframe to load\nconst frame = page.frameLocator('#dynamic-iframe')\n\n// Wait for element inside iframe\nawait expect(frame.getByRole('heading')).toBeVisible({timeout: 10000})\n\n// Wait for iframe src to change\nawait page.waitForFunction(() => {\n  const iframe = document.querySelector('iframe#my-frame') as HTMLIFrameElement\n  return iframe?.src.includes('loaded')\n})\n```\n\n## Cross-Origin iFrames\n\n### Accessing Cross-Origin Content\n\n```typescript\n// Cross-origin iframes work seamlessly with frameLocator\nconst thirdPartyFrame = page.frameLocator('iframe[src*=\"third-party.com\"]')\n\n// Interact with elements inside cross-origin iframe\nawait thirdPartyFrame.getByRole('textbox').fill('test@example.com')\nawait thirdPartyFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Wait for cross-origin iframe to be ready\nawait expect(thirdPartyFrame.locator('body')).toBeVisible()\n```\n\n### Payment Provider iFrames (Stripe, PayPal)\n\n```typescript\ntest('Stripe payment iframe', async ({page}) => {\n  await page.goto('/checkout')\n\n  // Stripe uses multiple iframes for each field\n  const cardFrame = page.frameLocator('iframe[name*=\"__privateStripeFrame\"]').first()\n\n  // Wait for Stripe to initialize\n  await expect(cardFrame.locator('[placeholder=\"Card number\"]')).toBeVisible({\n    timeout: 15000,\n  })\n\n  // Fill card details\n  await cardFrame.locator('[placeholder=\"Card number\"]').fill('4242424242424242')\n  await cardFrame.locator('[placeholder=\"MM / YY\"]').fill('12/30')\n  await cardFrame.locator('[placeholder=\"CVC\"]').fill('123')\n})\n```\n\n### Handling OAuth in iFrames\n\n```typescript\ntest('OAuth iframe flow', async ({page}) => {\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // If OAuth opens in iframe instead of popup\n  const oauthFrame = page.frameLocator('iframe[src*=\"accounts.google.com\"]')\n\n  // Wait for OAuth form\n  await expect(oauthFrame.getByLabel('Email')).toBeVisible({timeout: 10000})\n  await oauthFrame.getByLabel('Email').fill('test@gmail.com')\n})\n```\n\n## Nested iFrames\n\n### Accessing Nested Frames\n\n```typescript\n// Parent iframe contains child iframe\nconst parentFrame = page.frameLocator('#outer-frame')\nconst childFrame = parentFrame.frameLocator('#inner-frame')\n\n// Interact with deeply nested content\nawait childFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Multiple levels of nesting\nconst level1 = page.frameLocator('#level1')\nconst level2 = level1.frameLocator('#level2')\nconst level3 = level2.frameLocator('#level3')\nawait level3.getByText('Deep content').click()\n```\n\n### Finding Elements Across Frame Hierarchy\n\n```typescript\n// Helper to search all frames for an element\nasync function findInAnyFrame(page: Page, selector: string): Promise<Locator | null> {\n  // Check main page first\n  const mainCount = await page.locator(selector).count()\n  if (mainCount > 0) return page.locator(selector)\n\n  // Check all frames\n  for (const frame of page.frames()) {\n    const count = await frame.locator(selector).count()\n    if (count > 0) {\n      return frame.locator(selector)\n    }\n  }\n  return null\n}\n\ntest('find element in any frame', async ({page}) => {\n  await page.goto('/complex-page')\n  const element = await findInAnyFrame(page, '[data-testid=\"submit-btn\"]')\n  if (element) await element.click()\n})\n```\n\n## Dynamic iFrames\n\n### iFrames Created at Runtime\n\n```typescript\ntest('handle dynamically created iframe', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Click button that creates iframe\n  await page.getByRole('button', {name: 'Open Widget'}).click()\n\n  // Wait for iframe to appear in DOM\n  await page.waitForSelector('iframe#widget-frame')\n\n  // Now access the frame\n  const widgetFrame = page.frameLocator('#widget-frame')\n  await expect(widgetFrame.getByText('Widget Loaded')).toBeVisible()\n})\n```\n\n### iFrames with Changing src\n\n```typescript\ntest('iframe src changes', async ({page}) => {\n  await page.goto('/multi-step')\n\n  const frame = page.frameLocator('#step-frame')\n\n  // Step 1\n  await expect(frame.getByText('Step 1')).toBeVisible()\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for iframe to reload with new content\n  await expect(frame.getByText('Step 2')).toBeVisible({timeout: 10000})\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Step 3\n  await expect(frame.getByText('Step 3')).toBeVisible({timeout: 10000})\n})\n```\n\n### Lazy-Loaded iFrames\n\n```typescript\ntest('lazy loaded iframe', async ({page}) => {\n  await page.goto('/page-with-lazy-iframe')\n\n  // Scroll to trigger lazy load\n  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))\n\n  // Wait for iframe to load\n  const lazyFrame = page.frameLocator('#lazy-iframe')\n  await expect(lazyFrame.locator('body')).not.toBeEmpty({timeout: 15000})\n\n  // Interact with content\n  await lazyFrame.getByRole('button').click()\n})\n```\n\n## iFrame Navigation\n\n### Navigating Within iFrame\n\n```typescript\ntest('iframe internal navigation', async ({page}) => {\n  await page.goto('/app')\n\n  // Get frame object for navigation control\n  const frame = page.frame({name: 'content-frame'})\n  if (!frame) throw new Error('Frame not found')\n\n  // Navigate within iframe\n  await frame.goto('https://embedded-app.com/page2')\n\n  // Wait for navigation\n  await frame.waitForURL('**/page2')\n\n  // Verify content\n  await expect(frame.getByRole('heading')).toHaveText('Page 2')\n})\n```\n\n### Handling Frame Navigation Events\n\n```typescript\ntest('track iframe navigation', async ({page}) => {\n  const navigations: string[] = []\n\n  // Listen to frame navigation\n  page.on('framenavigated', (frame) => {\n    if (frame.parentFrame()) {\n      // This is an iframe navigation\n      navigations.push(frame.url())\n    }\n  })\n\n  await page.goto('/with-iframe')\n  await page.frameLocator('#nav-frame').getByRole('link', {name: 'Page 2'}).click()\n\n  // Verify navigation occurred\n  expect(navigations.some((url) => url.includes('page2'))).toBe(true)\n})\n```\n\n## Common Patterns\n\n### iFrame Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base, FrameLocator} from '@playwright/test'\n\nexport const test = base.extend<{paymentFrame: FrameLocator}>({\n  paymentFrame: async ({page}, use) => {\n    await page.goto('/checkout')\n\n    // Wait for payment iframe to be ready\n    const frame = page.frameLocator('iframe[src*=\"payment\"]')\n    await expect(frame.locator('body')).toBeVisible({timeout: 15000})\n\n    await use(frame)\n  },\n})\n\n// test file\ntest('complete payment', async ({paymentFrame}) => {\n  await paymentFrame.getByLabel('Card').fill('4242424242424242')\n  await paymentFrame.getByRole('button', {name: 'Pay'}).click()\n})\n```\n\n### Debugging iFrame Issues\n\n```typescript\ntest('debug iframe content', async ({page}) => {\n  await page.goto('/page-with-iframes')\n\n  // List all frames\n  console.log('All frames:')\n  for (const frame of page.frames()) {\n    console.log(`  - ${frame.name() || '(unnamed)'}: ${frame.url()}`)\n  }\n\n  // Screenshot specific iframe content\n  const frame = page.frame({name: 'target-frame'})\n  if (frame) {\n    const body = frame.locator('body')\n    await body.screenshot({path: 'iframe-content.png'})\n  }\n\n  // Get iframe HTML for debugging\n  const frameContent = page.frameLocator('#my-frame')\n  const html = await frameContent.locator('body').innerHTML()\n  console.log('iFrame HTML:', html.substring(0, 500))\n})\n```\n\n### Handling iFrame Load Failures\n\n```typescript\ntest('handle iframe load failure', async ({page}) => {\n  await page.goto('/page-with-unreliable-iframe')\n\n  const frame = page.frameLocator('#unreliable-frame')\n\n  try {\n    // Try to interact with iframe content\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 5000})\n    await frame.getByRole('button').click()\n  } catch (error) {\n    // Fallback: refresh iframe\n    await page.evaluate(() => {\n      const iframe = document.querySelector('#unreliable-frame') as HTMLIFrameElement\n      if (iframe) iframe.src = iframe.src\n    })\n\n    // Retry\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 10000})\n    await frame.getByRole('button').click()\n  }\n})\n```\n\n### Mocking iFrame Content\n\n```typescript\ntest('mock iframe response', async ({page}) => {\n  // Intercept iframe src request\n  await page.route('**/embedded-widget**', (route) => {\n    route.fulfill({\n      contentType: 'text/html',\n      body: `\n        <!DOCTYPE html>\n        <html>\n          <body>\n            <h1>Mocked Widget</h1>\n            <button>Mocked Button</button>\n          </body>\n        </html>\n      `,\n    })\n  })\n\n  await page.goto('/page-with-widget')\n\n  const frame = page.frameLocator('#widget-frame')\n  await expect(frame.getByRole('heading')).toHaveText('Mocked Widget')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                           | Solution                                           |\n| ------------------------------------- | --------------------------------- | -------------------------------------------------- |\n| Using `page.frame()` for interactions | Less reliable than frameLocator   | Use `page.frameLocator()` for element interactions |\n| Hardcoding iframe index               | Fragile if DOM order changes      | Use name, id, or src attribute selectors           |\n| Not waiting for iframe load           | Race conditions                   | Wait for element inside iframe to be visible       |\n| Assuming same-origin                  | Cross-origin has different timing | Always wait for iframe content explicitly          |\n| Ignoring nested iframes               | Element not found                 | Chain frameLocator calls for nested frames         |\n\n## Related References\n\n- **Locators**: See [locators.md](../core/locators.md) for selector strategies\n- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues\n",".agents/skills/playwright-best-practices/browser-apis/service-workers.md":"# Service Worker Testing\n\n## Table of Contents\n\n1. [Service Worker Basics](#service-worker-basics)\n2. [Registration & Lifecycle](#registration--lifecycle)\n3. [Cache Testing](#cache-testing)\n4. [Offline Testing](#offline-testing)\n5. [Push Notifications](#push-notifications)\n6. [Background Sync](#background-sync)\n\n## Service Worker Basics\n\n### Waiting for Service Worker Registration\n\n```typescript\ntest('service worker registers', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to register\n  const swRegistered = await page.evaluate(async () => {\n    if (!('serviceWorker' in navigator)) return false\n\n    const registration = await navigator.serviceWorker.ready\n    return !!registration.active\n  })\n\n  expect(swRegistered).toBe(true)\n})\n```\n\n### Getting Service Worker State\n\n```typescript\ntest('check SW state', async ({page}) => {\n  await page.goto('/')\n\n  const swState = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.getRegistration()\n    if (!registration) return null\n\n    return {\n      installing: !!registration.installing,\n      waiting: !!registration.waiting,\n      active: !!registration.active,\n      scope: registration.scope,\n    }\n  })\n\n  expect(swState?.active).toBe(true)\n  expect(swState?.scope).toContain(page.url())\n})\n```\n\n### Service Worker Context\n\n```typescript\ntest('access service worker', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Get all service workers in context\n  const workers = context.serviceWorkers()\n\n  // Wait for service worker if not yet available\n  if (workers.length === 0) {\n    await context.waitForEvent('serviceworker')\n  }\n\n  const sw = context.serviceWorkers()[0]\n  expect(sw.url()).toContain('sw.js')\n})\n```\n\n## Registration & Lifecycle\n\n### Testing SW Update Flow\n\n```typescript\ntest('service worker updates', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Check for update\n  const hasUpdate = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.ready\n    await registration.update()\n\n    return new Promise<boolean>((resolve) => {\n      if (registration.waiting) {\n        resolve(true)\n      } else {\n        registration.addEventListener('updatefound', () => {\n          resolve(true)\n        })\n        // Timeout if no update\n        setTimeout(() => resolve(false), 5000)\n      }\n    })\n  })\n\n  // If update found, test skip waiting flow\n  if (hasUpdate) {\n    await page.evaluate(async () => {\n      const registration = await navigator.serviceWorker.ready\n      registration.waiting?.postMessage({type: 'SKIP_WAITING'})\n    })\n\n    // Wait for controller change\n    await page.evaluate(() => {\n      return new Promise<void>((resolve) => {\n        navigator.serviceWorker.addEventListener('controllerchange', () => {\n          resolve()\n        })\n      })\n    })\n  }\n})\n```\n\n### Testing SW Installation\n\n```typescript\ntest('verify SW install event', async ({context, page}) => {\n  // Listen for service worker before navigating\n  const swPromise = context.waitForEvent('serviceworker')\n\n  await page.goto('/pwa-app')\n\n  const sw = await swPromise\n\n  // Evaluate in SW context\n  const swVersion = await sw.evaluate(() => {\n    // Access SW globals\n    return (self as any).SW_VERSION || 'unknown'\n  })\n\n  expect(swVersion).toBe('1.0.0')\n})\n```\n\n### Unregistering Service Workers\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.goto('/')\n\n  // Unregister all service workers for clean state\n  await page.evaluate(async () => {\n    const registrations = await navigator.serviceWorker.getRegistrations()\n    await Promise.all(registrations.map((r) => r.unregister()))\n  })\n\n  // Clear caches\n  await page.evaluate(async () => {\n    const cacheNames = await caches.keys()\n    await Promise.all(cacheNames.map((name) => caches.delete(name)))\n  })\n})\n```\n\n## Cache Testing\n\n### Verifying Cached Resources\n\n```typescript\ntest('assets are cached', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to cache assets\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n\n  // Check cache contents\n  const cachedUrls = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const requests = await cache.keys()\n    return requests.map((r) => r.url)\n  })\n\n  expect(cachedUrls).toContain(expect.stringContaining('/styles.css'))\n  expect(cachedUrls).toContain(expect.stringContaining('/app.js'))\n})\n```\n\n### Testing Cache Strategies\n\n```typescript\ntest('cache-first strategy', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for initial cache\n  await page.waitForFunction(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.length > 0\n  })\n\n  // Block network for cached resources\n  await page.route('**/styles.css', (route) => route.abort())\n\n  // Reload - should work from cache\n  await page.reload()\n\n  // Verify page still styled (CSS loaded from cache)\n  const hasStyles = await page.evaluate(() => {\n    const body = document.body\n    const styles = window.getComputedStyle(body)\n    return styles.fontFamily !== '' // Has custom font from CSS\n  })\n\n  expect(hasStyles).toBe(true)\n})\n```\n\n### Testing Cache Updates\n\n```typescript\ntest('cache updates on new version', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Get initial cache\n  const initialCacheKeys = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.map((r) => r.url)\n  })\n\n  // Simulate app update by mocking SW response\n  await page.route('**/sw.js', (route) => {\n    route.fulfill({\n      contentType: 'application/javascript',\n      body: `\n        const VERSION = 'v2';\n        self.addEventListener('install', (e) => {\n          e.waitUntil(caches.open('app-cache-v2'));\n          self.skipWaiting();\n        });\n      `,\n    })\n  })\n\n  // Trigger update\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.update()\n  })\n\n  // Verify new cache exists\n  await page.waitForFunction(async () => {\n    return await caches.has('app-cache-v2')\n  })\n})\n```\n\n## Offline Testing\n\nThis section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).\n\n### Simulating Offline Mode\n\n```typescript\ntest('app works offline', async ({page, context}) => {\n  await page.goto('/pwa-app')\n\n  // Ensure SW is active and content cached\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n  await page.waitForTimeout(1000) // Allow caching to complete\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to cached page\n  await page.reload()\n\n  // Verify content loads\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  // Verify offline indicator\n  await expect(page.locator('.offline-badge')).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n  await expect(page.locator('.offline-badge')).not.toBeVisible()\n})\n```\n\n### Testing Offline Fallback\n\n```typescript\ntest('shows offline page for uncached routes', async ({page, context}) => {\n  await page.goto('/pwa-app')\n  await page.evaluate(() => navigator.serviceWorker.ready)\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to uncached page\n  await page.goto('/uncached-page')\n\n  // Should show offline fallback\n  await expect(page.getByText('You are offline')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Testing Offline Form Submission\n\n```typescript\ntest('queues form submission offline', async ({page, context}) => {\n  await page.goto('/pwa-app/form')\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Submit form\n  await page.getByLabel('Message').fill('Offline message')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Should show queued status\n  await expect(page.getByText('Queued for sync')).toBeVisible()\n\n  // Go online\n  await context.setOffline(false)\n\n  // Trigger sync (or wait for automatic)\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    // Manually trigger sync for testing\n    await (reg as any).sync?.register('form-sync')\n  })\n\n  // Verify submission completed\n  await expect(page.getByText('Message sent')).toBeVisible({timeout: 10000})\n})\n```\n\n## Push Notifications\n\n### Mocking Push Subscription\n\n```typescript\ntest('handles push subscription', async ({page, context}) => {\n  // Grant notification permission\n  await context.grantPermissions(['notifications'])\n\n  await page.goto('/pwa-app')\n\n  // Subscribe to push\n  const subscription = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    const sub = await reg.pushManager.subscribe({\n      userVisibleOnly: true,\n      applicationServerKey: 'test-key',\n    })\n    return sub.toJSON()\n  })\n\n  expect(subscription.endpoint).toBeDefined()\n})\n```\n\n### Testing Push Message Handling\n\n```typescript\ntest('handles push notification', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Wait for SW\n  const swPromise = context.waitForEvent('serviceworker')\n  const sw = await swPromise\n\n  // Simulate push message to service worker\n  await sw.evaluate(async () => {\n    // Dispatch push event\n    const pushEvent = new PushEvent('push', {\n      data: new PushMessageData(JSON.stringify({title: 'Test', body: 'Push message'})),\n    })\n    self.dispatchEvent(pushEvent)\n  })\n\n  // Note: Actual notification display testing is limited in Playwright\n  // Focus on verifying the SW handles the push correctly\n})\n```\n\n### Testing Notification Click\n\n```typescript\ntest('notification click opens page', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Store notification URL target\n  let notificationUrl = ''\n\n  // Listen for new pages (notification click opens new page)\n  context.on('page', (newPage) => {\n    notificationUrl = newPage.url()\n  })\n\n  // Trigger notification via SW\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.showNotification('Test', {\n      body: 'Click me',\n      data: {url: '/notification-target'},\n    })\n  })\n\n  // Simulate clicking notification (via SW)\n  const sw = context.serviceWorkers()[0]\n  await sw.evaluate(() => {\n    self.dispatchEvent(\n      new NotificationEvent('notificationclick', {\n        notification: {data: {url: '/notification-target'}} as any,\n      }),\n    )\n  })\n\n  // Verify navigation occurred\n  await page.waitForTimeout(1000)\n  // Check if new page opened or current page navigated\n})\n```\n\n## Background Sync\n\n### Testing Background Sync Registration\n\n```typescript\ntest('registers background sync', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Register sync\n  const syncRegistered = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    if (!('sync' in reg)) return false\n\n    await (reg as any).sync.register('my-sync')\n    return true\n  })\n\n  expect(syncRegistered).toBe(true)\n})\n```\n\n### Testing Sync Event\n\n```typescript\ntest('sync event fires when online', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Queue data while offline\n  await context.setOffline(true)\n\n  await page.evaluate(async () => {\n    // Store data in IndexedDB for sync\n    const db = await openDB()\n    await db.put('sync-queue', {id: 1, data: 'test'})\n\n    // Register sync\n    const reg = await navigator.serviceWorker.ready\n    await (reg as any).sync.register('data-sync')\n  })\n\n  // Track sync completion\n  await page.evaluate(() => {\n    window.syncCompleted = false\n    navigator.serviceWorker.addEventListener('message', (e) => {\n      if (e.data.type === 'SYNC_COMPLETE') {\n        window.syncCompleted = true\n      }\n    })\n  })\n\n  // Go online\n  await context.setOffline(false)\n\n  // Wait for sync to complete\n  await page.waitForFunction(() => window.syncCompleted, {timeout: 10000})\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                 | Solution                                     |\n| ------------------------------ | ----------------------- | -------------------------------------------- |\n| Not clearing SW between tests  | Tests affect each other | Unregister SW in beforeEach                  |\n| Not waiting for SW ready       | Race conditions         | Always await `navigator.serviceWorker.ready` |\n| Testing in isolation only      | Misses real SW behavior | Test with actual caching                     |\n| Hardcoded timeouts for caching | Flaky tests             | Wait for cache to populate                   |\n| Ignoring SW update cycle       | Missing update bugs     | Test install, activate, update flows         |\n\n## Related References\n\n- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns\n- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception\n- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns\n",".agents/skills/playwright-best-practices/browser-apis/websockets.md":"# WebSocket & Real-Time Testing\n\n## Table of Contents\n\n1. [WebSocket Basics](#websocket-basics)\n2. [Mocking WebSocket Messages](#mocking-websocket-messages)\n3. [Testing Real-Time Features](#testing-real-time-features)\n4. [Server-Sent Events](#server-sent-events)\n5. [Reconnection Testing](#reconnection-testing)\n\n## WebSocket Basics\n\n### Wait for WebSocket Connection\n\n```typescript\ntest('chat connects via websocket', async ({page}) => {\n  // Listen for WebSocket connection\n  const wsPromise = page.waitForEvent('websocket')\n\n  await page.goto('/chat')\n\n  const ws = await wsPromise\n  expect(ws.url()).toContain('/ws/chat')\n\n  // Wait for connection to be established\n  await ws.waitForEvent('framesent')\n})\n```\n\n### Monitor WebSocket Messages\n\n```typescript\ntest('receives real-time updates', async ({page}) => {\n  const messages: string[] = []\n\n  // Set up listener before navigation\n  page.on('websocket', (ws) => {\n    ws.on('framereceived', (frame) => {\n      messages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Wait for some messages\n  await expect.poll(() => messages.length).toBeGreaterThan(0)\n\n  // Verify message format\n  const data = JSON.parse(messages[0])\n  expect(data).toHaveProperty('type')\n})\n```\n\n### Capture Sent Messages\n\n```typescript\ntest('sends correct message format', async ({page}) => {\n  const sentMessages: string[] = []\n\n  page.on('websocket', (ws) => {\n    ws.on('framesent', (frame) => {\n      sentMessages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/chat')\n  await page.getByLabel('Message').fill('Hello!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  await expect.poll(() => sentMessages.length).toBeGreaterThan(0)\n\n  const sent = JSON.parse(sentMessages[sentMessages.length - 1])\n  expect(sent).toEqual({\n    type: 'message',\n    content: 'Hello!',\n  })\n})\n```\n\n## Mocking WebSocket Messages\n\n### Inject Messages via Page Evaluate\n\n```typescript\ntest('displays incoming chat message', async ({page}) => {\n  await page.goto('/chat')\n\n  // Wait for WebSocket to be ready\n  await page.waitForFunction(() => (window as any).chatSocket?.readyState === 1)\n\n  // Simulate incoming message\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'message',\n        from: 'Alice',\n        content: 'Hello there!',\n      }),\n    })\n    ;(window as any).chatSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByText('Alice: Hello there!')).toBeVisible()\n})\n```\n\n### Mock WebSocket with Route Handler\n\n```typescript\ntest('mock websocket entirely', async ({page, context}) => {\n  // Intercept the WebSocket upgrade\n  await context.route('**/ws/**', async (route) => {\n    // For WebSocket routes, we can't fulfill directly\n    // Instead, use page.evaluate to mock the client-side\n  })\n\n  // Alternative: Mock at application level\n  await page.addInitScript(() => {\n    const OriginalWebSocket = window.WebSocket\n    ;(window as any).WebSocket = function (url: string) {\n      const ws = {\n        readyState: 1,\n        send: (data: string) => {\n          console.log('WS Send:', data)\n        },\n        close: () => {},\n        addEventListener: () => {},\n        removeEventListener: () => {},\n      }\n      setTimeout(() => ws.onopen?.(), 100)\n      return ws\n    }\n  })\n\n  await page.goto('/chat')\n})\n```\n\n### WebSocket Mock Fixture\n\n```typescript\n// fixtures/websocket.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype WsMessage = {type: string; [key: string]: any}\n\ntype WebSocketFixtures = {\n  mockWebSocket: {\n    injectMessage: (message: WsMessage) => Promise<void>\n    getSentMessages: () => Promise<WsMessage[]>\n  }\n}\n\nexport const test = base.extend<WebSocketFixtures>({\n  mockWebSocket: async ({page}, use) => {\n    const sentMessages: WsMessage[] = []\n\n    // Capture sent messages\n    await page.addInitScript(() => {\n      ;(window as any).__wsSent = []\n      const OriginalWebSocket = window.WebSocket\n      window.WebSocket = function (url: string) {\n        const ws = new OriginalWebSocket(url)\n        const originalSend = ws.send.bind(ws)\n        ws.send = (data: string) => {\n          ;(window as any).__wsSent.push(JSON.parse(data))\n          originalSend(data)\n        }\n        ;(window as any).__ws = ws\n        return ws\n      } as any\n    })\n\n    await use({\n      injectMessage: async (message) => {\n        await page.evaluate((msg) => {\n          const event = new MessageEvent('message', {\n            data: JSON.stringify(msg),\n          })\n          ;(window as any).__ws?.dispatchEvent(event)\n        }, message)\n      },\n      getSentMessages: async () => {\n        return page.evaluate(() => (window as any).__wsSent || [])\n      },\n    })\n  },\n})\n\n// Usage\ntest('chat with mocked websocket', async ({page, mockWebSocket}) => {\n  await page.goto('/chat')\n\n  // Inject incoming message\n  await mockWebSocket.injectMessage({\n    type: 'message',\n    from: 'Bob',\n    content: 'Hi!',\n  })\n\n  await expect(page.getByText('Bob: Hi!')).toBeVisible()\n\n  // Send a reply\n  await page.getByLabel('Message').fill('Hello Bob!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  const sent = await mockWebSocket.getSentMessages()\n  expect(sent).toContainEqual(expect.objectContaining({content: 'Hello Bob!'}))\n})\n```\n\n## Testing Real-Time Features\n\n### Live Notifications\n\n```typescript\ntest('displays live notification', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Simulate notification via WebSocket\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'notification',\n        title: 'New Order',\n        message: 'Order #123 received',\n      }),\n    })\n    ;(window as any).notificationSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByRole('alert')).toContainText('Order #123 received')\n})\n```\n\n### Live Data Updates\n\n```typescript\ntest('updates stock price in real-time', async ({page}) => {\n  await page.goto('/stocks/AAPL')\n\n  const priceElement = page.getByTestId('stock-price')\n  const initialPrice = await priceElement.textContent()\n\n  // Simulate price update\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'price_update',\n        symbol: 'AAPL',\n        price: 150.25,\n      }),\n    })\n    ;(window as any).stockSocket.dispatchEvent(event)\n  })\n\n  await expect(priceElement).not.toHaveText(initialPrice!)\n  await expect(priceElement).toContainText('150.25')\n})\n```\n\n### Collaborative Editing\n\n```typescript\ntest('shows collaborator cursor', async ({page}) => {\n  await page.goto('/document/123')\n\n  // Simulate another user's cursor position\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'cursor',\n        userId: 'user-456',\n        userName: 'Alice',\n        position: {x: 100, y: 200},\n      }),\n    })\n    ;(window as any).docSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByTestId('cursor-user-456')).toBeVisible()\n  await expect(page.getByText('Alice')).toBeVisible()\n})\n```\n\n## Server-Sent Events\n\n### Test SSE Updates\n\n```typescript\ntest('receives SSE updates', async ({page}) => {\n  // Mock SSE endpoint\n  await page.route('**/api/events', (route) => {\n    route.fulfill({\n      status: 200,\n      headers: {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n        'Connection': 'keep-alive',\n      },\n      body: `data: {\"type\":\"update\",\"value\":42}\\n\\n`,\n    })\n  })\n\n  await page.goto('/live-data')\n\n  await expect(page.getByTestId('value')).toHaveText('42')\n})\n```\n\n### Simulate Multiple SSE Events\n\n```typescript\ntest('handles multiple SSE events', async ({page}) => {\n  await page.route('**/api/events', async (route) => {\n    const encoder = new TextEncoder()\n    const events = [`data: {\"count\":1}\\n\\n`, `data: {\"count\":2}\\n\\n`, `data: {\"count\":3}\\n\\n`]\n\n    route.fulfill({\n      status: 200,\n      headers: {'Content-Type': 'text/event-stream'},\n      body: events.join(''),\n    })\n  })\n\n  await page.goto('/counter')\n\n  // Should receive all events\n  await expect(page.getByTestId('count')).toHaveText('3')\n})\n```\n\n## Reconnection Testing\n\n### Test Connection Loss\n\n```typescript\ntest('handles connection loss gracefully', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate connection close\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  // Should show disconnected state\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n})\n```\n\n### Test Reconnection\n\n```typescript\ntest('reconnects after connection loss', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate disconnect\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n\n  // Simulate reconnection\n  await page.evaluate(() => {\n    const event = new Event('open')\n    ;(window as any).chatSocket = {readyState: 1}\n    ;(window as any).chatSocket.dispatchEvent?.(event)\n  })\n\n  // Force component to re-check connection\n  await page.evaluate(() => {\n    window.dispatchEvent(new Event('online'))\n  })\n\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                       | Solution                           |\n| ------------------------------------- | ----------------------------- | ---------------------------------- |\n| Not waiting for WebSocket ready       | Messages sent too early       | Wait for `readyState === 1`        |\n| Testing against real WebSocket server | Flaky, timing-dependent       | Mock WebSocket messages            |\n| Ignoring connection state             | Tests pass but feature broken | Test connected/disconnected states |\n| No cleanup of listeners               | Memory leaks in tests         | Clean up event listeners           |\n\n## Related References\n\n- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns\n- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users\n",".agents/skills/playwright-best-practices/core/annotations.md":"# Test Annotations & Organization\n\n## Table of Contents\n\n1. [Skip Annotations](#skip-annotations)\n2. [Fixme & Fail Annotations](#fixme--fail-annotations)\n3. [Slow Tests](#slow-tests)\n4. [Test Steps](#test-steps)\n5. [Custom Annotations](#custom-annotations)\n6. [Conditional Annotations](#conditional-annotations)\n\n## Skip Annotations\n\n### Basic Skip\n\n```typescript\n// Skip unconditionally\ntest.skip('feature not implemented', async ({page}) => {\n  // This test won't run\n})\n\n// Skip with reason\ntest('payment flow', async ({page}) => {\n  test.skip(true, 'Payment gateway in maintenance')\n  // Test body won't execute\n})\n```\n\n### Conditional Skip\n\n```typescript\ntest('webkit-specific feature', async ({page, browserName}) => {\n  test.skip(browserName !== 'webkit', 'This feature only works in WebKit')\n\n  await page.goto('/webkit-feature')\n})\n\ntest('production only', async ({page}) => {\n  test.skip(process.env.ENV !== 'production', 'Only runs against production')\n\n  await page.goto('/prod-feature')\n})\n```\n\n### Skip by Platform\n\n```typescript\ntest('windows-specific', async ({page}) => {\n  test.skip(process.platform !== 'win32', 'Windows only')\n})\n\ntest('not on CI', async ({page}) => {\n  test.skip(!!process.env.CI, 'Skipped in CI environment')\n})\n```\n\n### Skip Describe Block\n\n```typescript\ntest.describe('Admin features', () => {\n  test.skip(({browserName}) => browserName === 'firefox', 'Firefox admin bug')\n\n  test('admin dashboard', async ({page}) => {\n    // Skipped in Firefox\n  })\n\n  test('admin settings', async ({page}) => {\n    // Skipped in Firefox\n  })\n})\n```\n\n## Fixme & Fail Annotations\n\n### Fixme - Known Issues\n\n```typescript\n// Mark test as needing fix (skips the test)\ntest.fixme('broken after refactor', async ({page}) => {\n  // Test won't run but is tracked\n})\n\n// Conditional fixme\ntest('flaky on CI', async ({page}) => {\n  test.fixme(!!process.env.CI, 'Investigate CI flakiness - ticket #123')\n\n  await page.goto('/flaky-feature')\n})\n```\n\n### Fail - Expected Failures\n\n```typescript\n// Test is expected to fail (runs but expects failure)\ntest('known bug', async ({page}) => {\n  test.fail()\n\n  await page.goto('/buggy-page')\n  // If this passes, the test fails (bug was fixed!)\n  await expect(page.getByText('Working')).toBeVisible()\n})\n\n// Conditional fail\ntest('fails on webkit', async ({page, browserName}) => {\n  test.fail(browserName === 'webkit', 'WebKit rendering bug #456')\n\n  await page.goto('/render-test')\n  await expect(page.getByTestId('element')).toHaveCSS('width', '100px')\n})\n```\n\n### Difference Between Skip, Fixme, Fail\n\n| Annotation     | Runs? | Use Case                         |\n| -------------- | ----- | -------------------------------- |\n| `test.skip()`  | No    | Feature not applicable           |\n| `test.fixme()` | No    | Known bug, needs investigation   |\n| `test.fail()`  | Yes   | Expected to fail, tracking a bug |\n\n## Slow Tests\n\n### Mark Slow Tests\n\n```typescript\n// Triple the default timeout\ntest('large data import', async ({page}) => {\n  test.slow()\n\n  await page.goto('/import')\n  await page.setInputFiles('#file', 'large-file.csv')\n  await page.getByRole('button', {name: 'Import'}).click()\n\n  await expect(page.getByText('Import complete')).toBeVisible()\n})\n\n// Conditional slow\ntest('video processing', async ({page, browserName}) => {\n  test.slow(browserName === 'webkit', 'WebKit video processing is slow')\n\n  await page.goto('/video-editor')\n})\n```\n\n### Custom Timeout\n\n```typescript\ntest('very long operation', async ({page}) => {\n  // Set specific timeout (in milliseconds)\n  test.setTimeout(120000) // 2 minutes\n\n  await page.goto('/long-operation')\n})\n\n// Timeout for describe block\ntest.describe('Integration tests', () => {\n  test.describe.configure({timeout: 60000})\n\n  test('test 1', async ({page}) => {\n    // Has 60 second timeout\n  })\n})\n```\n\n## Test Steps\n\n### Basic Steps\n\n```typescript\ntest('checkout flow', async ({page}) => {\n  await test.step('Add item to cart', async () => {\n    await page.goto('/products')\n    await page.getByRole('button', {name: 'Add to Cart'}).click()\n  })\n\n  await test.step('Go to checkout', async () => {\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n  })\n\n  await test.step('Fill shipping info', async () => {\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByLabel('City').fill('Test City')\n  })\n\n  await test.step('Complete payment', async () => {\n    await page.getByLabel('Card').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay'}).click()\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Nested Steps\n\n```typescript\ntest('user registration', async ({page}) => {\n  await test.step('Fill registration form', async () => {\n    await page.goto('/register')\n\n    await test.step('Personal info', async () => {\n      await page.getByLabel('Name').fill('John Doe')\n      await page.getByLabel('Email').fill('john@example.com')\n    })\n\n    await test.step('Security', async () => {\n      await page.getByLabel('Password').fill('SecurePass123')\n      await page.getByLabel('Confirm Password').fill('SecurePass123')\n    })\n  })\n\n  await test.step('Submit and verify', async () => {\n    await page.getByRole('button', {name: 'Register'}).click()\n    await expect(page.getByText('Welcome')).toBeVisible()\n  })\n})\n```\n\n### Steps with Return Values\n\n```typescript\ntest('verify order', async ({page}) => {\n  const orderId = await test.step('Create order', async () => {\n    await page.goto('/checkout')\n    await page.getByRole('button', {name: 'Place Order'}).click()\n\n    // Return value from step\n    return await page.getByTestId('order-id').textContent()\n  })\n\n  await test.step('Verify order details', async () => {\n    await page.goto(`/orders/${orderId}`)\n    await expect(page.getByText(`Order #${orderId}`)).toBeVisible()\n  })\n})\n```\n\n### Step in Page Object\n\n```typescript\n// pages/checkout.page.ts\nexport class CheckoutPage {\n  async fillShippingInfo(address: string, city: string) {\n    await test.step('Fill shipping information', async () => {\n      await this.page.getByLabel('Address').fill(address)\n      await this.page.getByLabel('City').fill(city)\n    })\n  }\n\n  async completePayment(cardNumber: string) {\n    await test.step('Complete payment', async () => {\n      await this.page.getByLabel('Card').fill(cardNumber)\n      await this.page.getByRole('button', {name: 'Pay'}).click()\n    })\n  }\n}\n```\n\n## Custom Annotations\n\n### Add Annotations\n\n```typescript\ntest('important feature', async ({page}, testInfo) => {\n  // Add custom annotation\n  testInfo.annotations.push({\n    type: 'priority',\n    description: 'high',\n  })\n\n  testInfo.annotations.push({\n    type: 'ticket',\n    description: 'JIRA-123',\n  })\n\n  await page.goto('/feature')\n})\n```\n\n### Annotation Fixture\n\n```typescript\n// fixtures/annotations.fixture.ts\nimport {test as base, TestInfo} from '@playwright/test'\n\ntype AnnotationFixtures = {\n  annotate: {\n    ticket: (id: string) => void\n    priority: (level: 'low' | 'medium' | 'high') => void\n    owner: (name: string) => void\n  }\n}\n\nexport const test = base.extend<AnnotationFixtures>({\n  annotate: async ({}, use, testInfo) => {\n    await use({\n      ticket: (id) => {\n        testInfo.annotations.push({type: 'ticket', description: id})\n      },\n      priority: (level) => {\n        testInfo.annotations.push({type: 'priority', description: level})\n      },\n      owner: (name) => {\n        testInfo.annotations.push({type: 'owner', description: name})\n      },\n    })\n  },\n})\n\n// Usage\ntest('critical feature', async ({page, annotate}) => {\n  annotate.ticket('JIRA-456')\n  annotate.priority('high')\n  annotate.owner('Alice')\n\n  await page.goto('/critical')\n})\n```\n\n### Read Annotations in Reporter\n\n```typescript\n// reporters/annotation-reporter.ts\nimport {Reporter, TestCase, TestResult} from '@playwright/test/reporter'\n\nclass AnnotationReporter implements Reporter {\n  onTestEnd(test: TestCase, result: TestResult) {\n    const ticket = test.annotations.find((a) => a.type === 'ticket')\n    const priority = test.annotations.find((a) => a.type === 'priority')\n\n    if (ticket) {\n      console.log(`Test linked to: ${ticket.description}`)\n    }\n\n    if (priority?.description === 'high' && result.status === 'failed') {\n      console.log(`HIGH PRIORITY FAILURE: ${test.title}`)\n    }\n  }\n}\n\nexport default AnnotationReporter\n```\n\n## Conditional Annotations\n\n### Annotation Helper\n\n```typescript\n// helpers/test-annotations.ts\nimport {test} from '@playwright/test'\n\nexport function skipInCI(reason = 'Skipped in CI') {\n  test.skip(!!process.env.CI, reason)\n}\n\nexport function skipInBrowser(browser: string, reason: string) {\n  test.beforeEach(({browserName}) => {\n    test.skip(browserName === browser, reason)\n  })\n}\n\nexport function onlyInEnv(env: string) {\n  test.skip(process.env.ENV !== env, `Only runs in ${env}`)\n}\n```\n\n```typescript\n// tests/feature.spec.ts\nimport {skipInCI, onlyInEnv} from '../helpers/test-annotations'\n\ntest('local only feature', async ({page}) => {\n  skipInCI('Uses local resources')\n\n  await page.goto('/local-feature')\n})\n\ntest('production check', async ({page}) => {\n  onlyInEnv('production')\n\n  await page.goto('/prod-only')\n})\n```\n\n### Describe-Level Conditions\n\n```typescript\ntest.describe('Mobile features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(!isMobile, 'Mobile only tests')\n  })\n\n  test('touch gestures', async ({page}) => {\n    // Only runs on mobile\n  })\n})\n\ntest.describe('Desktop features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(isMobile, 'Desktop only tests')\n  })\n\n  test('hover interactions', async ({page}) => {\n    // Only runs on desktop\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                | Solution                         |\n| --------------------------- | ---------------------- | -------------------------------- |\n| Skipping without reason     | Hard to track why      | Always provide description       |\n| Too many skipped tests      | Test debt accumulates  | Review and clean up regularly    |\n| Using skip instead of fixme | Loses intent           | Use fixme for bugs, skip for N/A |\n| Not using steps             | Hard to debug failures | Group logical actions in steps   |\n\n## Related References\n\n- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n",".agents/skills/playwright-best-practices/core/assertions-waiting.md":"# Assertions & Waiting\n\n## Table of Contents\n\n1. [Web-First Assertions](#web-first-assertions)\n2. [Generic Assertions](#generic-assertions)\n3. [Soft Assertions](#soft-assertions)\n4. [Waiting Strategies](#waiting-strategies)\n5. [Polling & Retrying](#polling--retrying)\n6. [Custom Matchers](#custom-matchers)\n\n## Web-First Assertions\n\nAuto-retry until condition is met or timeout. Always prefer these over generic assertions.\n\n### Locator Assertions\n\n```typescript\nimport {expect} from '@playwright/test'\n\n// Visibility\nawait expect(page.getByRole('button')).toBeVisible()\nawait expect(page.getByRole('button')).toBeHidden()\nawait expect(page.getByRole('button')).not.toBeVisible()\n\n// Enabled/Disabled\nawait expect(page.getByRole('button')).toBeEnabled()\nawait expect(page.getByRole('button')).toBeDisabled()\n\n// Text content\nawait expect(page.getByRole('heading')).toHaveText('Welcome')\nawait expect(page.getByRole('heading')).toHaveText(/welcome/i)\nawait expect(page.getByRole('heading')).toContainText('Welcome')\n\n// Count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Attributes\nawait expect(page.getByRole('link')).toHaveAttribute('href', '/home')\nawait expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)\n\n// CSS\nawait expect(page.getByRole('button')).toHaveClass(/primary/)\nawait expect(page.getByRole('button')).toHaveCSS('color', 'rgb(0, 0, 255)')\n\n// Input values\nawait expect(page.getByLabel('Email')).toHaveValue('user@example.com')\nawait expect(page.getByLabel('Email')).toBeEmpty()\n\n// Focus\nawait expect(page.getByLabel('Email')).toBeFocused()\n\n// Checked state\nawait expect(page.getByRole('checkbox')).toBeChecked()\nawait expect(page.getByRole('checkbox')).not.toBeChecked()\n\n// Editable state\nawait expect(page.getByLabel('Name')).toBeEditable()\n```\n\n### Page Assertions\n\n```typescript\n// URL\nawait expect(page).toHaveURL('/dashboard')\nawait expect(page).toHaveURL(/\\/dashboard/)\n\n// Title\nawait expect(page).toHaveTitle('Dashboard - MyApp')\nawait expect(page).toHaveTitle(/dashboard/i)\n```\n\n### Response Assertions\n\n```typescript\nconst response = await page.request.get('/api/users')\nawait expect(response).toBeOK()\nawait expect(response).not.toBeOK()\n```\n\n## Generic Assertions\n\nUse for non-UI values. Do NOT retry - execute immediately.\n\n```typescript\n// Equality\nexpect(value).toBe(5)\nexpect(object).toEqual({name: 'Test'})\nexpect(array).toContain('item')\n\n// Truthiness\nexpect(value).toBeTruthy()\nexpect(value).toBeFalsy()\nexpect(value).toBeNull()\nexpect(value).toBeUndefined()\nexpect(value).toBeDefined()\n\n// Numbers\nexpect(value).toBeGreaterThan(5)\nexpect(value).toBeLessThanOrEqual(10)\nexpect(value).toBeCloseTo(5.5, 1)\n\n// Strings\nexpect(string).toMatch(/pattern/)\nexpect(string).toContain('substring')\n\n// Arrays/Objects\nexpect(array).toHaveLength(3)\nexpect(object).toHaveProperty('key', 'value')\n\n// Exceptions\nexpect(() => fn()).toThrow()\nexpect(() => fn()).toThrow('error message')\nawait expect(asyncFn()).rejects.toThrow()\n```\n\n## Soft Assertions\n\nContinue test execution after failure, report all failures at end.\n\n```typescript\ntest('check multiple elements', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Won't stop on first failure\n  await expect.soft(page.getByRole('heading')).toHaveText('Dashboard')\n  await expect.soft(page.getByRole('button', {name: 'Save'})).toBeEnabled()\n  await expect.soft(page.getByText('Welcome')).toBeVisible()\n\n  // Test continues; all failures reported at end\n})\n```\n\n### Soft Assertions with Early Exit\n\n```typescript\ntest('check form', async ({page}) => {\n  await expect.soft(page.getByRole('form')).toBeVisible()\n\n  // Exit early if form not visible (pointless to check fields)\n  if (expect.soft.hasFailures()) {\n    return\n  }\n\n  await expect.soft(page.getByLabel('Name')).toBeVisible()\n  await expect.soft(page.getByLabel('Email')).toBeVisible()\n})\n```\n\n## Waiting Strategies\n\n### Auto-Waiting (Default)\n\nActions automatically wait for:\n\n- Element to be attached to DOM\n- Element to be visible\n- Element to be stable (no animations)\n- Element to be enabled\n- Element to receive events\n\n```typescript\n// These auto-wait\nawait page.click('button')\nawait page.fill('input', 'text')\nawait page.getByRole('button').click()\n```\n\n### Wait for Navigation\n\n```typescript\n// Wait for URL change\nawait page.waitForURL('/dashboard')\nawait page.waitForURL(/\\/dashboard/)\n\n// Wait for navigation after action\nawait Promise.all([page.waitForURL('**/dashboard'), page.click('a[href=\"/dashboard\"]')])\n\n// Or without Promise.all\nconst urlPromise = page.waitForURL('**/dashboard')\nawait page.click('a')\nawait urlPromise\n```\n\n### Wait for Network\n\n```typescript\n// Wait for specific response\nconst responsePromise = page.waitForResponse('**/api/users')\nawait page.click('button')\nconst response = await responsePromise\nexpect(response.status()).toBe(200)\n\n// Wait for request\nconst requestPromise = page.waitForRequest('**/api/submit')\nawait page.click('button')\nconst request = await requestPromise\n\n// Wait for no network activity\nawait page.waitForLoadState('networkidle')\n```\n\n### Wait for Element State\n\n```typescript\n// Wait for element to appear\nawait page.getByRole('dialog').waitFor({state: 'visible'})\n\n// Wait for element to disappear\nawait page.getByText('Loading...').waitFor({state: 'hidden'})\n\n// Wait for element to be attached\nawait page.getByTestId('result').waitFor({state: 'attached'})\n\n// Wait for element to be detached\nawait page.getByTestId('modal').waitFor({state: 'detached'})\n```\n\n### Wait for Function\n\n```typescript\n// Wait for arbitrary condition\nawait page.waitForFunction(() => {\n  return document.querySelector('.loaded') !== null\n})\n\n// With arguments\nawait page.waitForFunction(\n  (selector) => document.querySelector(selector)?.textContent === 'Ready',\n  '.status',\n)\n```\n\n## Polling & Retrying\n\n### toPass() for Polling\n\nRetry until block passes or times out:\n\n```typescript\nawait expect(async () => {\n  const response = await page.request.get('/api/status')\n  expect(response.status()).toBe(200)\n\n  const data = await response.json()\n  expect(data.ready).toBe(true)\n}).toPass({\n  intervals: [1000, 2000, 5000], // Retry intervals\n  timeout: 30000,\n})\n```\n\n### expect.poll()\n\nPoll a function until assertion passes:\n\n```typescript\n// Poll API until condition met\nawait expect\n  .poll(\n    async () => {\n      const response = await page.request.get('/api/job/123')\n      return (await response.json()).status\n    },\n    {\n      intervals: [1000, 2000, 5000],\n      timeout: 30000,\n    },\n  )\n  .toBe('completed')\n\n// Poll DOM value\nawait expect.poll(() => page.getByTestId('counter').textContent()).toBe('10')\n```\n\n## Custom Matchers\n\n```typescript\n// playwright.config.ts or fixtures\nimport {expect} from '@playwright/test'\n\nexpect.extend({\n  async toHaveDataLoaded(page: Page) {\n    const locator = page.getByTestId('data-container')\n    let pass = false\n    let message = ''\n\n    try {\n      await expect(locator).toBeVisible()\n      await expect(locator).not.toContainText('Loading')\n      pass = true\n    } catch (e) {\n      message = `Expected data to be loaded but found loading state`\n    }\n\n    return {pass, message: () => message}\n  },\n})\n\n// Extend TypeScript types\ndeclare global {\n  namespace PlaywrightTest {\n    interface Matchers<R> {\n      toHaveDataLoaded(): Promise<R>\n    }\n  }\n}\n\n// Usage\nawait expect(page).toHaveDataLoaded()\n```\n\n## Timeouts\n\n### Configure Timeouts\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  timeout: 30000, // Test timeout\n  expect: {\n    timeout: 5000, // Assertion timeout\n  },\n})\n\n// Per-test timeout\ntest('long test', async ({page}) => {\n  test.setTimeout(60000)\n  // ...\n})\n\n// Per-assertion timeout\nawait expect(page.getByRole('button')).toBeVisible({timeout: 10000})\n```\n\n## Best Practices\n\n| Do                             | Don't                          |\n| ------------------------------ | ------------------------------ |\n| Use web-first assertions       | Use generic assertions for DOM |\n| Let auto-waiting work          | Add unnecessary explicit waits |\n| Use `toPass()` for polling     | Write manual retry loops       |\n| Configure appropriate timeouts | Use `waitForTimeout()`         |\n| Check specific conditions      | Wait for arbitrary time        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                                              | Problem                       | Solution                                     |\n| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |\n| `await page.waitForTimeout(5000)`                         | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse`        |\n| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above                 | Use `waitForResponse` or element state waits |\n| Generic assertions on DOM elements                        | No auto-retry, flaky          | Use web-first assertions with `expect()`     |\n\n## Related References\n\n- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions\n- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n",".agents/skills/playwright-best-practices/core/configuration.md":"# Playwright Configuration\n\n## Table of Contents\n\n1. [CLI Quick Reference](#cli-quick-reference)\n2. [Decision Guide](#decision-guide)\n3. [Production-Ready Config](#production-ready-config)\n4. [Patterns](#patterns)\n5. [Anti-Patterns](#anti-patterns)\n6. [Troubleshooting](#troubleshooting)\n7. [Related](#related)\n\n> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.\n\n## CLI Quick Reference\n\n```bash\nnpx playwright init                           # scaffold config + first test\nnpx playwright test --config=custom.config.ts # use alternate config\nnpx playwright test --project=chromium        # run single project\nnpx playwright test --reporter=html           # override reporter\nnpx playwright test --grep @smoke             # run tests tagged @smoke\nnpx playwright test --grep-invert @slow       # exclude @slow tests\nnpx playwright show-report                    # open last HTML report\nDEBUG=pw:api npx playwright test              # verbose logging\n```\n\n## Decision Guide\n\n### Timeout Selection\n\n| Symptom                                   | Setting             | Default       | Recommended       |\n| ----------------------------------------- | ------------------- | ------------- | ----------------- |\n| Test takes too long overall               | `timeout`           | 30s           | 30-60s (max 120s) |\n| Assertion retries too long/short          | `expect.timeout`    | 5s            | 5-10s             |\n| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s           | 10-30s            |\n| `click()`, `fill()` time out              | `actionTimeout`     | 0 (unlimited) | 10-15s            |\n| Dev server slow to start                  | `webServer.timeout` | 60s           | 60-180s           |\n\n### Server Management\n\n| Scenario                     | Approach                                                |\n| ---------------------------- | ------------------------------------------------------- |\n| App in same repo             | `webServer` with `reuseExistingServer: !process.env.CI` |\n| Separate repos               | Manual start or Docker Compose                          |\n| Testing deployed environment | No `webServer`; set `baseURL` via env                   |\n| Multiple services            | Array of `webServer` entries                            |\n\n### Single vs Multi-Project\n\n| Scenario               | Approach                                   |\n| ---------------------- | ------------------------------------------ |\n| Early development      | Single project (chromium only)             |\n| Pre-release validation | Multi-project: chromium + firefox + webkit |\n| Mobile-responsive app  | Add mobile projects alongside desktop      |\n| Auth + non-auth tests  | Setup project with dependencies            |\n| Tight CI budget        | Chromium on PRs; all browsers on main      |\n\n### globalSetup vs Setup Projects vs Fixtures\n\n| Need                    | Use                                |\n| ----------------------- | ---------------------------------- |\n| One-time DB seed        | `globalSetup`                      |\n| Shared browser auth     | Setup project with `dependencies`  |\n| Per-test isolated state | Custom fixture via `test.extend()` |\n| Cleanup after all tests | `globalTeardown`                   |\n\n## Production-Ready Config\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\nimport dotenv from 'dotenv'\n\ndotenv.config({path: path.resolve(__dirname, '.env')})\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n\n  timeout: 30_000,\n  expect: {timeout: 5_000},\n\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:4000',\n    actionTimeout: 10_000,\n    navigationTimeout: 15_000,\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'retain-on-failure',\n    locale: 'en-US',\n    timezoneId: 'America/Los_Angeles',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'webkit', use: {...devices['Desktop Safari']}},\n    {name: 'mobile-chrome', use: {...devices['Pixel 7']}},\n    {name: 'mobile-safari', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: 'npm run start',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    stdout: 'pipe',\n    stderr: 'pipe',\n  },\n})\n```\n\n## Patterns\n\n### Environment-Specific Configuration\n\n**Use when**: Tests run against dev, staging, and production environments.\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\nconst ENV = process.env.TEST_ENV || 'local'\ndotenv.config({path: path.resolve(__dirname, `.env.${ENV}`)})\n\nconst envConfig: Record<string, {baseURL: string; retries: number}> = {\n  local: {baseURL: 'http://localhost:4000', retries: 0},\n  staging: {baseURL: 'https://staging.myapp.com', retries: 2},\n  prod: {baseURL: 'https://myapp.com', retries: 2},\n}\n\nexport default defineConfig({\n  testDir: './e2e',\n  retries: envConfig[ENV].retries,\n  use: {baseURL: envConfig[ENV].baseURL},\n})\n```\n\n```bash\nTEST_ENV=staging npx playwright test\nTEST_ENV=prod npx playwright test --grep @smoke\n```\n\n### Setup Project with Dependencies\n\n**Use when**: Tests need shared authentication state before running.\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /auth\\.setup\\.ts/,\n    },\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```ts\n// e2e/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/session.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n### webServer with Build Step\n\n**Use when**: Tests need a running application server managed by Playwright.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {baseURL: 'http://localhost:4000'},\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run preview' : 'npm run dev',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: 'test',\n      DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',\n    },\n  },\n})\n```\n\n### globalSetup / globalTeardown\n\n**Use when**: One-time non-browser work like seeding a database. Runs once per test run.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  globalSetup: './e2e/setup.ts',\n  globalTeardown: './e2e/teardown.ts',\n})\n```\n\n```ts\n// e2e/setup.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalSetup(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  process.env.TEST_RUN_ID = `run-${Date.now()}`\n}\n```\n\n```ts\n// e2e/teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalTeardown(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db push --force-reset', {stdio: 'inherit'})\n}\n```\n\n### Environment Variables with .env\n\n**Use when**: Managing secrets, URLs, or feature flags without hardcoding.\n\n```bash\n# .env.example (commit this)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=\nAPI_KEY=\n\n# .env.local (gitignored)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=secret123\nAPI_KEY=dev-key-abc\n\n# .env.staging (gitignored)\nBASE_URL=https://staging.myapp.com\nTEST_PASSWORD=staging-pass\nAPI_KEY=staging-key-xyz\n```\n\n```bash\n# .gitignore\n.env\n.env.local\n.env.staging\n.env.production\nplaywright/.auth/\n```\n\nInstall dotenv:\n\n```bash\nnpm install -D dotenv\n```\n\n### Tag-Based Test Filtering\n\n**Use when**: Running subsets of tests in different CI stages (PR vs nightly).\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n\n  // Filter by tags in CI\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n**Project-specific filtering:**\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n```bash\n# Run specific project\nnpx playwright test --project=smoke\nnpx playwright test --project=regression\n```\n\n### Artifact Collection Strategy\n\n| Setting      | Local   | CI                    | Reason                                    |\n| ------------ | ------- | --------------------- | ----------------------------------------- |\n| `trace`      | `'off'` | `'on-first-retry'`    | Traces are large; collect on failure only |\n| `screenshot` | `'off'` | `'only-on-failure'`   | Useful for CI debugging                   |\n| `video`      | `'off'` | `'retain-on-failure'` | Recording slows tests                     |\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n  },\n})\n```\n\n## Anti-Patterns\n\n| Don't                                                            | Problem                                | Do Instead                          |\n| ---------------------------------------------------------------- | -------------------------------------- | ----------------------------------- |\n| `timeout: 300_000` globally                                      | Masks flaky tests; slow CI             | Fix root cause; keep 30s default    |\n| Hardcoded URLs: `page.goto('http://localhost:4000/login')`       | Breaks in other environments           | Use `baseURL` + relative paths      |\n| All browsers on every PR                                         | 3x CI time                             | Chromium on PRs; all on main        |\n| `trace: 'on'` always                                             | Huge artifacts, slow uploads           | `trace: 'on-first-retry'`           |\n| `video: 'on'` always                                             | Massive storage; slow tests            | `video: 'retain-on-failure'`        |\n| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent                | Define once in project config       |\n| `retries: 3` locally                                             | Hides flakiness                        | `retries: 0` local, `retries: 2` CI |\n| No `forbidOnly` in CI                                            | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI`      |\n| `globalSetup` for browser auth                                   | No browser context available           | Use setup project with dependencies |\n| Committing `.env` with credentials                               | Security risk                          | Commit `.env.example` only          |\n\n## Troubleshooting\n\n### baseURL Not Working\n\n**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.\n\n```ts\n// Wrong - ignores baseURL\nawait page.goto('http://localhost:4000/dashboard')\n\n// Correct - uses baseURL\nawait page.goto('/dashboard')\n```\n\n### webServer Starts But Tests Get Connection Refused\n\n**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.\n\n```ts\nwebServer: {\n  command: 'npm run dev',\n  url: 'http://localhost:4000/api/health',  // use real endpoint\n  reuseExistingServer: !process.env.CI,\n  timeout: 120_000,\n},\n```\n\n### Tests Pass Locally But Timeout in CI\n\n**Cause**: CI machines are slower. Increase timeouts and reduce workers:\n\n```ts\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n  },\n})\n```\n\n### \"Target page, context or browser has been closed\"\n\n**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.\n\n**Fix**: Don't increase global timeout. Find slow step using trace:\n\n```bash\nnpx playwright test --trace on\nnpx playwright show-report\n```\n\n## Related\n\n- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`\n- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state\n- [test-suite-structure.md](test-suite-structure.md) - file structure and naming\n- [authentication.md](../advanced/authentication.md) - setup projects for shared auth\n- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns\n",".agents/skills/playwright-best-practices/core/fixtures-hooks.md":"# Fixtures & Hooks\n\n## Table of Contents\n\n1. [Built-in Fixtures](#built-in-fixtures)\n2. [Custom Fixtures](#custom-fixtures)\n3. [Fixture Scopes](#fixture-scopes)\n4. [Hooks](#hooks)\n5. [Authentication Patterns](#authentication-patterns)\n6. [Database Fixtures](#database-fixtures)\n\n## Built-in Fixtures\n\n### Core Fixtures\n\n```typescript\ntest('example', async ({\n  page, // Isolated page instance\n  context, // Browser context (cookies, localStorage)\n  browser, // Browser instance\n  browserName, // 'chromium', 'firefox', or 'webkit'\n  request, // API request context\n}) => {\n  // Each test gets fresh instances\n})\n```\n\n### Request Fixture\n\n```typescript\ntest('API call', async ({request}) => {\n  const response = await request.get('/api/users')\n  await expect(response).toBeOK()\n\n  const users = await response.json()\n  expect(users).toHaveLength(5)\n})\n```\n\n## Custom Fixtures\n\n### Basic Custom Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base} from '@playwright/test'\n\n// Declare fixture types\ntype MyFixtures = {\n  todoPage: TodoPage\n  apiClient: ApiClient\n}\n\nexport const test = base.extend<MyFixtures>({\n  // Fixture with setup and teardown\n  todoPage: async ({page}, use) => {\n    const todoPage = new TodoPage(page)\n    await todoPage.goto()\n\n    await use(todoPage) // Test runs here\n\n    // Teardown (optional)\n    await todoPage.clearTodos()\n  },\n\n  // Simple fixture\n  apiClient: async ({request}, use) => {\n    await use(new ApiClient(request))\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Fixture with Options\n\n```typescript\ntype Options = {\n  defaultUser: {email: string; password: string}\n}\n\ntype Fixtures = {\n  authenticatedPage: Page\n}\n\nexport const test = base.extend<Options & Fixtures>({\n  // Define option with default\n  defaultUser: [{email: 'test@example.com', password: 'pass123'}, {option: true}],\n\n  // Use option in fixture\n  authenticatedPage: async ({page, defaultUser}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(defaultUser.email)\n    await page.getByLabel('Password').fill(defaultUser.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await use(page)\n  },\n})\n\n// Override in config\nexport default defineConfig({\n  use: {\n    defaultUser: {email: 'admin@example.com', password: 'admin123'},\n  },\n})\n```\n\n### Automatic Fixtures\n\n```typescript\nexport const test = base.extend<{}, {setupDb: void}>({\n  // Auto-fixture runs for every test without explicit usage\n  setupDb: [\n    async ({}, use) => {\n      await seedDatabase()\n      await use()\n      await cleanDatabase()\n    },\n    {auto: true},\n  ],\n})\n```\n\n## Fixture Scopes\n\n### Test Scope (Default)\n\nCreated fresh for each test:\n\n```typescript\ntest.extend({\n  page: async ({browser}, use) => {\n    const page = await browser.newPage()\n    await use(page)\n    await page.close()\n  },\n})\n```\n\n### Worker Scope\n\nShared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):\n\n```typescript\ntype WorkerFixtures = {\n  sharedAccount: Account\n}\n\nexport const test = base.extend<{}, WorkerFixtures>({\n  sharedAccount: [\n    async ({browser}, use) => {\n      // Expensive setup - runs once per worker\n      const account = await createTestAccount()\n      await use(account)\n      await deleteTestAccount(account)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n### Isolate test data between parallel workers\n\nWhen tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:\n\n```typescript\nimport {test as baseTest} from '@playwright/test'\n\ntype WorkerFixtures = {\n  dbUserName: string\n}\n\nexport const test = baseTest.extend<{}, WorkerFixtures>({\n  dbUserName: [\n    async ({}, use, testInfo) => {\n      const userName = `user-${testInfo.workerIndex}`\n      await createUserInTestDatabase(userName)\n      await use(userName)\n      await deleteUserFromTestDatabase(userName)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\nThen each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.\n\n## Hooks\n\n### beforeEach / afterEach\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Runs before each test in file\n  await page.goto('/')\n})\n\ntest.afterEach(async ({page}, testInfo) => {\n  // Runs after each test\n  if (testInfo.status !== 'passed') {\n    await page.screenshot({path: `failed-${testInfo.title}.png`})\n  }\n})\n```\n\n### beforeAll / afterAll\n\n```typescript\ntest.beforeAll(async ({browser}) => {\n  // Runs once before all tests in file\n  // Note: Cannot use page fixture here\n})\n\ntest.afterAll(async () => {\n  // Runs once after all tests in file\n})\n```\n\n### Describe-Level Hooks\n\n```typescript\ntest.describe('User Management', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/users')\n  })\n\n  test('can list users', async ({page}) => {\n    // Starts at /users\n  })\n\n  test('can add user', async ({page}) => {\n    // Starts at /users\n  })\n})\n```\n\n## Authentication Patterns\n\n### Global Setup with Storage State\n\n```typescript\n// auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = '.auth/user.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\n// auth.setup.ts\nsetup('admin auth', async ({page}) => {\n  await login(page, 'admin@example.com', 'adminpass')\n  await page.context().storageState({path: '.auth/admin.json'})\n})\n\nsetup('user auth', async ({page}) => {\n  await login(page, 'user@example.com', 'userpass')\n  await page.context().storageState({path: '.auth/user.json'})\n})\n```\n\n```typescript\n// playwright.config.ts\nprojects: [\n  {\n    name: 'admin tests',\n    testMatch: /.*admin.*\\.spec\\.ts/,\n    use: {storageState: '.auth/admin.json'},\n    dependencies: ['setup'],\n  },\n  {\n    name: 'user tests',\n    testMatch: /.*user.*\\.spec\\.ts/,\n    use: {storageState: '.auth/user.json'},\n    dependencies: ['setup'],\n  },\n]\n```\n\n### Auth Fixture\n\n```typescript\n// fixtures/auth.fixture.ts\nexport const test = base.extend<{adminPage: Page; userPage: Page}>({\n  adminPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/admin.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n\n  userPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/user.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n})\n```\n\n## Database Fixtures\n\nThis section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:\n\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n### Transaction Rollback Pattern\n\n```typescript\nimport {test as base} from '@playwright/test'\n\nimport {db} from '../db'\n\nexport const test = base.extend<{dbTransaction: Transaction}>({\n  dbTransaction: async ({}, use) => {\n    const transaction = await db.beginTransaction()\n\n    await use(transaction)\n\n    await transaction.rollback() // Clean slate for next test\n  },\n})\n```\n\n### Seed Data Fixture\n\n```typescript\ntype TestData = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<TestData>({\n  testUser: async ({}, use) => {\n    const user = await db.users.create({\n      email: `test-${Date.now()}@example.com`,\n      name: 'Test User',\n    })\n\n    await use(user)\n\n    await db.users.delete(user.id)\n  },\n\n  testProducts: async ({testUser}, use) => {\n    const products = await db.products.createMany([\n      {name: 'Product A', ownerId: testUser.id},\n      {name: 'Product B', ownerId: testUser.id},\n    ])\n\n    await use(products)\n\n    await db.products.deleteMany(products.map((p) => p.id))\n  },\n})\n```\n\n## Fixture Tips\n\n| Tip                | Explanation                                 |\n| ------------------ | ------------------------------------------- |\n| Fixtures are lazy  | Only created when used                      |\n| Compose fixtures   | Use other fixtures as dependencies          |\n| Keep setup minimal | Do heavy lifting in worker-scoped fixtures  |\n| Clean up resources | Use teardown in fixtures, not afterEach     |\n| Avoid shared state | Each fixture instance should be independent |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                                                    | Solution                                                                                                                                                                              |\n| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Shared mutable state between tests        | Race conditions, order dependencies                        | Use fixtures for isolation                                                                                                                                                            |\n| Global variables in tests                 | Tests depend on execution order                            | Use fixtures or beforeEach for setup                                                                                                                                                  |\n| Not cleaning up test data                 | Tests interfere with each other                            | Use fixtures with teardown or database transactions                                                                                                                                   |\n| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |\n| Backend/DB state shared across workers    | Tests in different workers collide on same data            | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker                                                                                                |\n\n## Related References\n\n- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure\n- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n",".agents/skills/playwright-best-practices/core/global-setup.md":"# Global Setup & Teardown\n\n## Table of Contents\n\n1. [Global Setup](#global-setup)\n2. [Global Teardown](#global-teardown)\n3. [Database Patterns](#database-patterns)\n4. [Environment Provisioning](#environment-provisioning)\n5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)\n6. [Parallel Execution Caveats](#parallel-execution-caveats)\n\n## Global Setup\n\n### Basic Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  console.log('Running global setup...')\n  // Perform one-time setup: start services, run migrations, etc.\n}\n\nexport default globalSetup\n```\n\n### Configure Global Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n})\n```\n\n> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.\n\n### Global Setup with Return Value\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig): Promise<() => Promise<void>> {\n  const server = await startTestServer()\n\n  // Return cleanup function (alternative to globalTeardown)\n  return async () => {\n    await server.stop()\n  }\n}\n\nexport default globalSetup\n```\n\n### Access Config in Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  console.log(`Setting up for ${baseURL}`)\n\n  // Access custom config\n  const workers = config.workers\n  const timeout = config.timeout\n\n  // Access environment\n  const isCI = !!process.env.CI\n}\n\nexport default globalSetup\n```\n\n## Global Teardown\n\n### Basic Global Teardown\n\n```typescript\nimport fs from 'fs'\n\n// global-teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalTeardown(config: FullConfig) {\n  console.log('Running global teardown...')\n\n  // Clean up auth files\n  if (fs.existsSync('.auth')) {\n    fs.rmSync('.auth', {recursive: true})\n  }\n\n  // Clean up test data\n  await cleanupTestDatabase()\n\n  // Stop services\n  await stopTestServices()\n}\n\nexport default globalTeardown\n```\n\n### Conditional Teardown\n\n```typescript\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  // Skip cleanup in CI (containers are discarded anyway)\n  if (process.env.CI) {\n    console.log('Skipping teardown in CI')\n    return\n  }\n\n  // Local cleanup\n  await cleanupLocalTestData()\n}\n\nexport default globalTeardown\n```\n\n## Database Patterns\n\nThis section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n\n### Database Migration in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Running database migrations...')\n\n  // Run migrations\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n\n  // Seed test data\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Database Snapshot Pattern\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\nimport fs from 'fs'\n\nconst SNAPSHOT_PATH = './test-db-snapshot.sql'\n\nasync function globalSetup() {\n  // Check if snapshot exists\n  if (fs.existsSync(SNAPSHOT_PATH)) {\n    console.log('Restoring database from snapshot...')\n    execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n    return\n  }\n\n  // First run: migrate and create snapshot\n  console.log('Creating database snapshot...')\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Test Database per Worker\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  // Create a database for each worker\n  for (let i = 0; i < workerCount; i++) {\n    const dbName = `test_db_worker_${i}`\n    await createDatabase(dbName)\n    await runMigrations(dbName)\n    await seedDatabase(dbName)\n  }\n}\n\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  for (let i = 0; i < workerCount; i++) {\n    await dropDatabase(`test_db_worker_${i}`)\n  }\n}\n```\n\n## Environment Provisioning\n\n### Start Services in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync, spawn} from 'child_process'\n\nlet serverProcess: any\n\nasync function globalSetup() {\n  // Start backend server\n  serverProcess = spawn('npm', ['run', 'start:test'], {\n    stdio: 'pipe',\n    detached: true,\n  })\n\n  // Wait for server to be ready\n  await waitForServer('http://localhost:3000/health', 30000)\n\n  // Store PID for teardown\n  process.env.SERVER_PID = serverProcess.pid.toString()\n}\n\nasync function waitForServer(url: string, timeout: number) {\n  const start = Date.now()\n\n  while (Date.now() - start < timeout) {\n    try {\n      const response = await fetch(url)\n      if (response.ok) return\n    } catch {\n      // Server not ready yet\n    }\n    await new Promise((r) => setTimeout(r, 1000))\n  }\n\n  throw new Error(`Server did not start within ${timeout}ms`)\n}\n\nexport default globalSetup\n```\n\n### Docker Compose Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Starting Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml up -d', {\n    stdio: 'inherit',\n  })\n\n  // Wait for services to be healthy\n  execSync('docker-compose -f docker-compose.test.yml exec -T db pg_isready', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalSetup\n```\n\n```typescript\n// global-teardown.ts\nimport {execSync} from 'child_process'\n\nasync function globalTeardown() {\n  console.log('Stopping Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml down -v', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalTeardown\n```\n\n### Environment Variables Setup\n\n```typescript\nimport path from 'path'\n\n// global-setup.ts\nimport dotenv from 'dotenv'\n\nasync function globalSetup() {\n  // Load test-specific environment\n  const envFile = process.env.CI ? '.env.ci' : '.env.test'\n  dotenv.config({path: path.resolve(process.cwd(), envFile)})\n\n  // Validate required variables\n  const required = ['DATABASE_URL', 'API_KEY', 'TEST_EMAIL']\n  for (const key of required) {\n    if (!process.env[key]) {\n      throw new Error(`Missing required environment variable: ${key}`)\n    }\n  }\n}\n\nexport default globalSetup\n```\n\n## Setup Projects vs Global Setup\n\n### When to Use Each\n\n| Use Global Setup                      | Use Setup Projects                       |\n| ------------------------------------- | ---------------------------------------- |\n| One-time setup (migrations, services) | Per-project setup (auth states)          |\n| No access to Playwright fixtures      | Need page, request fixtures              |\n| Runs once before all projects         | Can run per-project or have dependencies |\n| Shared across all workers             | Can be parallelized                      |\n\n### Setup Project Pattern\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    // Setup project\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n    // Test projects depend on setup\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Combining Both\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Global: Start services, run migrations\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n\n  projects: [\n    // Setup project: Create auth states\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n## Parallel Execution Caveats\n\n### Understanding Global Setup Execution\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  globalSetup runs ONCE                                      │\n│  ↓                                                          │\n│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │\n│  │ Worker 1│  │ Worker 2│  │ Worker 3│  │ Worker 4│        │\n│  │ tests   │  │ tests   │  │ tests   │  │ tests   │        │\n│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │\n│  ↓                                                          │\n│  globalTeardown runs ONCE                                   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Key implications:**\n\n- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)\n- State created in global setup is **shared** across all workers\n- If tests **modify** shared state, they may conflict with parallel workers\n- Global setup **cannot** react to individual test needs\n\n### When to Prefer Worker-Scoped Fixtures\n\nUse **worker-scoped fixtures** instead of globalSetup when:\n\n| Scenario                             | Why Fixtures Are Better                              |\n| ------------------------------------ | ---------------------------------------------------- |\n| Each worker needs isolated resources | Fixtures can create per-worker databases, servers    |\n| Setup needs Playwright APIs          | Fixtures have access to `page`, `request`, `browser` |\n| Setup depends on test configuration  | Fixtures receive test context and options            |\n| Resources need cleanup per worker    | Worker fixtures auto-cleanup when worker exits       |\n\n### Common Parallel Pitfall\n\n```typescript\n// ❌ BAD: Global setup creates ONE user, all workers fight over it\nasync function globalSetup() {\n  await createUser({email: 'test@example.com'}) // Shared!\n}\n\n// ✅ GOOD: Each worker gets its own user via worker-scoped fixture\n// Uses workerInfo.workerIndex to create unique data per worker\n```\n\n> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                          | Solution                                   |\n| ------------------------------ | -------------------------------- | ------------------------------------------ |\n| Heavy setup in globalSetup     | Slow test startup                | Use setup projects for parallelizable work |\n| Not cleaning up in teardown    | Leaks resources, flaky CI        | Always clean up or use containers          |\n| Hardcoded URLs in setup        | Breaks in different environments | Use config.projects[0].use.baseURL         |\n| No timeout on service wait     | Hangs forever if service fails   | Add timeout with clear error               |\n| Shared mutable state           | Race conditions in parallel      | Use worker-scoped fixtures for isolation   |\n| Global setup for per-test data | Tests conflict                   | Use test-scoped fixtures                   |\n\n## Related References\n\n- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration\n",".agents/skills/playwright-best-practices/core/locators.md":"# Locator Strategies\n\n## Table of Contents\n\n1. [Priority Order](#priority-order)\n2. [User-Facing Locators](#user-facing-locators)\n3. [Filtering & Chaining](#filtering--chaining)\n4. [Dynamic Content](#dynamic-content)\n5. [Shadow DOM](#shadow-dom)\n6. [Iframes](#iframes)\n\n## Priority Order\n\nUse locators in this order of preference:\n\n1. **Role-based** (most resilient): `getByRole`\n2. **Label-based**: `getByLabel`, `getByPlaceholder`\n3. **Text-based**: `getByText`, `getByTitle`\n4. **Test IDs** (when semantic locators aren't possible): `getByTestId`\n5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`\n\n## User-Facing Locators\n\n### getByRole\n\nMost robust approach - matches how users and assistive technology perceive the page.\n\n```typescript\n// Buttons\npage.getByRole('button', {name: 'Submit', exact: true}) // exact accessible name\npage.getByRole('button', {name: /submit/i}) // flexible case-insensitive match\n\n// Links\npage.getByRole('link', {name: 'Home'})\n\n// Form elements\npage.getByRole('textbox', {name: 'Email'})\npage.getByRole('checkbox', {name: 'Remember me'})\npage.getByRole('combobox', {name: 'Country'})\npage.getByRole('radio', {name: 'Option A'})\n\n// Headings\npage.getByRole('heading', {name: 'Welcome', level: 1})\n\n// Lists & items\npage.getByRole('list').getByRole('listitem')\n\n// Navigation & regions\npage.getByRole('navigation')\npage.getByRole('main')\npage.getByRole('dialog')\npage.getByRole('alert')\n```\n\n### getByLabel\n\nFor form elements with associated labels.\n\n```typescript\n// Input with <label for=\"email\">\npage.getByLabel('Email address')\n\n// Input with aria-label\npage.getByLabel('Search')\n\n// Exact match\npage.getByLabel('Email', {exact: true})\n```\n\n### getByPlaceholder\n\n```typescript\npage.getByPlaceholder('Enter your email')\npage.getByPlaceholder(/email/i)\n```\n\n### getByText\n\n```typescript\n// Partial match (default)\npage.getByText('Welcome')\n\n// Exact match\npage.getByText('Welcome to our site', {exact: true})\n\n// Regex\npage.getByText(/welcome/i)\n```\n\n### getByTestId\n\nConfigure custom test ID attribute in `playwright.config.ts`:\n\n```typescript\nuse: {\n  testIdAttribute: 'data-testid' // default\n}\n```\n\nUsage:\n\n```typescript\n// HTML: <button data-testid=\"submit-btn\">Submit</button>\npage.getByTestId('submit-btn')\n```\n\n## Filtering & Chaining\n\n### filter()\n\nNarrow down locators:\n\n```typescript\n// Filter by text\npage.getByRole('listitem').filter({hasText: 'Product'})\n\n// Filter by NOT having text\npage.getByRole('listitem').filter({hasNotText: 'Out of stock'})\n\n// Filter by child locator\npage.getByRole('listitem').filter({\n  has: page.getByRole('button', {name: 'Buy'}),\n})\n\n// Combine filters\npage\n  .getByRole('listitem')\n  .filter({hasText: 'Product'})\n  .filter({has: page.getByText('$9.99')})\n```\n\n### Chaining\n\n```typescript\n// Navigate down the DOM tree\npage.getByRole('article').getByRole('heading')\n\n// Get parent/ancestor\npage.getByText('Child').locator('..')\npage.getByText('Child').locator('xpath=ancestor::article')\n```\n\n### nth() and first()/last()\n\n```typescript\npage.getByRole('listitem').first()\npage.getByRole('listitem').last()\npage.getByRole('listitem').nth(2) // 0-indexed\n```\n\n## Dynamic Content\n\n### Waiting for Elements\n\nLocators auto-wait for actionability by default. For explicit state waiting:\n\n```typescript\nawait page.getByRole('button').waitFor({state: 'visible'})\nawait page.getByText('Loading').waitFor({state: 'hidden'})\n```\n\n> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n### Lists with Dynamic Items\n\n```typescript\n// Wait for specific count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Get all matching elements\nconst items = await page.getByRole('listitem').all()\nfor (const item of items) {\n  await expect(item).toBeVisible()\n}\n```\n\n## Shadow DOM\n\nPlaywright pierces shadow DOM by default:\n\n```typescript\n// Automatically finds elements inside shadow roots\npage.getByRole('button', {name: 'Shadow Button'})\n\n// Explicit shadow DOM traversal (if needed)\npage.locator('my-component').locator('internal:shadow=button')\n```\n\n## Iframes\n\n```typescript\n// By frame name or URL\nconst frame = page.frameLocator('iframe[name=\"content\"]')\nawait frame.getByRole('button').click()\n\n// By index\nconst frame = page.frameLocator('iframe').first()\n\n// Nested iframes\nconst nestedFrame = page.frameLocator('#outer').frameLocator('#inner')\nawait nestedFrame.getByText('Content').click()\n```\n\n## Debugging Locators\n\n```typescript\n// Highlight element in headed mode\nawait page.getByRole('button').highlight()\n\n// Count matches\nconst count = await page.getByRole('listitem').count()\n\n// Check if exists without waiting\nconst exists = (await page.getByRole('button').count()) > 0\n\n// Use Playwright Inspector\n// PWDEBUG=1 npx playwright test\n```\n\n## Common Issues & Solutions\n\n| Issue                   | Solution                                         |\n| ----------------------- | ------------------------------------------------ |\n| Multiple elements match | Add filters or use `nth()`, `first()`, `last()`  |\n| Element not found       | Check visibility, wait for load, verify selector |\n| Stale element           | Locators are lazy; re-query if DOM changes       |\n| Dynamic IDs             | Use stable attributes like role, text, test-id   |\n| Hidden elements         | Use `{ force: true }` only when necessary        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                      | Problem                           | Solution                                          |\n| --------------------------------- | --------------------------------- | ------------------------------------------------- |\n| `page.locator('.btn-primary')`    | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })`    |\n| `page.locator('#dynamic-id-123')` | Breaks when IDs change            | Use stable attributes like role, text, or test-id |\n| Testing implementation details    | Breaks on refactoring             | Test user-visible behavior                        |\n\n## Related References\n\n- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies\n- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators\n",".agents/skills/playwright-best-practices/core/page-object-model.md":"# Page Object Model (POM)\n\n## Table of Contents\n\n1. [Overview](#overview)\n2. [Basic Structure](#basic-structure)\n3. [Component Objects](#component-objects)\n4. [Composition Patterns](#composition-patterns)\n5. [Factory Functions](#factory-functions)\n6. [Best Practices](#best-practices)\n\n## Overview\n\nPage Object Model encapsulates page structure and interactions, providing:\n\n- **Maintainability**: Change selectors in one place\n- **Reusability**: Share page interactions across tests\n- **Readability**: Tests express intent, not implementation\n\n## Basic Structure\n\n### Page Class\n\n```typescript\n// pages/login.page.ts\nimport {Page, Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly emailInput: Locator\n  readonly passwordInput: Locator\n  readonly submitButton: Locator\n  readonly errorMessage: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.emailInput = page.getByLabel('Email')\n    this.passwordInput = page.getByLabel('Password')\n    this.submitButton = page.getByRole('button', {name: 'Sign in'})\n    this.errorMessage = page.getByRole('alert')\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n  }\n\n  async login(email: string, password: string) {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n  }\n\n  async expectError(message: string) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n}\n```\n\n### Usage in Tests\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../pages/login.page'\n\ntest.describe('Login', () => {\n  test('successful login redirects to dashboard', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('user@example.com', 'password123')\n    await expect(page).toHaveURL('/dashboard')\n  })\n\n  test('shows error for invalid credentials', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('invalid@example.com', 'wrong')\n    await loginPage.expectError('Invalid credentials')\n  })\n})\n```\n\n## Component Objects\n\nFor reusable UI components:\n\n```typescript\n// components/navbar.component.ts\nimport {Page, Locator} from '@playwright/test'\n\nexport class NavbarComponent {\n  readonly container: Locator\n  readonly logo: Locator\n  readonly searchInput: Locator\n  readonly userMenu: Locator\n\n  constructor(page: Page) {\n    this.container = page.getByRole('navigation')\n    this.logo = this.container.getByRole('link', {name: 'Home'})\n    this.searchInput = this.container.getByRole('searchbox')\n    this.userMenu = this.container.getByRole('button', {name: /user menu/i})\n  }\n\n  async search(query: string) {\n    await this.searchInput.fill(query)\n    await this.searchInput.press('Enter')\n  }\n\n  async openUserMenu() {\n    await this.userMenu.click()\n  }\n}\n```\n\n```typescript\n// components/modal.component.ts\nimport {Locator, expect} from '@playwright/test'\n\nexport class ModalComponent {\n  readonly container: Locator\n  readonly title: Locator\n  readonly closeButton: Locator\n  readonly confirmButton: Locator\n\n  constructor(container: Locator) {\n    this.container = container\n    this.title = container.getByRole('heading')\n    this.closeButton = container.getByRole('button', {name: 'Close'})\n    this.confirmButton = container.getByRole('button', {name: 'Confirm'})\n  }\n\n  async expectTitle(title: string) {\n    await expect(this.title).toHaveText(title)\n  }\n\n  async close() {\n    await this.closeButton.click()\n  }\n\n  async confirm() {\n    await this.confirmButton.click()\n  }\n}\n```\n\n## Composition Patterns\n\n### Page with Components\n\n```typescript\n// pages/dashboard.page.ts\nimport {Page, Locator} from '@playwright/test'\n\nimport {ModalComponent} from '../components/modal.component'\nimport {NavbarComponent} from '../components/navbar.component'\n\nexport class DashboardPage {\n  readonly page: Page\n  readonly navbar: NavbarComponent\n  readonly newProjectButton: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.navbar = new NavbarComponent(page)\n    this.newProjectButton = page.getByRole('button', {name: 'New Project'})\n  }\n\n  async goto() {\n    await this.page.goto('/dashboard')\n  }\n\n  async createProject() {\n    await this.newProjectButton.click()\n    return new ModalComponent(this.page.getByRole('dialog'))\n  }\n}\n```\n\n### Page Navigation\n\n```typescript\n// pages/base.page.ts\nimport {Page} from '@playwright/test'\n\nexport abstract class BasePage {\n  constructor(readonly page: Page) {}\n\n  abstract goto(): Promise<void>\n\n  async getTitle(): Promise<string> {\n    return this.page.title()\n  }\n}\n```\n\n```typescript\n// Return new page object on navigation\nexport class LoginPage extends BasePage {\n  async login(email: string, password: string): Promise<DashboardPage> {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n    return new DashboardPage(this.page)\n  }\n}\n\n// Usage\nconst loginPage = new LoginPage(page)\nawait loginPage.goto()\nconst dashboardPage = await loginPage.login('user@example.com', 'pass')\nawait dashboardPage.expectWelcomeMessage()\n```\n\n## Factory Functions\n\nAlternative to classes for simpler pages:\n\n```typescript\n// pages/login.page.ts\nimport {Page} from '@playwright/test'\n\nexport function createLoginPage(page: Page) {\n  const emailInput = page.getByLabel('Email')\n  const passwordInput = page.getByLabel('Password')\n  const submitButton = page.getByRole('button', {name: 'Sign in'})\n\n  return {\n    goto: () => page.goto('/login'),\n    login: async (email: string, password: string) => {\n      await emailInput.fill(email)\n      await passwordInput.fill(password)\n      await submitButton.click()\n    },\n    emailInput,\n    passwordInput,\n    submitButton,\n  }\n}\n\n// Usage\nconst loginPage = createLoginPage(page)\nawait loginPage.goto()\nawait loginPage.login('user@example.com', 'password')\n```\n\n## Best Practices\n\n### Do\n\n- **Keep locators in page objects** - Single source of truth\n- **Return new page objects** when navigation occurs\n- **Expose elements** for custom assertions in tests\n- **Use descriptive method names** - `submitOrder()` not `clickButton()`\n- **Keep methods focused** - One action per method\n\n### Don't\n\n- **Don't include assertions in page methods** (usually) - Keep in tests\n- **Don't expose implementation details** - Hide complex interactions\n- **Don't make page objects too large** - Split into components\n- **Don't share state** between page object instances\n\n### Directory Structure\n\n```\ntests/\n├── pages/\n│   ├── base.page.ts\n│   ├── login.page.ts\n│   ├── dashboard.page.ts\n│   └── settings.page.ts\n├── components/\n│   ├── navbar.component.ts\n│   ├── modal.component.ts\n│   └── table.component.ts\n├── fixtures/\n│   └── pages.fixture.ts\n└── specs/\n    ├── login.spec.ts\n    └── dashboard.spec.ts\n```\n\n### Using with Fixtures\n\n```typescript\n// fixtures/pages.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {DashboardPage} from '../pages/dashboard.page'\nimport {LoginPage} from '../pages/login.page'\n\ntype Pages = {\n  loginPage: LoginPage\n  dashboardPage: DashboardPage\n}\n\nexport const test = base.extend<Pages>({\n  loginPage: async ({page}, use) => {\n    await use(new LoginPage(page))\n  },\n  dashboardPage: async ({page}, use) => {\n    await use(new DashboardPage(page))\n  },\n})\n\n// Usage in tests\ntest('can login', async ({loginPage}) => {\n  await loginPage.goto()\n  await loginPage.login('user@example.com', 'password')\n})\n```\n\n## Related References\n\n- **Locator strategies**: See [locators.md](locators.md) for selecting elements\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites\n",".agents/skills/playwright-best-practices/core/projects-dependencies.md":"# Projects & Dependencies\n\n## Table of Contents\n\n1. [Project Configuration](#project-configuration)\n2. [Project Dependencies](#project-dependencies)\n3. [Setup Projects](#setup-projects)\n4. [Filtering & Running Projects](#filtering--running-projects)\n5. [Sharing Configuration](#sharing-configuration)\n6. [Advanced Patterns](#advanced-patterns)\n\n## Project Configuration\n\n### Basic Multi-Browser Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n    },\n    {\n      name: 'webkit',\n      use: {...devices['Desktop Safari']},\n    },\n  ],\n})\n```\n\n### Environment-Based Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'staging',\n      use: {\n        baseURL: 'https://staging.example.com',\n      },\n    },\n    {\n      name: 'production',\n      use: {\n        baseURL: 'https://example.com',\n      },\n    },\n    {\n      name: 'local',\n      use: {\n        baseURL: 'http://localhost:3000',\n      },\n    },\n  ],\n})\n```\n\n### Test Type Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'e2e',\n      testDir: './tests/e2e',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'api',\n      testDir: './tests/api',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'visual',\n      testDir: './tests/visual',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n    },\n  ],\n})\n```\n\n## Project Dependencies\n\n### Setup Dependency\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Setup project runs first\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n\n    // Browser projects depend on setup\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Auth setup projects\n    {\n      name: 'setup-admin',\n      testMatch: /admin\\.setup\\.ts/,\n    },\n    {\n      name: 'setup-user',\n      testMatch: /user\\.setup\\.ts/,\n    },\n\n    // Admin tests\n    {\n      name: 'admin-tests',\n      testDir: './tests/admin',\n      use: {storageState: '.auth/admin.json'},\n      dependencies: ['setup-admin'],\n    },\n\n    // User tests\n    {\n      name: 'user-tests',\n      testDir: './tests/user',\n      use: {storageState: '.auth/user.json'},\n      dependencies: ['setup-user'],\n    },\n\n    // Tests that need both\n    {\n      name: 'integration-tests',\n      testDir: './tests/integration',\n      dependencies: ['setup-admin', 'setup-user'],\n    },\n  ],\n})\n```\n\n### Chained Dependencies\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Step 1: Database setup\n    {\n      name: 'db-setup',\n      testMatch: /db\\.setup\\.ts/,\n    },\n\n    // Step 2: Auth setup (needs DB)\n    {\n      name: 'auth-setup',\n      testMatch: /auth\\.setup\\.ts/,\n      dependencies: ['db-setup'],\n    },\n\n    // Step 3: Seed data (needs auth)\n    {\n      name: 'seed-setup',\n      testMatch: /seed\\.setup\\.ts/,\n      dependencies: ['auth-setup'],\n    },\n\n    // Tests (need everything)\n    {\n      name: 'tests',\n      testDir: './tests',\n      dependencies: ['seed-setup'],\n    },\n  ],\n})\n```\n\n## Setup Projects\n\n### Authentication Setup\n\nSetup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.\n\n> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Data Seeding Setup\n\n```typescript\n// seed.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('seed test data', async ({request}) => {\n  // Create test data via API\n  await request.post('/api/test/seed', {\n    data: {\n      users: 10,\n      products: 50,\n      orders: 100,\n    },\n  })\n})\n```\n\n### Cleanup Setup\n\n```typescript\n// cleanup.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('cleanup previous run', async ({request}) => {\n  // Clean up data from previous test runs\n  await request.delete('/api/test/cleanup')\n})\n```\n\n## Filtering & Running Projects\n\n### Run Specific Project\n\n```bash\n# Run single project\nnpx playwright test --project=chromium\n\n# Run multiple projects\nnpx playwright test --project=chromium --project=firefox\n```\n\n### Run by Grep\n\n```bash\n# Run tests matching pattern\nnpx playwright test --grep @smoke\n\n# Run project with grep\nnpx playwright test --project=chromium --grep @critical\n\n# Exclude pattern\nnpx playwright test --grep-invert @slow\n```\n\n### Project-Specific Grep\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n## Sharing Configuration\n\n### Base Configuration\n\n```typescript\n// playwright.config.ts\nconst baseConfig = {\n  timeout: 30000,\n  expect: {timeout: 5000},\n  use: {\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n}\n\nexport default defineConfig({\n  ...baseConfig,\n  projects: [\n    {\n      name: 'chromium',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n### Shared Project Settings\n\n```typescript\nconst sharedBrowserConfig = {\n  timeout: 60000,\n  retries: 2,\n  use: {\n    video: 'on-first-retry',\n    trace: 'on-first-retry',\n  },\n}\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n## Advanced Patterns\n\n### Conditional Projects\n\n```typescript\nconst projects = [\n  {\n    name: 'chromium',\n    use: {...devices['Desktop Chrome']},\n  },\n]\n\n// Add Firefox only in CI\nif (process.env.CI) {\n  projects.push({\n    name: 'firefox',\n    use: {...devices['Desktop Firefox']},\n  })\n}\n\n// Add mobile only for specific test dirs\nif (process.env.TEST_MOBILE) {\n  projects.push({\n    name: 'mobile',\n    use: {...devices['iPhone 14']},\n  })\n}\n\nexport default defineConfig({projects})\n```\n\n### Project Metadata\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      metadata: {\n        platform: 'desktop',\n        browser: 'chromium',\n        priority: 'high',\n      },\n    },\n  ],\n})\n\n// Access in test\ntest('example', async ({page}, testInfo) => {\n  const {platform, priority} = testInfo.project.metadata\n  console.log(`Running on ${platform} with ${priority} priority`)\n})\n```\n\n### Teardown Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n      teardown: 'teardown', // Run teardown after this completes\n    },\n    {\n      name: 'teardown',\n      testMatch: /.*\\.teardown\\.ts/,\n    },\n    {\n      name: 'tests',\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```typescript\n// cleanup.teardown.ts\nimport {test as teardown} from '@playwright/test'\n\nteardown('cleanup', async ({request}) => {\n  await request.delete('/api/test/data')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                | Solution                            |\n| -------------------------- | ---------------------- | ----------------------------------- |\n| Too many browser projects  | Slow CI, expensive     | Focus on critical browsers          |\n| Missing setup dependencies | Tests fail randomly    | Declare all dependencies explicitly |\n| Duplicated configuration   | Hard to maintain       | Extract shared config               |\n| Not using setup projects   | Repeated auth in tests | Use setup project + storageState    |\n\n## Related References\n\n- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI\n",".agents/skills/playwright-best-practices/core/test-data.md":"# Test Data Factories & Generators\n\nThis file covers **reusable test data builders** (factories, Faker, data generators). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n## Table of Contents\n\n1. [Factory Pattern](#factory-pattern)\n2. [Faker Integration](#faker-integration)\n3. [Data-Driven Testing](#data-driven-testing)\n4. [Test Data Fixtures](#test-data-fixtures)\n5. [Database Seeding](#database-seeding)\n\n## Factory Pattern\n\n### Basic Factory\n\n```typescript\n// factories/user.factory.ts\ninterface User {\n  id: string\n  email: string\n  name: string\n  role: 'admin' | 'user' | 'guest'\n  createdAt: Date\n}\n\nlet userIdCounter = 0\n\nexport function createUser(overrides: Partial<User> = {}): User {\n  userIdCounter++\n  return {\n    id: `user-${userIdCounter}`,\n    email: `user${userIdCounter}@test.com`,\n    name: `Test User ${userIdCounter}`,\n    role: 'user',\n    createdAt: new Date(),\n    ...overrides,\n  }\n}\n\n// Usage\nconst user = createUser()\nconst admin = createUser({role: 'admin', name: 'Admin User'})\n```\n\n### Factory with Traits\n\n```typescript\n// factories/product.factory.ts\ninterface Product {\n  id: string\n  name: string\n  price: number\n  stock: number\n  category: string\n  featured: boolean\n}\n\ntype ProductTrait = 'outOfStock' | 'featured' | 'expensive' | 'sale'\n\nconst traits: Record<ProductTrait, Partial<Product>> = {\n  outOfStock: {stock: 0},\n  featured: {featured: true},\n  expensive: {price: 999.99},\n  sale: {price: 9.99},\n}\n\nlet productIdCounter = 0\n\nexport function createProduct(\n  overrides: Partial<Product> = {},\n  ...traitNames: ProductTrait[]\n): Product {\n  productIdCounter++\n\n  const appliedTraits = traitNames.reduce((acc, trait) => ({...acc, ...traits[trait]}), {})\n\n  return {\n    id: `prod-${productIdCounter}`,\n    name: `Product ${productIdCounter}`,\n    price: 29.99,\n    stock: 100,\n    category: 'General',\n    featured: false,\n    ...appliedTraits,\n    ...overrides,\n  }\n}\n\n// Usage\nconst product = createProduct()\nconst featuredProduct = createProduct({}, 'featured')\nconst saleItem = createProduct({name: 'Sale Item'}, 'sale', 'featured')\nconst soldOut = createProduct({}, 'outOfStock')\n```\n\n### Factory with Relationships\n\n```typescript\nimport {createProduct, Product} from './product.factory'\n// factories/order.factory.ts\nimport {createUser, User} from './user.factory'\n\ninterface OrderItem {\n  product: Product\n  quantity: number\n}\n\ninterface Order {\n  id: string\n  user: User\n  items: OrderItem[]\n  total: number\n  status: 'pending' | 'paid' | 'shipped' | 'delivered'\n}\n\nlet orderIdCounter = 0\n\nexport function createOrder(overrides: Partial<Order> = {}): Order {\n  orderIdCounter++\n\n  const user = overrides.user ?? createUser()\n  const items = overrides.items ?? [{product: createProduct(), quantity: 1}]\n  const total = items.reduce((sum, item) => sum + item.product.price * item.quantity, 0)\n\n  return {\n    id: `order-${orderIdCounter}`,\n    user,\n    items,\n    total,\n    status: 'pending',\n    ...overrides,\n  }\n}\n\n// Usage\nconst order = createOrder()\nconst bigOrder = createOrder({\n  items: [\n    {product: createProduct({price: 100}), quantity: 5},\n    {product: createProduct({price: 50}), quantity: 2},\n  ],\n})\n```\n\n## Faker Integration\n\n### Setup Faker\n\n```bash\nnpm install -D @faker-js/faker\n```\n\n```typescript\n// factories/faker-user.factory.ts\nimport {faker} from '@faker-js/faker'\n\ninterface User {\n  id: string\n  email: string\n  name: string\n  avatar: string\n  address: {\n    street: string\n    city: string\n    country: string\n    zipCode: string\n  }\n}\n\nexport function createFakeUser(overrides: Partial<User> = {}): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    avatar: faker.image.avatar(),\n    address: {\n      street: faker.location.streetAddress(),\n      city: faker.location.city(),\n      country: faker.location.country(),\n      zipCode: faker.location.zipCode(),\n    },\n    ...overrides,\n  }\n}\n```\n\n### Seeded Faker for Reproducibility\n\n```typescript\nimport {faker} from '@faker-js/faker'\n\n// Set seed for reproducible data\nfaker.seed(12345)\n\nexport function createDeterministicUser(): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    // Same seed = same data every time\n  }\n}\n\n// Or seed per test\ntest('user profile', async ({page}) => {\n  faker.seed(42) // Reset seed for this test\n  const user = createFakeUser()\n  // user will always have the same data\n})\n```\n\n### Faker Fixture\n\n```typescript\nimport {faker} from '@faker-js/faker'\n// fixtures/faker.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype FakerFixtures = {\n  fake: typeof faker\n}\n\nexport const test = base.extend<FakerFixtures>({\n  fake: async ({}, use, testInfo) => {\n    // Seed based on test name for reproducibility\n    faker.seed(testInfo.title.length)\n    await use(faker)\n  },\n})\n\n// Usage\ntest('create user with fake data', async ({page, fake}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Name').fill(fake.person.fullName())\n  await page.getByLabel('Email').fill(fake.internet.email())\n  await page.getByLabel('Password').fill(fake.internet.password())\n\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n})\n```\n\n## Data-Driven Testing\n\n### test.each with Arrays\n\n```typescript\nconst loginScenarios = [\n  {email: 'user@example.com', password: 'pass123', expected: 'Dashboard'},\n  {email: 'admin@example.com', password: 'admin123', expected: 'Admin Panel'},\n  {\n    email: 'invalid@example.com',\n    password: 'wrong',\n    expected: 'Invalid credentials',\n  },\n]\n\nfor (const {email, password, expected} of loginScenarios) {\n  test(`login with ${email}`, async ({page}) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Sign In'}).click()\n\n    await expect(page.getByText(expected)).toBeVisible()\n  })\n}\n```\n\n### Parameterized Tests\n\n```typescript\n// data/checkout-scenarios.ts\nexport const checkoutScenarios = [\n  {\n    name: 'standard shipping',\n    shipping: 'standard',\n    expectedDays: '5-7 business days',\n    expectedCost: '$5.99',\n  },\n  {\n    name: 'express shipping',\n    shipping: 'express',\n    expectedDays: '2-3 business days',\n    expectedCost: '$14.99',\n  },\n  {\n    name: 'overnight shipping',\n    shipping: 'overnight',\n    expectedDays: 'Next business day',\n    expectedCost: '$29.99',\n  },\n]\n```\n\n```typescript\nimport {checkoutScenarios} from './data/checkout-scenarios'\n\ntest.describe('shipping options', () => {\n  for (const scenario of checkoutScenarios) {\n    test(`checkout with ${scenario.name}`, async ({page}) => {\n      await page.goto('/checkout')\n\n      await page.getByLabel(scenario.shipping, {exact: false}).check()\n\n      await expect(page.getByText(scenario.expectedDays)).toBeVisible()\n      await expect(page.getByText(scenario.expectedCost)).toBeVisible()\n    })\n  }\n})\n```\n\n### CSV/JSON Data Source\n\n```typescript\nimport fs from 'fs'\n\ninterface TestCase {\n  input: string\n  expected: string\n}\n\n// Load test data from JSON\nconst testCases: TestCase[] = JSON.parse(fs.readFileSync('./data/search-tests.json', 'utf-8'))\n\ntest.describe('search functionality', () => {\n  for (const {input, expected} of testCases) {\n    test(`search for \"${input}\"`, async ({page}) => {\n      await page.goto('/search')\n      await page.getByLabel('Search').fill(input)\n      await page.getByLabel('Search').press('Enter')\n\n      await expect(page.getByText(expected)).toBeVisible()\n    })\n  }\n})\n```\n\n## Test Data Fixtures\n\n### Fixture with Factory\n\n```typescript\n// fixtures/data.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {createProduct, Product} from '../factories/product.factory'\nimport {createUser, User} from '../factories/user.factory'\n\ntype DataFixtures = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<DataFixtures>({\n  testUser: async ({}, use) => {\n    const user = createUser({name: 'E2E Test User'})\n    await use(user)\n  },\n\n  testProducts: async ({}, use) => {\n    const products = [\n      createProduct({name: 'Test Product 1'}),\n      createProduct({name: 'Test Product 2'}),\n      createProduct({name: 'Test Product 3'}),\n    ]\n    await use(products)\n  },\n})\n\n// Usage\ntest('add product to cart', async ({page, testUser, testProducts}) => {\n  // Mock API with test data\n  await page.route('**/api/user', (route) => route.fulfill({json: testUser}))\n  await page.route('**/api/products', (route) => route.fulfill({json: testProducts}))\n\n  await page.goto('/products')\n  await expect(page.getByText(testProducts[0].name)).toBeVisible()\n})\n```\n\n## Database Seeding\n\n### API-Based Seeding\n\n```typescript\n// fixtures/seed.fixture.ts\nimport {test as base, APIRequestContext} from '@playwright/test'\n\nimport {createUser} from '../factories/user.factory'\n\ntype SeedFixtures = {\n  seedUser: (overrides?: Partial<User>) => Promise<User>\n  cleanupUsers: string[]\n}\n\nexport const test = base.extend<SeedFixtures>({\n  cleanupUsers: [],\n\n  seedUser: async ({request, cleanupUsers}, use) => {\n    await use(async (overrides = {}) => {\n      const userData = createUser(overrides)\n\n      const response = await request.post('/api/test/users', {\n        data: userData,\n      })\n      const user = await response.json()\n\n      cleanupUsers.push(user.id)\n      return user\n    })\n  },\n\n  // Cleanup after test\n  cleanupUsers: async ({request}, use) => {\n    const userIds: string[] = []\n    await use(userIds)\n\n    // Delete all created users\n    for (const id of userIds) {\n      await request.delete(`/api/test/users/${id}`)\n    }\n  },\n})\n\n// Usage\ntest('user profile page', async ({page, seedUser}) => {\n  const user = await seedUser({name: 'John Doe'})\n\n  await page.goto(`/users/${user.id}`)\n  await expect(page.getByText('John Doe')).toBeVisible()\n})\n```\n\n### Transaction Rollback Seeding\n\n```typescript\n// fixtures/db.fixture.ts\nexport const test = base.extend<{}, {db: DbTransaction}>({\n  db: [\n    async ({}, use) => {\n      const client = await pool.connect()\n      await client.query('BEGIN')\n\n      await use({\n        query: (sql: string, params?: any[]) => client.query(sql, params),\n        seed: async (table: string, data: object) => {\n          const keys = Object.keys(data)\n          const values = Object.values(data)\n          const placeholders = keys.map((_, i) => `$${i + 1}`)\n\n          const result = await client.query(\n            `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`,\n            values,\n          )\n          return result.rows[0]\n        },\n      })\n\n      await client.query('ROLLBACK')\n      client.release()\n    },\n    {scope: 'test'},\n  ],\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                    | Problem                         | Solution                   |\n| ------------------------------- | ------------------------------- | -------------------------- |\n| Hardcoded test data             | Brittle, repetitive             | Use factories              |\n| Random data without seed        | Non-reproducible failures       | Seed faker per test        |\n| Shared mutable test data        | Tests interfere with each other | Create fresh data per test |\n| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories    |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns\n- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n",".agents/skills/playwright-best-practices/core/test-suite-structure.md":"# Test Suite Structure\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [E2E Tests](#e2e-tests)\n3. [Component Tests](#component-tests)\n4. [API Tests](#api-tests)\n5. [Visual Regression Tests](#visual-regression-tests)\n6. [Directory Structure](#directory-structure)\n7. [Tagging & Filtering](#tagging--filtering)\n\n### Project Setup\n\n```bash\nnpm init playwright@latest\n```\n\n## Configuration\n\n### Essential Configuration\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: [['html'], ['list']],\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n  ],\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n})\n```\n\n## E2E Tests\n\nFull user journey tests through the browser.\n\n### Structure\n\n```typescript\n// tests/e2e/checkout.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Checkout Flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/products')\n  })\n\n  test('complete purchase as guest', async ({page}) => {\n    // Add to cart\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await expect(page.getByTestId('cart-count')).toHaveText('1')\n\n    // Go to checkout\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n\n    // Fill shipping\n    await page.getByLabel('Email').fill('guest@example.com')\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByRole('button', {name: 'Continue'}).click()\n\n    // Payment\n    await page.getByLabel('Card Number').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay Now'}).click()\n\n    // Confirmation\n    await expect(page.getByRole('heading')).toHaveText('Order Confirmed')\n  })\n\n  test('apply discount code', async ({page}) => {\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await page.getByRole('link', {name: 'Cart'}).click()\n\n    await page.getByLabel('Discount Code').fill('SAVE10')\n    await page.getByRole('button', {name: 'Apply'}).click()\n\n    await expect(page.getByText('10% discount applied')).toBeVisible()\n  })\n})\n```\n\n### Best Practices\n\n- Test critical user journeys\n- Keep tests independent\n- Use realistic data\n- Clean up test data in teardown\n\n## Component Tests\n\nTest individual components in isolation using Playwright Component Testing.\n\n```bash\nnpm init playwright@latest -- --ct\n```\n\nFor comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.\n\n## API Tests\n\nTest backend APIs without browser.\n\n### API Mocking Patterns\n\nFor E2E tests that need to mock API responses:\n\n```typescript\n// Mock single endpoint\ntest('displays mocked users', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 200,\n      json: [{id: 1, name: 'Test User'}],\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Test User')).toBeVisible()\n})\n\n// Mock with different responses\ntest('handles API errors', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 500,\n      json: {error: 'Server error'},\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Server error')).toBeVisible()\n})\n\n// Conditional mocking\ntest('mocks based on request', async ({page}) => {\n  await page.route('**/api/users', (route, request) => {\n    if (request.method() === 'GET') {\n      route.fulfill({json: [{id: 1, name: 'User'}]})\n    } else {\n      route.continue()\n    }\n  })\n})\n\n// Mock with delay (simulate slow network)\ntest('handles slow API', async ({page}) => {\n  await page.route('**/api/data', (route) =>\n    route.fulfill({\n      json: {data: 'test'},\n      delay: 2000, // 2 second delay\n    }),\n  )\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('Loading...')).toBeVisible()\n  await expect(page.getByText('test')).toBeVisible()\n})\n```\n\nFor advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.\n\n## Visual Regression Tests\n\nCompare screenshots to detect visual changes.\n\n### Basic Visual Test\n\n```typescript\n// tests/visual/homepage.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('homepage visual', async ({page}) => {\n  await page.goto('/')\n  await expect(page).toHaveScreenshot('homepage.png')\n})\n\ntest('component visual', async ({page}) => {\n  await page.goto('/components')\n\n  const button = page.getByRole('button', {name: 'Primary'})\n  await expect(button).toHaveScreenshot('primary-button.png')\n})\n```\n\n### Visual Test Options\n\n```typescript\ntest('dashboard visual', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page).toHaveScreenshot('dashboard.png', {\n    fullPage: true, // Capture entire scrollable page\n    maxDiffPixels: 100, // Allow up to 100 different pixels\n    maxDiffPixelRatio: 0.01, // Or 1% difference\n    threshold: 0.2, // Pixel comparison threshold\n    animations: 'disabled', // Disable animations\n    mask: [page.getByTestId('date')], // Mask dynamic content\n  })\n})\n```\n\n### Handling Dynamic Content\n\n```typescript\ntest('page with dynamic content', async ({page}) => {\n  await page.goto('/profile')\n\n  // Mask elements that change\n  await expect(page).toHaveScreenshot('profile.png', {\n    mask: [page.getByTestId('timestamp'), page.getByTestId('avatar'), page.getByRole('img')],\n  })\n})\n\n// Or hide elements via CSS\ntest('page hiding dynamic elements', async ({page}) => {\n  await page.goto('/profile')\n\n  await page.addStyleTag({\n    content: `\n      .dynamic-content { visibility: hidden !important; }\n      [data-testid=\"ad-banner\"] { display: none !important; }\n    `,\n  })\n\n  await expect(page).toHaveScreenshot('profile-stable.png')\n})\n```\n\n### Visual Test Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  expect: {\n    toHaveScreenshot: {\n      maxDiffPixels: 50,\n      animations: 'disabled',\n    },\n  },\n  projects: [\n    {\n      name: 'visual-chrome',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n      testMatch: /.*visual.*\\.spec\\.ts/,\n    },\n  ],\n})\n```\n\n### Update Snapshots\n\n```bash\n# Update all snapshots\nnpx playwright test --update-snapshots\n\n# Update specific test\nnpx playwright test homepage.spec.ts --update-snapshots\n```\n\n## Directory Structure\n\n```\ntests/\n├── e2e/                    # End-to-end tests\n│   ├── auth.spec.ts\n│   ├── checkout.spec.ts\n│   └── dashboard.spec.ts\n├── component/              # Component tests\n│   ├── Button.spec.tsx\n│   └── Modal.spec.tsx\n├── api/                    # API tests\n│   ├── users.spec.ts\n│   └── products.spec.ts\n├── visual/                 # Visual regression tests\n│   └── homepage.spec.ts\n├── fixtures/               # Custom fixtures\n│   ├── auth.fixture.ts\n│   └── api.fixture.ts\n└── pages/                  # Page objects\n    ├── login.page.ts\n    └── dashboard.page.ts\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                            | Solution                  |\n| ------------------------------------- | ---------------------------------- | ------------------------- |\n| Long test files                       | Hard to maintain, slow to navigate | Split by feature, use POM |\n| Tests depend on execution order       | Flaky, hard to debug               | Keep tests independent    |\n| Testing multiple features in one test | Hard to debug failures             | One feature per test      |\n\n## Related References\n\n- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering\n- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions\n- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data\n\n## Tagging & Filtering\n\n### Using Tags\n\n```typescript\ntest('user login @smoke @auth', async ({page}) => {\n  // ...\n})\n\ntest('checkout flow @e2e @critical', async ({page}) => {\n  // ...\n})\n\ntest.describe('API tests @api', () => {\n  test('create user', async ({request}) => {\n    // ...\n  })\n})\n```\n\n### Running Tagged Tests\n\n```bash\n# Run smoke tests\nnpx playwright test --grep @smoke\n\n# Run all except slow tests\nnpx playwright test --grep-invert @slow\n\n# Combine tags\nnpx playwright test --grep \"@smoke|@critical\"\n```\n\nFor project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.\n",".agents/skills/playwright-best-practices/core/test-tags.md":"# Test Tags\n\n## Table of Contents\n\n1. [Basic Tagging](#basic-tagging)\n2. [Tagging Describe Blocks](#tagging-describe-blocks)\n3. [Running Tagged Tests](#running-tagged-tests)\n4. [Filtering by Tags](#filtering-by-tags)\n5. [Configuration-Based Filtering](#configuration-based-filtering)\n6. [Tag Organization Patterns](#tag-organization-patterns)\n7. [Common Tag Categories](#common-tag-categories)\n8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)\n9. [Related References](#related-references)\n\n## Basic Tagging\n\n### Tag via Details Object\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest(\n  'test login page',\n  {\n    tag: '@fast',\n  },\n  async ({page}) => {\n    await page.goto('/login')\n    await expect(page.getByRole('heading')).toBeVisible()\n  },\n)\n\ntest(\n  'test dashboard',\n  {\n    tag: '@slow',\n  },\n  async ({page}) => {\n    await page.goto('/dashboard')\n    await expect(page.getByTestId('charts')).toBeVisible()\n  },\n)\n```\n\n### Tag via Title (not recommended)\n\n```typescript\ntest('test full report @slow', async ({page}) => {\n  await page.goto('/reports/full')\n  await expect(page.getByText('Report loaded')).toBeVisible()\n})\n\ntest('quick validation @fast @smoke', async ({page}) => {\n  await page.goto('/')\n  await expect(page.locator('body')).toBeVisible()\n})\n```\n\n## Tagging Describe Blocks\n\n### Tag All Tests in Group\n\n```typescript\ntest.describe(\n  'report tests',\n  {\n    tag: '@report',\n  },\n  () => {\n    test('test report header', async ({page}) => {\n      // Inherits @report tag\n    })\n\n    test('test report footer', async ({page}) => {\n      // Inherits @report tag\n    })\n  },\n)\n```\n\n### Combine Group and Test Tags\n\n```typescript\ntest.describe(\n  'admin features',\n  {\n    tag: '@admin',\n  },\n  () => {\n    test('admin dashboard', async ({page}) => {\n      // Has @admin tag\n    })\n\n    test(\n      'admin settings',\n      {\n        tag: ['@slow', '@critical'],\n      },\n      async ({page}) => {\n        // Has @admin, @slow, @critical tags\n      },\n    )\n  },\n)\n```\n\n## Running Tagged Tests\n\n### Run Tests with Specific Tag\n\n```bash\n# Run all @fast tests\nnpx playwright test --grep @fast\n```\n\n### Exclude Tests with Tag\n\n```bash\n# Run all tests except @slow\nnpx playwright test --grep-invert @slow\n```\n\n## Filtering by Tags\n\n### Logical OR (Either Tag)\n\n```bash\n# Run tests with @fast OR @smoke\nnpx playwright test --grep \"@fast|@smoke\"\n```\n\n### Logical AND (Both Tags)\n\n```bash\n# Run tests with both @fast AND @critical\nnpx playwright test --grep \"(?=.*@fast)(?=.*@critical)\"\n```\n\n### Complex Patterns\n\n```bash\n# Run @e2e tests that are also @critical\nnpx playwright test --grep \"(?=.*@e2e)(?=.*@critical)\"\n\n# Run @api tests excluding @slow\nnpx playwright test --grep \"@api\" --grep-invert \"@slow\"\n```\n\n## Configuration-Based Filtering\n\n### Filter in playwright.config.ts\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  grep: /@smoke/,\n  grepInvert: /@flaky/,\n})\n```\n\n### Project-Specific Tags\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n    },\n  ],\n})\n```\n\n### Environment-Based Filtering\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nconst isCI = !!process.env.CI\n\nexport default defineConfig({\n  grep: isCI ? /@smoke|@critical/ : undefined,\n  grepInvert: isCI ? /@flaky/ : undefined,\n})\n```\n\n## Tag Organization Patterns\n\n### By Test Type\n\n```typescript\n// Smoke tests - quick validation\ntest('homepage loads', {tag: '@smoke'}, async ({page}) => {})\ntest('login works', {tag: '@smoke'}, async ({page}) => {})\n\n// Regression tests - comprehensive\ntest('full checkout flow', {tag: '@regression'}, async ({page}) => {})\ntest('all payment methods', {tag: '@regression'}, async ({page}) => {})\n\n// E2E tests - user journeys\ntest('complete user journey', {tag: '@e2e'}, async ({page}) => {})\n```\n\n### By Priority\n\n```typescript\ntest(\n  'payment processing',\n  {\n    tag: ['@critical', '@p0'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'user preferences',\n  {\n    tag: ['@p1'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'theme customization',\n  {\n    tag: ['@p2'],\n  },\n  async ({page}) => {},\n)\n```\n\n### By Feature Area\n\n```typescript\ntest.describe(\n  'authentication',\n  {\n    tag: '@auth',\n  },\n  () => {\n    test('login @smoke', async ({page}) => {})\n    test('logout', async ({page}) => {})\n    test('password reset @slow', async ({page}) => {})\n  },\n)\n\ntest.describe(\n  'payments',\n  {\n    tag: '@payments',\n  },\n  () => {\n    test('credit card @critical', async ({page}) => {})\n    test('paypal @critical', async ({page}) => {})\n  },\n)\n```\n\n## Common Tag Categories\n\n| Category        | Tags                                          | Purpose                       |\n| --------------- | --------------------------------------------- | ----------------------------- |\n| **Speed**       | `@fast`, `@slow`                              | Execution time classification |\n| **Priority**    | `@critical`, `@p0`, `@p1`, `@p2`              | Business importance           |\n| **Type**        | `@smoke`, `@regression`, `@e2e`               | Test suite categorization     |\n| **Feature**     | `@auth`, `@payments`, `@settings`             | Feature area grouping         |\n| **Pipeline**    | `@pr`, `@nightly`, `@release`                 | CI/CD execution timing        |\n| **Status**      | `@flaky`, `@wip`, `@quarantine`               | Test health tracking          |\n| **Environment** | `@local`, `@staging`, `@prod`                 | Target environment            |\n| **Team**        | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment               |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                  | Solution                                       |\n| ------------------------ | ------------------------ | ---------------------------------------------- |\n| Too many tags per test   | Hard to maintain         | Limit to 2-3 relevant tags                     |\n| Inconsistent naming      | Confusing filtering      | Establish naming conventions                   |\n| Missing `@` prefix       | Tags won't match filters | Always prefix with `@`                         |\n| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics                     |\n| Not using tags           | Can't selectively run    | Tag by type, priority, or feature              |\n| Tags in test title       | Hard to parse/filter     | Use the details object for tags, not the title |\n\n## Related References\n\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow\n- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup\n",".agents/skills/playwright-best-practices/debugging/console-errors.md":"# Browser Console & JavaScript Error Handling\n\n## Table of Contents\n\n1. [Capturing Console Messages](#capturing-console-messages)\n2. [Failing on Console Errors](#failing-on-console-errors)\n3. [JavaScript Error Detection](#javascript-error-detection)\n4. [Monitoring Warnings](#monitoring-warnings)\n5. [Console Fixtures](#console-fixtures)\n\n## Capturing Console Messages\n\n### Basic Console Capture\n\n```typescript\ntest('capture console logs', async ({page}) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`${msg.type()}: ${msg.text()}`)\n  })\n\n  await page.goto('/')\n\n  // Check what was logged\n  console.log('Captured logs:', logs)\n})\n```\n\n### Capture by Type\n\n```typescript\ntest('capture specific console types', async ({page}) => {\n  const errors: string[] = []\n  const warnings: string[] = []\n  const infos: string[] = []\n\n  page.on('console', (msg) => {\n    switch (msg.type()) {\n      case 'error':\n        errors.push(msg.text())\n        break\n      case 'warning':\n        warnings.push(msg.text())\n        break\n      case 'info':\n      case 'log':\n        infos.push(msg.text())\n        break\n    }\n  })\n\n  await page.goto('/dashboard')\n\n  expect(errors).toHaveLength(0)\n  console.log('Warnings:', warnings)\n})\n```\n\n### Capture with Stack Trace\n\n```typescript\ntest('capture errors with location', async ({page}) => {\n  const errors: {message: string; location?: string}[] = []\n\n  page.on('console', async (msg) => {\n    if (msg.type() === 'error') {\n      const location = msg.location()\n      errors.push({\n        message: msg.text(),\n        location: location ? `${location.url}:${location.lineNumber}` : undefined,\n      })\n    }\n  })\n\n  await page.goto('/buggy-page')\n\n  // Log errors with source location\n  errors.forEach((e) => {\n    console.log(`Error: ${e.message}`)\n    if (e.location) console.log(`  at ${e.location}`)\n  })\n})\n```\n\n## Failing on Console Errors\n\n### Fail Test on Any Error\n\n```typescript\ntest('no console errors allowed', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Load Data'}).click()\n\n  // Fail if any console errors\n  expect(errors, `Console errors found:\\n${errors.join('\\n')}`).toHaveLength(0)\n})\n```\n\n### Fail with Allowed Exceptions\n\n```typescript\ntest('no unexpected console errors', async ({page}) => {\n  const allowedErrors = [/Failed to load resource.*favicon/, /ResizeObserver loop/]\n\n  const unexpectedErrors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      const text = msg.text()\n      const isAllowed = allowedErrors.some((pattern) => pattern.test(text))\n      if (!isAllowed) {\n        unexpectedErrors.push(text)\n      }\n    }\n  })\n\n  await page.goto('/')\n\n  expect(\n    unexpectedErrors,\n    `Unexpected console errors:\\n${unexpectedErrors.join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Auto-Fail Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleFixtures = {\n  failOnConsoleError: void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  failOnConsoleError: [\n    async ({page}, use, testInfo) => {\n      const errors: string[] = []\n\n      page.on('console', (msg) => {\n        if (msg.type() === 'error') {\n          errors.push(msg.text())\n        }\n      })\n\n      await use()\n\n      // After test, check for errors\n      if (errors.length > 0) {\n        testInfo.annotations.push({\n          type: 'console-errors',\n          description: errors.join('\\n'),\n        })\n        throw new Error(`Console errors detected:\\n${errors.join('\\n')}`)\n      }\n    },\n    {auto: true}, // Runs for every test\n  ],\n})\n```\n\n## JavaScript Error Detection\n\n### Catch Uncaught Exceptions\n\n```typescript\ntest('no uncaught exceptions', async ({page}) => {\n  const pageErrors: Error[] = []\n\n  page.on('pageerror', (error) => {\n    pageErrors.push(error)\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Trigger Action'}).click()\n\n  expect(\n    pageErrors,\n    `Uncaught exceptions:\\n${pageErrors.map((e) => e.message).join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Capture Error Details\n\n```typescript\ntest('capture JS error details', async ({page}) => {\n  const errors: {message: string; stack?: string}[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push({\n      message: error.message,\n      stack: error.stack,\n    })\n  })\n\n  await page.goto('/error-page')\n\n  if (errors.length > 0) {\n    console.log('JavaScript errors:')\n    errors.forEach((e) => {\n      console.log(`  Message: ${e.message}`)\n      console.log(`  Stack: ${e.stack}`)\n    })\n  }\n})\n```\n\n### Test Error Boundary Triggers\n\n```typescript\ntest('error boundary catches render error', async ({page}) => {\n  let errorCaught = false\n\n  page.on('pageerror', () => {\n    // Note: React error boundaries catch errors before they become pageerrors\n    // This would only fire for unhandled errors\n    errorCaught = true\n  })\n\n  // Trigger component error via props\n  await page.route(\n    '**/api/data',\n    (route) => route.fulfill({json: null}), // Will cause \"cannot read property of null\"\n  )\n\n  await page.goto('/dashboard')\n\n  // Error boundary should show fallback, not crash\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  expect(errorCaught).toBe(false) // Error was caught by boundary\n})\n```\n\n## Monitoring Warnings\n\n### Capture Deprecation Warnings\n\n```typescript\ntest('no deprecation warnings', async ({page}) => {\n  const deprecations: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('deprecated') || text.includes('Deprecation'))) {\n      deprecations.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  if (deprecations.length > 0) {\n    console.warn('Deprecation warnings found:')\n    deprecations.forEach((d) => console.warn(`  - ${d}`))\n  }\n\n  // Optionally fail\n  // expect(deprecations).toHaveLength(0);\n})\n```\n\n### React Development Warnings\n\n```typescript\ntest('no React warnings', async ({page}) => {\n  const reactWarnings: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('Warning:') || text.includes('React'))) {\n      reactWarnings.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  // Common React warnings to check\n  const criticalWarnings = reactWarnings.filter(\n    (w) =>\n      w.includes('Each child in a list should have a unique') ||\n      w.includes('Cannot update a component') ||\n      w.includes(\"Can't perform a React state update\"),\n  )\n\n  expect(criticalWarnings, `React warnings:\\n${criticalWarnings.join('\\n')}`).toHaveLength(0)\n})\n```\n\n## Console Fixtures\n\n### Comprehensive Console Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleMessage = {\n  type: string\n  text: string\n  location?: {url: string; line: number}\n  timestamp: number\n}\n\ntype ConsoleFixtures = {\n  consoleMessages: ConsoleMessage[]\n  getConsoleErrors: () => ConsoleMessage[]\n  getConsoleWarnings: () => ConsoleMessage[]\n  assertNoErrors: (allowedPatterns?: RegExp[]) => void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  consoleMessages: async ({page}, use) => {\n    const messages: ConsoleMessage[] = []\n\n    page.on('console', (msg) => {\n      const location = msg.location()\n      messages.push({\n        type: msg.type(),\n        text: msg.text(),\n        location: location ? {url: location.url, line: location.lineNumber} : undefined,\n        timestamp: Date.now(),\n      })\n    })\n\n    await use(messages)\n  },\n\n  getConsoleErrors: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'error'))\n  },\n\n  getConsoleWarnings: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'warning'))\n  },\n\n  assertNoErrors: async ({getConsoleErrors}, use) => {\n    await use((allowedPatterns = []) => {\n      const errors = getConsoleErrors()\n      const unexpected = errors.filter((e) => !allowedPatterns.some((p) => p.test(e.text)))\n\n      if (unexpected.length > 0) {\n        throw new Error(`Unexpected console errors:\\n${unexpected.map((e) => e.text).join('\\n')}`)\n      }\n    })\n  },\n})\n\n// Usage\ntest('page loads without errors', async ({page, assertNoErrors}) => {\n  await page.goto('/dashboard')\n  await page.getByRole('button', {name: 'Load'}).click()\n\n  assertNoErrors([/favicon/]) // Allow favicon errors\n})\n```\n\n### Attach Console to Report\n\n```typescript\ntest('capture console for debugging', async ({page}, testInfo) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`[${msg.type()}] ${msg.text()}`)\n  })\n\n  page.on('pageerror', (error) => {\n    logs.push(`[EXCEPTION] ${error.message}`)\n  })\n\n  await page.goto('/')\n  // ... test actions\n\n  // Attach console log to test report\n  await testInfo.attach('console-log', {\n    body: logs.join('\\n'),\n    contentType: 'text/plain',\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                    | Solution                    |\n| -------------------------- | -------------------------- | --------------------------- |\n| Ignoring console errors    | Bugs go unnoticed          | Check for errors in tests   |\n| Too strict error checking  | Tests fail on minor issues | Allow known/expected errors |\n| Not capturing stack traces | Hard to debug              | Include location info       |\n| Checking only at end       | Miss errors during actions | Capture continuously        |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for troubleshooting\n- **Error Testing**: See [error-testing.md](error-testing.md) for error scenarios\n",".agents/skills/playwright-best-practices/debugging/debugging.md":"# Debugging & Troubleshooting\n\n## Table of Contents\n\n1. [Debug Tools](#debug-tools)\n2. [Trace Viewer](#trace-viewer)\n3. [Identifying Flaky Tests](#identifying-flaky-tests)\n4. [Debugging Network Issues](#debugging-network-issues)\n5. [Debugging in CI](#debugging-in-ci)\n6. [Debugging Authentication](#debugging-authentication)\n7. [Debugging Screenshots](#debugging-screenshots)\n8. [Common Issues](#common-issues)\n9. [Logging](#logging)\n\n## Debug Tools\n\n### Playwright Inspector\n\n```bash\n# Run with inspector\nPWDEBUG=1 npx playwright test\n# Or specific test\nPWDEBUG=1 npx playwright test login.spec.ts\n```\n\nFeatures:\n\n- Step through test actions\n- Pick locators visually\n- Inspect DOM state\n- Edit and re-run\n\n### Headed Mode\n\n```bash\n# Run with visible browser\nnpx playwright test --headed\n\n# Interactive debugging (headed, paused, step-through)\nnpx playwright test --debug\n```\n\nYou can also set `slowMo` to add an `N` ms delay per action, making test execution easier to follow while debugging.\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    launchOptions: {\n      slowMo: 500,\n    },\n  },\n})\n```\n\n### UI Mode\n\n```bash\n# Interactive test runner\nnpx playwright test --ui\n```\n\nFeatures:\n\n- Watch mode\n- Test timeline\n- DOM snapshots\n- Network logs\n- Console logs\n\n### Debug in Code\n\n```typescript\ntest('debug example', async ({page}) => {\n  await page.goto('/')\n\n  // Pause and open inspector\n  await page.pause()\n\n  // Continue test...\n  await page.click('button')\n})\n```\n\n## Trace Viewer\n\n### Enable Traces\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    trace: 'on-first-retry', // Record on retry\n    // trace: 'on',                 // Always record\n    // trace: 'retain-on-failure',  // Keep only failures\n  },\n})\n```\n\n### View Traces\n\n```bash\n# Open trace file\nnpx playwright show-trace trace.zip\n\n# From test-results\nnpx playwright show-trace test-results/test-name/trace.zip\n```\n\n### Trace Contents\n\n- Screenshots at each action\n- DOM snapshots\n- Network requests/responses\n- Console logs\n- Action timeline\n- Source code\n\n### Programmatic Traces\n\n```typescript\ntest('manual trace', async ({page, context}) => {\n  await context.tracing.start({screenshots: true, snapshots: true})\n\n  await page.goto('/')\n  await page.click('button')\n\n  await context.tracing.stop({path: 'trace.zip'})\n})\n```\n\n## Identifying Flaky Tests\n\nIf a test fails intermittently, it's likely flaky. Quick checks:\n\n| Behavior                               | Likely Cause                  | Next Step                                    |\n| -------------------------------------- | ----------------------------- | -------------------------------------------- |\n| Fails sometimes, passes other times    | Flaky - timing/race condition | [flaky-tests.md](flaky-tests.md)             |\n| Fails only with multiple workers       | Flaky - parallelism/isolation | [flaky-tests.md](flaky-tests.md)             |\n| Fails only in CI                       | Environment difference        | [CI Debugging](#debugging-in-ci) below       |\n| Always fails                           | Bug in test or app            | Debug with tools above                       |\n| Always passes locally, always fails CI | CI-specific issue             | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) |\n\n> **For flaky test detection commands, root cause analysis, and fixing strategies**, see [flaky-tests.md](flaky-tests.md).\n\n## Debugging Network Issues\n\n### Monitor All Requests\n\n```typescript\ntest('debug network', async ({page}) => {\n  const requests: string[] = []\n  const failures: string[] = []\n\n  page.on('request', (req) => requests.push(`>> ${req.method()} ${req.url()}`))\n  page.on('requestfinished', (req) => {\n    const resp = req.response()\n    requests.push(`<< ${resp?.status()} ${req.url()}`)\n  })\n  page.on('requestfailed', (req) => {\n    failures.push(`FAILED: ${req.url()} - ${req.failure()?.errorText}`)\n  })\n\n  await page.goto('/dashboard')\n\n  // Log summary\n  console.log('Requests:', requests.length)\n  if (failures.length) console.log('Failures:', failures)\n})\n```\n\n### Wait for Specific API Response\n\nWhen debugging network-dependent issues, wait for specific API responses instead of arbitrary timeouts.\n\n```typescript\n// Start waiting BEFORE triggering the request\nconst responsePromise = page.waitForResponse(\n  (resp) => resp.url().includes('/api/data') && resp.status() === 200,\n)\nawait page.getByRole('button', {name: 'Load'}).click()\nconst response = await responsePromise\nconsole.log('Status:', response.status())\n```\n\n> **For comprehensive waiting patterns** (navigation, element state, network, polling), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies).\n\n### Debug Slow Requests\n\n```typescript\ntest('find slow requests', async ({page}) => {\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const total = timing.responseEnd - timing.requestStart\n    if (total > 1000) {\n      console.log(`SLOW (${total}ms): ${request.url()}`)\n    }\n  })\n\n  await page.goto('/')\n})\n```\n\n## Debugging in CI\n\n### Simulate CI Locally\n\n```bash\n# Run in headless mode like CI\nCI=true npx playwright test\n\n# Match CI browser versions\nnpx playwright install --with-deps\n\n# Run in Docker (same as CI)\ndocker run --rm -v $(pwd):/work -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### CI-Specific Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // More artifacts in CI for debugging\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n  },\n\n  // More retries in CI (but investigate failures!)\n  retries: process.env.CI ? 2 : 0,\n})\n```\n\n### Debug CI Environment\n\n```typescript\ntest('CI environment check', async ({page}, testInfo) => {\n  console.log('CI:', process.env.CI)\n  console.log('Project:', testInfo.project.name)\n  console.log('Worker:', testInfo.workerIndex)\n  console.log('Retry:', testInfo.retry)\n  console.log('Base URL:', testInfo.project.use.baseURL)\n\n  // Check viewport\n  const viewport = page.viewportSize()\n  console.log('Viewport:', viewport)\n})\n```\n\n## Debugging Authentication\n\n```typescript\ntest('debug auth', async ({page, context}) => {\n  // Inspect current storage state\n  const storage = await context.storageState()\n  console.log(\n    'Cookies:',\n    storage.cookies.map((c) => c.name),\n  )\n\n  // Check if auth cookies are present\n  const cookies = await context.cookies()\n  const authCookie = cookies.find((c) => c.name.includes('session'))\n  console.log('Auth cookie:', authCookie ? 'present' : 'MISSING')\n\n  await page.goto('/protected')\n\n  // Check if redirected to login (auth failed)\n  if (page.url().includes('/login')) {\n    console.error('Auth failed - redirected to login')\n    // Save state for inspection\n    await context.storageState({path: 'debug-auth.json'})\n  }\n})\n```\n\n## Debugging Screenshots\n\n### Compare Visual State\n\n```typescript\ntest('visual debug', async ({page}, testInfo) => {\n  await page.goto('/')\n\n  // Screenshot before action\n  await page.screenshot({\n    path: testInfo.outputPath('before.png'),\n    fullPage: true,\n  })\n\n  await page.getByRole('button', {name: 'Open Menu'}).click()\n\n  // Screenshot after action\n  await page.screenshot({\n    path: testInfo.outputPath('after.png'),\n    fullPage: true,\n  })\n\n  // Attach to report\n  await testInfo.attach('before', {\n    path: testInfo.outputPath('before.png'),\n    contentType: 'image/png',\n  })\n})\n```\n\n### Screenshot Specific Element\n\n```typescript\ntest('element screenshot', async ({page}) => {\n  await page.goto('/')\n\n  const element = page.getByTestId('problem-area')\n\n  // Screenshot just the element\n  await element.screenshot({path: 'element-debug.png'})\n\n  // Highlight element in full page screenshot\n  await element.evaluate((el) => (el.style.border = '3px solid red'))\n  await page.screenshot({path: 'highlighted.png'})\n})\n```\n\n## Common Issues\n\n### Element Not Found\n\n```typescript\n// Debug: Check if element exists\nconsole.log(await page.getByRole('button').count())\n\n// Debug: Log all buttons\nconst buttons = await page.getByRole('button').all()\nfor (const button of buttons) {\n  console.log(await button.textContent())\n}\n\n// Debug: Screenshot before action\nawait page.screenshot({path: 'debug.png'})\nawait page.getByRole('button').click()\n```\n\n### Timeout Issues\n\n```typescript\n// Increase timeout for slow operations\nawait expect(page.getByText('Loaded')).toBeVisible({timeout: 30000})\n\n// Global timeout increase\ntest.setTimeout(60000)\n\n// Check what's blocking\ntest('debug timeout', async ({page}) => {\n  await page.goto('/slow-page')\n\n  // Log network activity\n  page.on('request', (request) => console.log('>>', request.url()))\n  page.on('response', (response) => console.log('<<', response.url(), response.status()))\n})\n```\n\n### Selector Issues\n\n```typescript\n// Debug: Highlight element\nawait page.getByRole('button').highlight()\n\n// Debug: Evaluate selector in browser console\n// Run in Inspector console:\n// playwright.locator('button').first().highlight()\n\n// Debug: Get element info\nconst element = page.getByRole('button')\nconsole.log('Count:', await element.count())\nconsole.log('Visible:', await element.isVisible())\nconsole.log('Enabled:', await element.isEnabled())\n```\n\n### Frame Issues\n\n```typescript\n// Debug: List all frames\nfor (const frame of page.frames()) {\n  console.log('Frame:', frame.url())\n}\n\n// Debug: Check if element is in iframe\nconst frame = page.frameLocator('iframe').first()\nconsole.log(await frame.getByRole('button').count())\n```\n\n## Logging\n\n### Capture Browser Console\n\n```typescript\ntest('with logging', async ({page}) => {\n  page.on('console', (msg) => console.log('Browser:', msg.text()))\n  page.on('pageerror', (error) => console.log('Page error:', error.message))\n  await page.goto('/')\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Custom Test Attachments\n\n```typescript\ntest('with attachments', async ({page}, testInfo) => {\n  // Attach screenshot to report\n  const screenshot = await page.screenshot()\n  await testInfo.attach('screenshot', {\n    body: screenshot,\n    contentType: 'image/png',\n  })\n\n  // Attach logs or data\n  await testInfo.attach('logs', {\n    body: 'Custom log data',\n    contentType: 'text/plain',\n  })\n\n  // Use testInfo for output paths\n  const outputPath = testInfo.outputPath('debug-file.json')\n})\n```\n\n## Troubleshooting Checklist\n\n### By Symptom\n\n| Symptom                                       | Common Causes                                                | Quick Fixes                                                         | Reference                                                                                  |\n| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| **Element not found**                         | Wrong selector, element not visible, in iframe, timing issue | Check locator with Inspector, wait for visibility, use frameLocator | [locators.md](../core/locators.md), [assertions-waiting.md](../core/assertions-waiting.md) |\n| **Timeout errors**                            | Slow network, heavy page load, waiting for wrong condition   | Increase timeout, wait for specific response, check network tab     | [assertions-waiting.md](../core/assertions-waiting.md)                                     |\n| **Flaky tests**                               | Race conditions, shared state, timing dependencies           | See comprehensive flaky test guide                                  | [flaky-tests.md](flaky-tests.md)                                                           |\n| **Tests pass locally, fail in CI**            | Environment differences, missing dependencies, timing        | Simulate CI locally, check CI logs, verify environment vars         | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md), [flaky-tests.md](flaky-tests.md)             |\n| **Slow test execution**                       | Not parallelized, heavy network calls, unnecessary waits     | Enable parallelization, mock APIs, optimize waits                   | [performance.md](../infrastructure-ci-cd/performance.md)                                   |\n| **Selector works in browser but not in test** | Element not attached, wrong context, dynamic content         | Use auto-waiting, check iframe, verify element state                | [locators.md](../core/locators.md)                                                         |\n| **Test fails on retry**                       | Non-deterministic data, external dependencies                | Use test data fixtures, mock external services                      | [fixtures-hooks.md](../core/fixtures-hooks.md)                                             |\n\n### Step-by-Step Debugging Process\n\n1. **Reproduce the issue**\n\n   ```bash\n   # Run with trace enabled\n   npx playwright test tests/failing.spec.ts --trace on\n\n   # If intermittent, run multiple times\n   npx playwright test --repeat-each=10\n   ```\n\n2. **Inspect the failure**\n\n   ```bash\n   # View trace\n   npx playwright show-trace test-results/path-to-trace.zip\n\n   # Run in headed mode to watch\n   npx playwright test --headed\n\n   # Use inspector for step-by-step\n   PWDEBUG=1 npx playwright test\n   ```\n\n3. **Isolate the problem**\n\n   ```typescript\n   // Add debugging points\n   await page.pause()\n\n   // Log element state\n   console.log('Element count:', await page.getByRole('button').count())\n   console.log('Element visible:', await page.getByRole('button').isVisible())\n\n   // Take screenshot at failure point\n   await page.screenshot({path: 'debug.png'})\n   ```\n\n4. **Check related areas**\n   - Network requests: Are API calls completing? (see [Debugging Network Issues](#debugging-network-issues))\n   - Timing: Is auto-waiting working correctly?\n   - State: Is the test isolated? (see [flaky-tests.md](flaky-tests.md))\n   - Environment: Does it work locally but fail in CI? (see [Debugging in CI](#debugging-in-ci))\n\n5. **Apply fix and verify**\n   - Fix the root cause (not just symptoms)\n   - Run multiple times to confirm stability: `--repeat-each=10`\n   - Check related tests aren't affected\n\n## Related References\n\n- **Flaky tests**: See [flaky-tests.md](flaky-tests.md) for comprehensive flaky test guide\n- **Locator issues**: See [locators.md](../core/locators.md) for selector strategies\n- **Waiting problems**: See [assertions-waiting.md](../core/assertions-waiting.md) for waiting patterns\n- **Test isolation**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for fixtures and isolation\n- **CI issues**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI configuration\n",".agents/skills/playwright-best-practices/debugging/error-testing.md":"# Error & Edge Case Testing\n\n## Table of Contents\n\n1. [Error Boundaries](#error-boundaries)\n2. [Network Failures](#network-failures)\n3. [Offline Testing](#offline-testing)\n4. [Loading States](#loading-states)\n5. [Form Validation](#form-validation)\n\n## Error Boundaries\n\n### Test Component Errors\n\n```typescript\ntest('error boundary catches component error', async ({page}) => {\n  // Trigger error via mock\n  await page.route('**/api/user', (route) => {\n    route.fulfill({\n      json: null, // Will cause component to throw\n    })\n  })\n\n  await page.goto('/profile')\n\n  // Error boundary should render fallback\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Try Again'})).toBeVisible()\n})\n```\n\n### Test Error Recovery\n\n```typescript\ntest('recover from error state', async ({page}) => {\n  let requestCount = 0\n\n  await page.route('**/api/data', (route) => {\n    requestCount++\n    if (requestCount === 1) {\n      return route.fulfill({status: 500})\n    }\n    return route.fulfill({\n      json: {data: 'success'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Error state\n  await expect(page.getByText('Failed to load')).toBeVisible()\n\n  // Retry\n  await page.getByRole('button', {name: 'Retry'}).click()\n\n  // Success state\n  await expect(page.getByText('success')).toBeVisible()\n})\n```\n\n### Test JavaScript Errors\n\n```typescript\ntest('handles runtime error gracefully', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push(error.message)\n  })\n\n  await page.goto('/buggy-page')\n\n  // App should still be functional despite error\n  await expect(page.getByRole('navigation')).toBeVisible()\n\n  // Error was logged\n  expect(errors.length).toBeGreaterThan(0)\n})\n```\n\n## Network Failures\n\n### Test API Errors\n\n```typescript\ntest.describe('API error handling', () => {\n  const errorCodes = [400, 401, 403, 404, 500, 502, 503]\n\n  for (const status of errorCodes) {\n    test(`handles ${status} error`, async ({page}) => {\n      await page.route('**/api/data', (route) =>\n        route.fulfill({\n          status,\n          json: {error: `Error ${status}`},\n        }),\n      )\n\n      await page.goto('/dashboard')\n\n      // Appropriate error message shown\n      await expect(page.getByRole('alert')).toBeVisible()\n    })\n  }\n})\n```\n\n### Test Timeout\n\n```typescript\ntest('handles request timeout', async ({page}) => {\n  await page.route('**/api/slow', async (route) => {\n    // Never respond - simulates timeout\n    await new Promise(() => {})\n  })\n\n  await page.goto('/slow-page')\n\n  // Should show timeout message (app should have its own timeout)\n  await expect(page.getByText('Request timed out')).toBeVisible({\n    timeout: 15000,\n  })\n})\n```\n\n### Test Connection Reset\n\n```typescript\ntest('handles connection failure', async ({page}) => {\n  await page.route('**/api/data', (route) => {\n    route.abort('connectionfailed')\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByText('Connection failed')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Test Mid-Request Failure\n\n```typescript\ntest('handles failure during request', async ({page}) => {\n  let requestStarted = false\n\n  await page.route('**/api/upload', async (route) => {\n    requestStarted = true\n    // Abort after small delay (mid-request)\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.abort('failed')\n  })\n\n  await page.goto('/upload')\n  await page.getByLabel('File').setInputFiles('./fixtures/large-file.pdf')\n  await page.getByRole('button', {name: 'Upload'}).click()\n\n  // Should show failure, not hang\n  await expect(page.getByText('Upload failed')).toBeVisible()\n  expect(requestStarted).toBe(true)\n})\n```\n\n## Offline Testing\n\nThis section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing).\n\n### Go Offline During Session\n\n```typescript\ntest('handles going offline', async ({page, context}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByTestId('data')).toBeVisible()\n\n  // Go offline unexpectedly\n  await context.setOffline(true)\n\n  // Try to refresh data\n  await page.getByRole('button', {name: 'Refresh'}).click()\n\n  // Should show offline indicator\n  await expect(page.getByText(\"You're offline\")).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n\n  // Should recover\n  await page.getByRole('button', {name: 'Refresh'}).click()\n  await expect(page.getByText(\"You're offline\")).toBeHidden()\n})\n```\n\n### Test Network Recovery\n\n```typescript\ntest('recovers gracefully when connection returns', async ({page, context}) => {\n  await page.goto('/dashboard')\n\n  // Simulate connection drop\n  await context.setOffline(true)\n\n  // App should show degraded state\n  await expect(page.getByRole('alert')).toContainText(/offline|connection/i)\n\n  // Connection restored\n  await context.setOffline(false)\n\n  // Retry should work\n  await page.getByRole('button', {name: 'Retry'}).click()\n  await expect(page.getByTestId('data')).toBeVisible()\n})\n```\n\n## Loading States\n\n### Test Skeleton Loaders\n\n```typescript\ntest('shows skeleton during load', async ({page}) => {\n  // Add delay to API response\n  await page.route('**/api/posts', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 1000))\n    route.fulfill({\n      json: [{id: 1, title: 'Post 1'}],\n    })\n  })\n\n  await page.goto('/posts')\n\n  // Skeleton should appear immediately\n  await expect(page.getByTestId('skeleton')).toBeVisible()\n\n  // Then content replaces skeleton\n  await expect(page.getByText('Post 1')).toBeVisible()\n  await expect(page.getByTestId('skeleton')).toBeHidden()\n})\n```\n\n### Test Loading Indicators\n\n```typescript\ntest('shows loading state for actions', async ({page}) => {\n  await page.route('**/api/save', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.fulfill({json: {success: true}})\n  })\n\n  await page.goto('/editor')\n  await page.getByLabel('Content').fill('New content')\n\n  const saveButton = page.getByRole('button', {name: 'Save'})\n  await saveButton.click()\n\n  // Button should show loading state\n  await expect(saveButton).toBeDisabled()\n  await expect(page.getByTestId('spinner')).toBeVisible()\n\n  // Then success state\n  await expect(saveButton).toBeEnabled()\n  await expect(page.getByText('Saved')).toBeVisible()\n})\n```\n\n### Test Empty States\n\n```typescript\ntest('shows empty state when no data', async ({page}) => {\n  await page.route('**/api/items', (route) => route.fulfill({json: []}))\n\n  await page.goto('/items')\n\n  await expect(page.getByText('No items yet')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Create First Item'})).toBeVisible()\n})\n```\n\n## Form Validation\n\n### Test Client-Side Validation\n\n```typescript\ntest('validates required fields', async ({page}) => {\n  await page.goto('/signup')\n\n  // Submit empty form\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Should show validation errors\n  await expect(page.getByText('Email is required')).toBeVisible()\n  await expect(page.getByText('Password is required')).toBeVisible()\n\n  // Form should not submit\n  await expect(page).toHaveURL('/signup')\n})\n```\n\n### Test Format Validation\n\n```typescript\ntest('validates email format', async ({page}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Email').fill('invalid-email')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeVisible()\n\n  // Fix the error\n  await page.getByLabel('Email').fill('valid@email.com')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeHidden()\n})\n```\n\n### Test Server-Side Validation\n\n```typescript\ntest('handles server validation errors', async ({page}) => {\n  await page.route('**/api/register', (route) =>\n    route.fulfill({\n      status: 422,\n      json: {\n        errors: {\n          email: 'Email already exists',\n          username: 'Username is taken',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('taken@email.com')\n  await page.getByLabel('Username').fill('takenuser')\n  await page.getByLabel('Password').fill('password123')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Server errors should display\n  await expect(page.getByText('Email already exists')).toBeVisible()\n  await expect(page.getByText('Username is taken')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                               |\n| ------------------------ | ------------------------------ | -------------------------------------- |\n| Only testing happy path  | Misses error handling bugs     | Test all error scenarios               |\n| No network failure tests | App crashes on poor connection | Test offline/slow/failed requests      |\n| Skipping loading states  | Janky UX not caught            | Assert loading UI appears              |\n| Ignoring validation      | Form bugs slip through         | Test both client and server validation |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for mock patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for error assertions\n",".agents/skills/playwright-best-practices/debugging/flaky-tests.md":"# Debugging and Managing Flaky Tests\n\n## Table of Contents\n\n1. [Understanding Flakiness Types](#understanding-flakiness-types)\n2. [Detection and Reproduction](#detection-and-reproduction)\n3. [Root Cause Analysis](#root-cause-analysis)\n4. [Fixing Strategies by Type](#fixing-strategies-by-type)\n5. [CI-Specific Flakiness](#ci-specific-flakiness)\n6. [Quarantine and Management](#quarantine-and-management)\n7. [Prevention Strategies](#prevention-strategies)\n\n## Understanding Flakiness Types\n\n### Categories of Flakiness\n\nMost flaky tests fall into distinct categories requiring different remediation:\n\n| Category                    | Symptoms                        | Common Causes                                          |\n| --------------------------- | ------------------------------- | ------------------------------------------------------ |\n| **UI-driven**               | Element not found, click missed | Missing waits, animations, dynamic rendering           |\n| **Environment-driven**      | CI-only failures                | Slower CPU, memory limits, cold browser starts         |\n| **Data/parallelism-driven** | Fails with multiple workers     | Shared backend data, reused accounts, state collisions |\n| **Test-suite-driven**       | Fails when run with other tests | Leaked state, shared fixtures, order dependencies      |\n\n### Flakiness Decision Tree\n\n```\nTest fails intermittently\n├─ Fails locally too?\n│  ├─ YES → Timing/async issue → Check waits and assertions\n│  └─ NO → CI-specific → Check environment differences\n│\n├─ Fails only with multiple workers?\n│  └─ YES → Parallelism issue → Check data isolation\n│\n├─ Fails only when run after specific tests?\n│  └─ YES → State leak → Check fixtures and cleanup\n│\n└─ Fails randomly regardless of conditions?\n   └─ External dependency → Check network/API stability\n```\n\n## Detection and Reproduction\n\n### Confirming Flakiness\n\n```bash\n# Run test multiple times to confirm instability\nnpx playwright test tests/checkout.spec.ts --repeat-each=20\n\n# Run with single worker to isolate parallelism issues\nnpx playwright test --workers=1\n\n# Run in CI-like conditions locally\nCI=true npx playwright test --repeat-each=10\n```\n\n### Reproduction Strategies\n\n```typescript\n// playwright.config.ts - Enable artifacts for flaky test investigation\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0,\n  use: {\n    trace: 'on-first-retry', // Capture trace on retry\n    video: 'retain-on-failure',\n    screenshot: 'only-on-failure',\n  },\n})\n```\n\n### Identify Flaky Tests Programmatically\n\n```typescript\n// Track test results across runs\ntest.afterEach(async ({}, testInfo) => {\n  if (testInfo.retry > 0 && testInfo.status === 'passed') {\n    console.warn(`FLAKY: ${testInfo.title} passed on retry ${testInfo.retry}`)\n    // Log to your tracking system\n  }\n})\n```\n\n## Root Cause Analysis\n\n### Event Logging for Race Conditions\n\nAdd comprehensive event logging to expose timing issues:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  page.on('console', (msg) => console.log(`CONSOLE [${msg.type()}]:`, msg.text()))\n  page.on('pageerror', (err) => console.error('PAGE ERROR:', err.message))\n  page.on('requestfailed', (req) => console.error(`REQUEST FAILED: ${req.url()}`))\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Network Timing Analysis\n\n```typescript\n// Capture slow or failed requests\ntest.beforeEach(async ({page}) => {\n  const slowRequests: string[] = []\n\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const duration = timing.responseEnd - timing.requestStart\n    if (duration > 2000) {\n      slowRequests.push(`${request.url()} took ${duration}ms`)\n    }\n  })\n\n  page.on('requestfailed', (request) => {\n    console.error(`Failed: ${request.url()} - ${request.failure()?.errorText}`)\n  })\n})\n```\n\n### Trace Analysis\n\n```bash\n# View trace from failed CI run\nnpx playwright show-trace path/to/trace.zip\n\n# Generate trace for specific test\nnpx playwright test tests/flaky.spec.ts --trace on\n```\n\n## Fixing Strategies by Type\n\n### UI-Driven Flakiness\n\n**Problem: Element not ready when action executes**\n\n```typescript\n// ❌ BAD: No wait for element state\nawait page.click('#submit')\nawait page.fill('#username', 'test') // Element may not be ready\n\n// ✅ GOOD: Actions + assertions pattern (auto-waiting built-in)\nawait page.getByRole('button', {name: 'Submit'}).click()\nawait expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n```\n\n**Problem: Animations or transitions interfere**\n\n```typescript\n// ❌ BAD: Click during animation\nawait page.click('.menu-item')\n\n// ✅ GOOD: Wait for animation to complete\nawait page.getByRole('menuitem', {name: 'Settings'}).click()\nawait expect(page.getByRole('dialog')).toBeVisible()\n// Or disable animations entirely\nawait page.emulateMedia({reducedMotion: 'reduce'})\n```\n\n**Problem: Brittle selectors**\n\n```typescript\n// ❌ BAD: Fragile CSS chain\nawait page.click('div.container > div:nth-child(2) > button.btn-primary')\n\n// ✅ GOOD: Semantic selectors\nawait page.getByRole('button', {name: 'Continue'}).click()\nawait page.getByTestId('checkout-button').click()\nawait page.getByLabel('Email address').fill('test@example.com')\n```\n\n### Async/Timing Flakiness\n\n**Problem: Race between test and application**\n\n```typescript\n// ❌ BAD: Arbitrary sleep\nawait page.click('#load-data')\nawait page.waitForTimeout(3000) // Hope data loads in 3s\n\n// ✅ GOOD: Wait for specific condition\nawait page.click('#load-data')\nawait expect(page.locator('.data-row')).toHaveCount(10, {timeout: 10000})\n\n// ✅ BETTER: Wait for network response, then assert\nconst responsePromise = page.waitForResponse(\n  (r) => r.url().includes('/api/data') && r.request().method() === 'GET' && r.ok(),\n)\nawait page.click('#load-data')\nawait responsePromise\nawait expect(page.locator('.data-row')).toHaveCount(10)\n```\n\n> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n**Problem: Complex async state**\n\n```typescript\n// Custom wait for application-specific conditions\nawait page.waitForFunction(() => {\n  const app = (window as any).__APP_STATE__\n  return app?.isReady && !app?.isLoading\n})\n\n// Wait for multiple conditions\nawait Promise.all([\n  page.waitForResponse('**/api/user'),\n  page.waitForResponse('**/api/settings'),\n  page.getByRole('button', {name: 'Load'}).click(),\n])\n```\n\n### Data/Parallelism-Driven Flakiness\n\n**Problem: Tests share backend data**\n\n```typescript\n// ❌ BAD: All workers use same user\nconst testUser = {email: 'test@example.com', password: 'pass123'}\n\n// ✅ GOOD: Unique data per worker\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend<{}, {testUser: {email: string; id: string}}>({\n  testUser: [\n    async ({}, use, workerInfo) => {\n      const email = `test-${workerInfo.workerIndex}-${Date.now()}@example.com`\n      const user = await createTestUser(email)\n      await use(user)\n      await deleteTestUser(user.id)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n**Problem: Shared storageState across workers**\n\n```typescript\n// ❌ BAD: All workers share same auth state\nuse: {\n  storageState: '.auth/user.json',\n}\n\n// ✅ GOOD: Per-worker auth state\nexport const test = base.extend<{}, { workerStorageState: string }>({\n  workerStorageState: [\n    async ({ browser }, use, workerInfo) => {\n      const id = workerInfo.workerIndex;\n      const fileName = `.auth/user-${id}.json`;\n\n      if (!fs.existsSync(fileName)) {\n        const page = await browser.newPage({ storageState: undefined });\n        await authenticateUser(page, `worker${id}@test.com`);\n        await page.context().storageState({ path: fileName });\n        await page.close();\n      }\n\n      await use(fileName);\n    },\n    { scope: \"worker\" },\n  ],\n});\n```\n\n### Test-Suite-Driven Flakiness (State Leaks)\n\n**Problem: Tests affect each other**\n\n```typescript\n// ❌ BAD: Module-level state persists across tests\nlet sharedPage: Page\n\ntest.beforeAll(async ({browser}) => {\n  sharedPage = await browser.newPage() // Shared across tests!\n})\n\n// ✅ GOOD: Use Playwright's default isolation (fresh context per test)\ntest('first test', async ({page}) => {\n  // Fresh page for this test\n})\n\ntest('second test', async ({page}) => {\n  // Fresh page for this test\n})\n```\n\n**Problem: Fixture cleanup not happening**\n\n```typescript\n// ✅ GOOD: Proper fixture with cleanup\nexport const test = base.extend<{tempFile: string}>({\n  tempFile: async ({}, use) => {\n    const file = `/tmp/test-${Date.now()}.json`\n    fs.writeFileSync(file, '{}')\n\n    await use(file)\n\n    // Cleanup always runs, even on failure\n    if (fs.existsSync(file)) {\n      fs.unlinkSync(file)\n    }\n  },\n})\n```\n\n## CI-Specific Flakiness\n\n### Why Tests Fail Only in CI\n\n| CI Condition       | Impact                                | Solution                                             |\n| ------------------ | ------------------------------------- | ---------------------------------------------------- |\n| Slower CPU         | Actions complete later than expected  | Use auto-waiting, not timeouts                       |\n| Cold browser start | No cached assets, slower initial load | Add explicit waits for first navigation              |\n| Headless mode      | Different rendering behavior          | Test locally in headless mode                        |\n| Shared runners     | Resource contention                   | Reduce parallelism or use dedicated runners          |\n| Network latency    | API calls slower                      | Mock external APIs, increase timeouts for real calls |\n\n### Simulating CI Locally\n\n```bash\n# Run headless with CI environment variable\nCI=true npx playwright test\n\n# Limit CPU (Linux/Mac)\ncpulimit -l 50 -- npx playwright test\n\n# Run in Docker matching CI environment\ndocker run -it --rm \\\n  -v $(pwd):/work \\\n  -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### Consistent Viewport and Scale\n\n```typescript\n// playwright.config.ts - Match CI rendering exactly\nexport default defineConfig({\n  use: {\n    viewport: {width: 1280, height: 720},\n    deviceScaleFactor: 1,\n  },\n})\n```\n\n### Network Stubbing for External APIs\n\n```typescript\n// Eliminate external API flakiness\ntest.beforeEach(async ({page}) => {\n  // Stub unstable third-party APIs\n  await page.route('**/api.analytics.com/**', (route) => route.fulfill({body: ''}))\n  await page.route('**/api.payment-provider.com/**', (route) =>\n    route.fulfill({json: {status: 'ok'}}),\n  )\n})\n\n// Test-specific stub\ntest('checkout with payment', async ({page}) => {\n  await page.route('**/api/payment', (route) =>\n    route.fulfill({json: {success: true, transactionId: 'test-123'}}),\n  )\n  // Test proceeds with deterministic response\n})\n```\n\n## Quarantine and Management\n\n### Quarantine Pattern\n\n```typescript\n// playwright.config.ts - Separate flaky tests\nexport default defineConfig({\n  projects: [\n    {\n      name: 'stable',\n      testIgnore: ['**/*.flaky.spec.ts'],\n    },\n    {\n      name: 'quarantine',\n      testMatch: ['**/*.flaky.spec.ts'],\n      retries: 3,\n    },\n  ],\n})\n```\n\n### Annotation-Based Quarantine\n\n```typescript\n// Mark flaky tests with annotations\ntest('intermittent checkout issue', async ({page}, testInfo) => {\n  testInfo.annotations.push({\n    type: 'flaky',\n    description: 'Investigating payment API timing - JIRA-1234',\n  })\n\n  // Test implementation\n})\n\n// Skip flaky test conditionally\ntest('known CI flaky', async ({page}) => {\n  test.skip(!!process.env.CI, 'Flaky in CI - investigating JIRA-5678')\n  // Test implementation\n})\n```\n\n## Prevention Strategies\n\n### Test Burn-In\n\n```bash\n# Run new tests many times before merging\nnpx playwright test tests/new-feature.spec.ts --repeat-each=50\n\n# Run in parallel to expose race conditions\nnpx playwright test tests/new-feature.spec.ts --repeat-each=20 --workers=4\n```\n\n### Isolation Checklist\n\n```typescript\n// ✅ Each test should be self-contained\ntest.describe('User profile', () => {\n  test('can update name', async ({page, testUser}) => {\n    // Uses unique testUser fixture\n    // No dependency on other tests\n    // Cleanup handled by fixture\n  })\n\n  test('can update email', async ({page, testUser}) => {\n    // Independent of \"can update name\"\n    // Own testUser, own state\n  })\n})\n```\n\n### Defensive Assertions\n\n```typescript\n// ❌ BAD: Single point of failure\nawait expect(page.locator('.items')).toHaveCount(5)\n\n// ✅ GOOD: Progressive assertions that help diagnose\nawait expect(page.locator('.items-container')).toBeVisible()\nawait expect(page.locator('.loading')).not.toBeVisible()\nawait expect(page.locator('.items')).toHaveCount(5)\n```\n\n### Retry Budget\n\n```typescript\n// playwright.config.ts - Limit retries to avoid masking issues\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0, // Only retry in CI\n  expect: {\n    timeout: 10000, // Reasonable assertion timeout\n  },\n  timeout: 60000, // Test timeout\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                             | Solution                                       |\n| ----------------------------------------- | ----------------------------------- | ---------------------------------------------- |\n| `waitForTimeout()` as primary wait        | Arbitrary, hides real timing issues | Use auto-waiting assertions                    |\n| Increasing global timeout to \"fix\" flakes | Masks root cause, slows all tests   | Find and fix actual timing issue               |\n| Retrying until pass                       | Hides systemic problems             | Fix root cause, use retries for diagnosis only |\n| Shared test data across workers           | Race conditions, collisions         | Isolate data per worker                        |\n| Testing real external APIs                | Network variability                 | Mock external dependencies                     |\n| Module-level mutable state                | Leaks between tests                 | Use fixtures with proper cleanup               |\n| Ignoring flaky tests                      | Problem compounds over time         | Quarantine and track for fixing                |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for trace viewer and inspector\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped isolation\n- **Performance**: See [performance.md](../infrastructure-ci-cd/performance.md) for parallel execution patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for auto-waiting patterns\n- **Global Setup**: See [global-setup.md](../core/global-setup.md) for setup vs fixtures decision\n",".agents/skills/playwright-best-practices/frameworks/angular.md":"# Angular Testing with Playwright\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [Locator Strategies](#locator-strategies)\n3. [Reactive Forms](#reactive-forms)\n4. [Angular Material Components](#angular-material-components)\n5. [Router Navigation](#router-navigation)\n6. [Lazy-Loaded Modules](#lazy-loaded-modules)\n7. [Signals and Observables](#signals-and-observables)\n8. [Zone.js and Change Detection](#zonejs-and-change-detection)\n9. [SSR Testing](#ssr-testing)\n10. [Protractor Migration Reference](#protractor-migration-reference)\n11. [Build Configurations](#build-configurations)\n12. [CDK Overlay Container](#cdk-overlay-container)\n13. [Anti-Patterns](#anti-patterns)\n14. [Related](#related)\n\n> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.\n> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)\n\n## Configuration\n\n### Playwright Config\n\n```typescript\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:4200',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI\n      ? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'\n      : 'npx ng serve',\n    url: 'http://localhost:4200',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Project Structure\n\n```text\nmy-angular-app/\n  src/\n  e2e/\n    tests/\n      dashboard.spec.ts\n      login.spec.ts\n    fixtures/\n      auth.fixture.ts\n  playwright.config.ts\n  angular.json\n```\n\n### Package Scripts\n\n```json\n{\n  \"scripts\": {\n    \"e2e\": \"playwright test\",\n    \"e2e:headed\": \"playwright test --headed\",\n    \"e2e:debug\": \"playwright test --debug\",\n    \"e2e:report\": \"playwright show-report\"\n  }\n}\n```\n\n## Locator Strategies\n\nAngular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.\n\n```typescript\ntest('use semantic locators for Angular apps', async ({page}) => {\n  await page.goto('/projects')\n\n  // Role-based locators work with Angular Material and native HTML\n  await page.getByRole('button', {name: 'New project'}).click()\n  await expect(page.getByRole('heading', {name: 'Create Project'})).toBeVisible()\n\n  // Label-based for form fields\n  await page.getByLabel('Project title').fill('Alpha')\n\n  // Test IDs for complex components without semantic roles\n  const chart = page.getByTestId('metrics-chart')\n  await expect(chart).toBeVisible()\n\n  // Scope locators within component boundaries\n  const projectTable = page.getByRole('table', {name: 'Projects'})\n  const activeRow = projectTable.getByRole('row').filter({\n    has: page.getByRole('cell', {name: 'Active'}),\n  })\n  await activeRow.getByRole('button', {name: 'Edit'}).click()\n})\n```\n\n## Reactive Forms\n\nPlaywright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.\n\n```typescript\ntest.describe('form validation', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('displays validation errors on blur', async ({page}) => {\n    const emailField = page.getByLabel('Email')\n    await emailField.click()\n    await emailField.blur()\n    await expect(page.getByText('Email is required')).toBeVisible()\n\n    await emailField.fill('invalid')\n    await emailField.blur()\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('validates password confirmation', async ({page}) => {\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Mismatch')\n    await page.getByLabel('Confirm password').blur()\n\n    await expect(page.getByText('Passwords must match')).toBeVisible()\n\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await expect(page.getByText('Passwords must match')).toBeHidden()\n  })\n\n  test('handles FormArray add/remove', async ({page}) => {\n    await page.goto('/contacts/edit')\n\n    await page.getByRole('button', {name: 'Add email'}).click()\n    const emailInputs = page.getByLabel(/Email address/)\n    await expect(emailInputs).toHaveCount(2)\n\n    await emailInputs.nth(1).fill('backup@example.com')\n    await page.getByRole('button', {name: 'Remove email 1'}).click()\n\n    await expect(emailInputs).toHaveCount(1)\n    await expect(emailInputs.first()).toHaveValue('backup@example.com')\n  })\n\n  test('disables submit until form is valid', async ({page}) => {\n    const submitBtn = page.getByRole('button', {name: 'Register'})\n    await expect(submitBtn).toBeDisabled()\n\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await page.getByLabel('Accept terms').check()\n\n    await expect(submitBtn).toBeEnabled()\n  })\n\n  test('shows async validator loading state', async ({page}) => {\n    await page.route('**/api/username-check*', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({json: {available: true}})\n    })\n\n    await page.getByLabel('Username').fill('alice')\n    await page.getByLabel('Username').blur()\n\n    await expect(page.getByTestId('username-loading')).toBeVisible()\n    await expect(page.getByTestId('username-loading')).toBeHidden()\n    await expect(page.getByText('Username available')).toBeVisible()\n  })\n})\n```\n\n## Angular Material Components\n\nAngular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.\n\n```typescript\ntest.describe('Material components', () => {\n  test('mat-select dropdown', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Language'}).click()\n    await page.getByRole('option', {name: 'Spanish'}).click()\n\n    await expect(page.getByRole('combobox', {name: 'Language'})).toContainText('Spanish')\n  })\n\n  test('mat-autocomplete suggestions', async ({page}) => {\n    await page.goto('/members/add')\n\n    const roleField = page.getByRole('combobox', {name: 'Role'})\n    await roleField.fill('dev')\n\n    await expect(page.getByRole('option', {name: 'Developer'})).toBeVisible()\n    await expect(page.getByRole('option', {name: 'DevOps'})).toBeVisible()\n\n    await page.getByRole('option', {name: 'Developer'}).click()\n    await expect(roleField).toHaveValue('Developer')\n  })\n\n  test('mat-dialog interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove item'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByText('Confirm deletion?')).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('mat-table sorting', async ({page}) => {\n    await page.goto('/members')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    const header = page.getByRole('columnheader', {name: 'Name'})\n    await expect(header).toHaveAttribute('aria-sort', 'ascending')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    await expect(header).toHaveAttribute('aria-sort', 'descending')\n  })\n\n  test('mat-paginator navigation', async ({page}) => {\n    await page.goto('/members')\n\n    await expect(page.getByText('1 - 10 of 100')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next page'}).click()\n    await expect(page.getByText('11 - 20 of 100')).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Items per page'}).click()\n    await page.getByRole('option', {name: '50'}).click()\n    await expect(page.getByText('1 - 50 of 100')).toBeVisible()\n  })\n\n  test('mat-snack-bar notification', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Changes saved')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Close'}).click()\n    await expect(page.getByText('Changes saved')).toBeHidden()\n  })\n\n  test('mat-stepper wizard', async ({page}) => {\n    await page.goto('/wizard')\n\n    await expect(page.getByText('Step 1 of 3')).toBeVisible()\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 2 of 3')).toBeVisible()\n    await page.getByLabel('Organization').fill('Acme')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 3 of 3')).toBeVisible()\n    await expect(page.getByText('Bob')).toBeVisible()\n    await expect(page.getByText('Acme')).toBeVisible()\n  })\n})\n```\n\n## Router Navigation\n\n```typescript\ntest.describe('Angular Router', () => {\n  test('lazy-loaded module loads on navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.getByRole('link', {name: 'Reports'}).click()\n    await page.waitForURL('/reports')\n\n    await expect(page.getByRole('heading', {name: 'Reports Dashboard'})).toBeVisible()\n  })\n\n  test('route guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/settings')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n  })\n\n  test('resolver prefetches data', async ({page}) => {\n    const resolverPromise = page.waitForResponse('**/api/items/*')\n    await page.goto('/items/42')\n    await resolverPromise\n\n    await expect(page.getByRole('heading', {level: 1})).toContainText('Item')\n  })\n\n  test('nested router-outlet renders children', async ({page}) => {\n    await page.goto('/account/profile')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Profile', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Security'}).click()\n    await page.waitForURL('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n  })\n\n  test('query parameters drive filters', async ({page}) => {\n    await page.goto('/products?type=hardware&page=3')\n\n    await expect(page.getByRole('heading', {name: 'Hardware'})).toBeVisible()\n    await expect(page.getByText('Page 3')).toBeVisible()\n  })\n\n  test('browser back navigates history', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Products'}).click()\n    await page.waitForURL('/products')\n    await page.getByRole('link', {name: 'About'}).click()\n    await page.waitForURL('/about')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/products/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n})\n```\n\n## Lazy-Loaded Modules\n\n```typescript\ntest('lazy module loads without chunk errors', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') consoleErrors.push(msg.text())\n  })\n\n  await page.goto('/')\n\n  const chunkRequest = page.waitForResponse((r) => r.url().includes('.js') && r.status() === 200)\n  await page.getByRole('link', {name: 'Analytics'}).click()\n  await chunkRequest\n\n  await page.waitForURL('/analytics')\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n\n  const chunkErrors = consoleErrors.filter(\n    (e) => e.includes('ChunkLoadError') || e.includes('Loading chunk'),\n  )\n  expect(chunkErrors).toEqual([])\n})\n```\n\n## Signals and Observables\n\nPlaywright cannot subscribe to observables or read signals directly. Test through the rendered output.\n\n```typescript\ntest.describe('signals through UI', () => {\n  test('signal-based counter updates DOM', async ({page}) => {\n    await page.goto('/counter')\n\n    await expect(page.getByTestId('value')).toHaveText('0')\n\n    await page.getByRole('button', {name: 'Increment'}).click()\n    await expect(page.getByTestId('value')).toHaveText('1')\n\n    await page.getByRole('button', {name: 'Reset'}).click()\n    await expect(page.getByTestId('value')).toHaveText('0')\n  })\n\n  test('computed signal updates derived values', async ({page}) => {\n    await page.goto('/cart')\n    await expect(page.getByTestId('total')).toHaveText('$0.00')\n\n    await page.goto('/catalog')\n    await page\n      .getByRole('listitem')\n      .filter({hasText: '$19.99'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await expect(page.getByTestId('total')).toHaveText('$19.99')\n  })\n})\n\ntest.describe('observables through UI', () => {\n  test('debounced search batches API calls', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/search*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('playwright', {\n      delay: 50,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(5)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n\n  test('switchMap cancels stale requests', async ({page}) => {\n    await page.goto('/search')\n\n    await page.getByRole('textbox', {name: 'Search'}).fill('initial')\n    await page.getByRole('textbox', {name: 'Search'}).fill('final')\n\n    await expect(page.getByRole('listitem').first()).toContainText(/final/i)\n  })\n})\n```\n\n## Zone.js and Change Detection\n\nAngular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.\n\n- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.\n- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.\n- **Long-running async**: `setInterval` or long-running observables keep Angular \"not stable.\" This does not affect Playwright (unlike Protractor).\n\n## SSR Testing\n\n```typescript\n// playwright.config.ts for SSR\nwebServer: {\n  command: process.env.CI\n    ? 'npx ng build --ssr && node dist/my-app/server/server.mjs'\n    : 'npx ng serve --ssr',\n  url: 'http://localhost:4200',\n  reuseExistingServer: !process.env.CI,\n  timeout: 180_000,\n},\n```\n\n```typescript\ntest('no hydration errors', async ({page}) => {\n  const errors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error' && msg.text().includes('hydration')) {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  expect(errors).toEqual([])\n})\n```\n\n## Protractor Migration Reference\n\n| Protractor                              | Playwright                                     |\n| --------------------------------------- | ---------------------------------------------- |\n| `element(by.css('.btn'))`               | `page.getByRole('button', { name: '...' })`    |\n| `element(by.id('login'))`               | `page.getByTestId('login')`                    |\n| `element(by.buttonText('Submit'))`      | `page.getByRole('button', { name: 'Submit' })` |\n| `element(by.model('user.name'))`        | `page.getByLabel('Name')`                      |\n| `element(by.binding('user.name'))`      | `page.getByText(expectedValue)`                |\n| `element(by.repeater('item in items'))` | `page.getByRole('listitem')`                   |\n| `browser.waitForAngular()`              | Not needed — Playwright auto-waits             |\n| `browser.sleep(3000)`                   | `await expect(locator).toBeVisible()`          |\n| `browser.get('/path')`                  | `await page.goto('/path')`                     |\n| `protractor.ExpectedConditions`         | `await expect(locator).toBeVisible()`          |\n\n## Build Configurations\n\n| Scenario      | Command                                                       | Notes                          |\n| ------------- | ------------------------------------------------------------- | ------------------------------ |\n| Local dev     | `npx ng serve`                                                | Fast rebuild, source maps      |\n| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle        |\n| CI SSR        | `npx ng build --ssr && node dist/app/server/server.mjs`       | Tests server-side rendering    |\n| Staging       | No `webServer`                                                | Point `baseURL` to staging URL |\n\nThe `-s` flag on `http-server` enables SPA fallback for Angular Router.\n\n## CDK Overlay Container\n\nAngular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:\n\n```typescript\nconst dialog = page.getByRole('dialog')\nconst menu = page.getByRole('menu')\nconst listbox = page.getByRole('listbox')\n```\n\n## Anti-Patterns\n\n| Anti-Pattern                         | Problem                                        | Solution                                           |\n| ------------------------------------ | ---------------------------------------------- | -------------------------------------------------- |\n| `page.locator('[_ngcontent-xyz]')`   | Scoped style attributes change every build     | Use `getByRole`, `getByLabel`, `getByTestId`       |\n| `page.locator('[ng-reflect-model]')` | Only exists in dev mode                        | Test rendered value: `expect(input).toHaveValue()` |\n| `page.locator('app-my-component')`   | Component selectors are implementation details | Target rendered content with semantic locators     |\n| `page.locator('.mat-mdc-button')`    | Material classes change between versions       | `page.getByRole('button', { name: '...' })`        |\n| `page.evaluate(() => window.ng)`     | Not available in production builds             | Test through the DOM                               |\n| `await page.waitForTimeout(500)`     | Zone.js timing varies                          | Use auto-retrying assertions                       |\n| `browser.waitForAngular()`           | Does not exist in Playwright                   | Remove entirely                                    |\n| `ng serve` in CI                     | Slower, includes debug code                    | Use `ng build && http-server`                      |\n\n## Related\n\n- [core/locators.md](../core/locators.md) — locator strategies for Angular Material\n- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions\n- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed\n",".agents/skills/playwright-best-practices/frameworks/nextjs.md":"# Next.js Testing Patterns\n\n## Table of Contents\n\n1. [Setup](#setup)\n2. [App Router Patterns](#app-router-patterns)\n3. [Pages Router Patterns](#pages-router-patterns)\n4. [Dynamic Routes](#dynamic-routes)\n5. [API Routes](#api-routes)\n6. [Middleware Testing](#middleware-testing)\n7. [Hydration Testing](#hydration-testing)\n8. [next/image Testing](#nextimage-testing)\n9. [NextAuth.js Authentication](#nextauthjs-authentication)\n10. [Tips](#tips)\n11. [Anti-Patterns](#anti-patterns)\n12. [Related](#related)\n\n> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Setup\n\n### Configuration with webServer\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: process.env.CI ? 'production' : 'test',\n    },\n  },\n})\n```\n\n### Environment Variables\n\nNext.js loads `.env.test` when `NODE_ENV=test`:\n\n```bash\n# .env.test (commit this)\nNEXT_PUBLIC_API_URL=http://localhost:3000/api\nDATABASE_URL=postgresql://localhost:5432/test_db\n\n# .env.test.local (gitignored)\nNEXTAUTH_SECRET=test-secret-local\n```\n\n## App Router Patterns\n\n### Server Component Content\n\n```typescript\ntest('renders server component content', async ({page}) => {\n  await page.goto('/')\n\n  await expect(page.getByRole('heading', {name: 'Welcome', level: 1})).toBeVisible()\n  await expect(page.getByRole('navigation', {name: 'Main'})).toBeVisible()\n})\n```\n\n### Loading States with Streaming\n\n```typescript\ntest('loading state during data streaming', async ({page}) => {\n  await page.route('**/api/stats', async (route) => {\n    await new Promise((r) => setTimeout(r, 2000))\n    await route.continue()\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('progressbar')).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByRole('progressbar')).toBeHidden()\n})\n```\n\n### Nested Layouts\n\n```typescript\ntest('layouts persist across navigation', async ({page}) => {\n  await page.goto('/dashboard/analytics')\n\n  const sidebar = page.getByRole('navigation', {name: 'Dashboard'})\n  await expect(sidebar).toBeVisible()\n\n  await sidebar.getByRole('link', {name: 'Settings'}).click()\n  await page.waitForURL('/dashboard/settings')\n\n  await expect(sidebar).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible()\n})\n```\n\n## Pages Router Patterns\n\n### SSR with getServerSideProps\n\n```typescript\ntest('page with getServerSideProps renders data', async ({page}) => {\n  await page.goto('/blog')\n\n  await expect(page.getByRole('heading', {name: 'Blog', level: 1})).toBeVisible()\n  await expect(page.getByRole('article')).toHaveCount(10)\n  await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n})\n```\n\n### Static Generation with getStaticProps\n\n```typescript\ntest('static page shows pre-rendered content', async ({page}) => {\n  await page.goto('/about')\n\n  await expect(page.getByRole('heading', {name: 'About Us'})).toBeVisible()\n  await expect(page.getByText('Founded in 2020')).toBeVisible()\n})\n```\n\n## Dynamic Routes\n\n### Slug Parameters\n\n```typescript\ntest('dynamic [slug] renders correct content', async ({page}) => {\n  await page.goto('/blog/testing-guide')\n\n  await expect(page.getByRole('heading', {level: 1})).toContainText('Testing Guide')\n  await expect(page.getByText('Page not found')).toBeHidden()\n})\n\ntest('non-existent slug shows 404', async ({page}) => {\n  const response = await page.goto('/blog/nonexistent-post')\n\n  expect(response?.status()).toBe(404)\n  await expect(page.getByRole('heading', {name: '404'})).toBeVisible()\n})\n```\n\n### Catch-All Routes\n\n```typescript\ntest('catch-all handles nested paths', async ({page}) => {\n  await page.goto('/docs/getting-started/installation')\n  await expect(page.getByRole('heading', {name: 'Installation'})).toBeVisible()\n\n  await page.goto('/docs/api/configuration')\n  await expect(page.getByRole('heading', {name: 'Configuration'})).toBeVisible()\n})\n```\n\n### Query Parameters\n\n```typescript\ntest('query parameters filter content', async ({page}) => {\n  await page.goto('/products?category=electronics&sort=price-asc')\n\n  await expect(page.getByRole('heading', {name: 'Electronics'})).toBeVisible()\n\n  const prices = await page.getByTestId('product-price').allTextContents()\n  const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')))\n  expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))\n})\n```\n\n## API Routes\n\n### Direct API Testing\n\n```typescript\ntest('GET /api/products returns list', async ({request}) => {\n  const response = await request.get('/api/products')\n\n  expect(response.ok()).toBeTruthy()\n  const body = await response.json()\n  expect(body.products).toBeInstanceOf(Array)\n  expect(body.products[0]).toHaveProperty('id')\n  expect(body.products[0]).toHaveProperty('name')\n})\n\ntest('POST /api/products creates item', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: 'Test Product', price: 29.99},\n  })\n\n  expect(response.status()).toBe(201)\n  const body = await response.json()\n  expect(body.product.name).toBe('Test Product')\n})\n\ntest('POST /api/products validates fields', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: ''},\n  })\n\n  expect(response.status()).toBe(400)\n  const body = await response.json()\n  expect(body.error).toContainEqual(expect.objectContaining({field: 'price'}))\n})\n```\n\n### API Through UI\n\n```typescript\ntest('form submission calls API', async ({page}) => {\n  await page.goto('/products/new')\n\n  await page.getByLabel('Product name').fill('Widget')\n  await page.getByLabel('Price').fill('19.99')\n  await page.getByRole('button', {name: 'Create product'}).click()\n\n  await expect(page.getByText('Product created successfully')).toBeVisible()\n  await page.waitForURL('/products/**')\n})\n```\n\n## Middleware Testing\n\n### Auth Redirects\n\n```typescript\ntest('unauthenticated user redirected to login', async ({page}) => {\n  await page.goto('/dashboard')\n\n  expect(page.url()).toContain('/login')\n  await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n})\n\ntest('redirect preserves return URL', async ({page}) => {\n  await page.goto('/dashboard/settings')\n\n  const url = new URL(page.url())\n  expect(url.pathname).toBe('/login')\n  expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo')).toContain(\n    '/dashboard/settings',\n  )\n})\n```\n\n### Security Headers\n\n```typescript\ntest('middleware sets security headers', async ({page}) => {\n  const response = await page.goto('/')\n\n  const headers = response!.headers()\n  expect(headers['x-frame-options']).toBe('DENY')\n  expect(headers['x-content-type-options']).toBe('nosniff')\n})\n```\n\n### Locale Rewrites\n\n```typescript\ntest('middleware rewrites based on locale', async ({page, context}) => {\n  await context.setExtraHTTPHeaders({\n    'Accept-Language': 'fr-FR,fr;q=0.9',\n  })\n\n  await page.goto('/')\n\n  await expect(page.getByText('Bienvenue')).toBeVisible()\n})\n```\n\n## Hydration Testing\n\n### Console Error Detection\n\n```typescript\ntest('no hydration errors in console', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      consoleErrors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  const hydrationErrors = consoleErrors.filter(\n    (e) => e.includes('Hydration') || e.includes('hydration') || e.includes('did not match'),\n  )\n  expect(hydrationErrors).toEqual([])\n})\n```\n\n### Interactive Elements After Hydration\n\n```typescript\ntest('interactive elements work after hydration', async ({page}) => {\n  await page.goto('/')\n\n  const counter = page.getByTestId('counter-value')\n  await expect(counter).toHaveText('0')\n\n  await page.getByRole('button', {name: 'Increment'}).click()\n  await expect(counter).toHaveText('1')\n})\n```\n\n## next/image Testing\n\n```typescript\ntest('hero image loads with srcset', async ({page}) => {\n  await page.goto('/')\n\n  const heroImage = page.getByRole('img', {name: 'Hero banner'})\n  await expect(heroImage).toBeVisible()\n\n  const srcset = await heroImage.getAttribute('srcset')\n  expect(srcset).toBeTruthy()\n  expect(srcset).toContain('w=')\n\n  const loading = await heroImage.getAttribute('loading')\n  expect(loading).not.toBe('lazy')\n})\n\ntest('offscreen images lazy load', async ({page}) => {\n  await page.goto('/gallery')\n\n  const offscreenImage = page.getByRole('img', {name: 'Gallery item 20'})\n\n  await offscreenImage.scrollIntoViewIfNeeded()\n  await expect(offscreenImage).toBeVisible()\n\n  const naturalWidth = await offscreenImage.evaluate((img: HTMLImageElement) => img.naturalWidth)\n  expect(naturalWidth).toBeGreaterThan(0)\n})\n```\n\n## NextAuth.js Authentication\n\n### Setup Project\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /auth\\.setup\\.ts/},\n    {\n      name: 'authenticated',\n      use: {storageState: 'playwright/.auth/user.json'},\n      dependencies: ['setup'],\n    },\n    {name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts'},\n  ],\n})\n```\n\n### Auth Setup\n\n```typescript\n// tests/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/user.json'\n\nsetup('authenticate via credentials', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await page.waitForURL('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  await page.context().storageState({path: authFile})\n})\n```\n\n### Authenticated Tests\n\n```typescript\ntest('authenticated user sees dashboard', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByText('test@example.com')).toBeVisible()\n})\n```\n\n## Tips\n\n### Dev Server vs Production Build\n\n| Scenario          | Command                          | Trade-off                              |\n| ----------------- | -------------------------------- | -------------------------------------- |\n| Local development | `npm run dev`                    | Fast iteration, no production behavior |\n| CI pipeline       | `npm run build && npm run start` | Tests real production bundle           |\n\n### Turbopack\n\n```typescript\nwebServer: {\n  command: process.env.CI\n    ? 'npm run build && npm run start'\n    : 'npx next dev --turbopack',\n  url: 'http://localhost:3000',\n  reuseExistingServer: !process.env.CI,\n},\n```\n\n### Multiple webServer Entries\n\n```typescript\nwebServer: [\n  {\n    command: 'npm run dev:api',\n    url: 'http://localhost:4000/health',\n    reuseExistingServer: !process.env.CI,\n  },\n  {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n],\n```\n\n## Anti-Patterns\n\n| Don't Do This                               | Problem                             | Do This Instead                                                           |\n| ------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |\n| `await page.waitForTimeout(3000)`           | Arbitrary waits are fragile         | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |\n| Test `getServerSideProps` directly          | Depends on req/res context          | Navigate to page and verify rendered output                               |\n| Mock your own API routes                    | Hides real API bugs                 | Let real API handle requests; mock only external services                 |\n| `page.goto('http://localhost:3000/path')`   | Breaks when port changes            | Use `page.goto('/path')` with `baseURL`                                   |\n| Run `npm run build` locally for every test  | Extremely slow                      | Use `npm run dev` locally with `reuseExistingServer: true`                |\n| Test `next/image` by checking exact URLs    | Paths change between dev/prod       | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset`                 |\n| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons)                                       |\n\n## Related\n\n- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`\n- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`\n- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context\n- [react.md](react.md) -- React patterns for Next.js client components\n",".agents/skills/playwright-best-practices/frameworks/react.md":"# React Application Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Setup](#setup)\n3. [Framework Tips](#framework-tips)\n4. [Anti-Patterns](#anti-patterns)\n5. [Related](#related)\n\n> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Patterns\n\n### Testing Context and Global State\n\n**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.\n**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('theme switching', () => {\n  test('toggle applies dark mode across pages', async ({page}) => {\n    await page.goto('/preferences')\n\n    const root = page.locator('html')\n    await expect(root).not.toHaveClass(/dark-mode/)\n\n    await page.getByRole('switch', {name: 'Enable dark theme'}).click()\n    await expect(root).toHaveClass(/dark-mode/)\n\n    await page.getByRole('link', {name: 'Dashboard'}).click()\n    await expect(page.locator('html')).toHaveClass(/dark-mode/)\n  })\n})\n\ntest.describe('cart state persistence', () => {\n  test('item count updates globally', async ({page}) => {\n    await page.goto('/catalog')\n\n    const badge = page.getByTestId('cart-badge')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Wireless Headphones'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await expect(badge).toHaveText('1')\n  })\n})\n\ntest.describe('auth state', () => {\n  test('login updates header across components', async ({page}) => {\n    await page.goto('/')\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Login'}).click()\n    await page.getByLabel('Username').fill('testuser')\n    await page.getByLabel('Password').fill('secret123')\n    await page.getByRole('button', {name: 'Submit'}).click()\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeHidden()\n    await expect(page.getByText('testuser')).toBeVisible()\n  })\n})\n```\n\n### React Router Navigation\n\n**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.\n**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('client routing', () => {\n  test('navigation preserves SPA state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__spaMarker = 'active'\n    })\n\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n\n    const marker = await page.evaluate(() => (window as any).__spaMarker)\n    expect(marker).toBe('active')\n  })\n\n  test('query params filter content', async ({page}) => {\n    await page.goto('/items?type=books')\n\n    await expect(page.getByRole('heading', {name: 'Books'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Music'}).click()\n    await page.waitForURL('/items?type=music')\n\n    await expect(page.getByRole('heading', {name: 'Music'})).toBeVisible()\n  })\n\n  test('nested routes render layouts', async ({page}) => {\n    await page.goto('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Privacy'}).click()\n    await page.waitForURL('/account/privacy')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Privacy', level: 2})).toBeVisible()\n  })\n\n  test('history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n    await page.getByRole('link', {name: 'Help'}).click()\n    await page.waitForURL('/help')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/inventory/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n\n  test('protected route redirects', async ({page}) => {\n    await page.goto('/admin/users')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('unknown route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-path')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Testing Hooks Through UI\n\n**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.\n**Avoid when**: Hook logic is pure computation—use unit tests instead.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('useDebounce via SearchBox', () => {\n  test('batches rapid input', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/query*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('testing', {\n      delay: 40,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(3)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n})\n\ntest.describe('usePagination via DataGrid', () => {\n  test('page controls work', async ({page}) => {\n    await page.goto('/records')\n\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next'}).click()\n    await expect(page.getByText('Page 2 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Previous'}).click()\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Previous'})).toBeDisabled()\n  })\n})\n```\n\n### Form Libraries (React Hook Form, Formik)\n\n**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('signup form', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('validation on empty submit', async ({page}) => {\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByText('Email required')).toBeVisible()\n    await expect(page.getByText('Password required')).toBeVisible()\n  })\n\n  test('inline validation on blur', async ({page}) => {\n    const email = page.getByLabel('Email')\n    await email.fill('invalid')\n    await email.blur()\n\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('password strength indicator', async ({page}) => {\n    const pwd = page.getByLabel('Password', {exact: true})\n\n    await pwd.fill('weak')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/)\n\n    await pwd.fill('StrongPass1!')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/)\n  })\n\n  test('successful submission redirects', async ({page}) => {\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await page.waitForURL('/welcome')\n    await expect(page.getByText('Hello, Alice')).toBeVisible()\n  })\n\n  test('submit button disabled during request', async ({page}) => {\n    await page.route('**/api/signup', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({status: 201, json: {id: 1}})\n    })\n\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByLabel('Email').fill('bob@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByRole('button', {name: /Registering|Loading/})).toBeDisabled()\n  })\n})\n```\n\n### Portals (Modals, Tooltips, Dropdowns)\n\n**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('portal components', () => {\n  test('modal interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm removal'})\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeFocused()\n\n    await dialog.getByRole('button', {name: 'Remove'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('escape closes modal', async ({page}) => {\n    await page.goto('/items')\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n\n    await page.keyboard.press('Escape')\n    await expect(dialog).toBeHidden()\n  })\n\n  test('tooltip on hover', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Help'}).hover()\n    await expect(page.getByRole('tooltip')).toBeVisible()\n\n    await page.mouse.move(0, 0)\n    await expect(page.getByRole('tooltip')).toBeHidden()\n  })\n\n  test('dropdown menu', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Actions'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await menu.getByRole('menuitem', {name: 'Rename'}).click()\n    await expect(menu).toBeHidden()\n  })\n\n  test('toast auto-dismisses', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Preferences saved')).toBeVisible()\n\n    await expect(page.getByText('Preferences saved')).toBeHidden({timeout: 8000})\n  })\n})\n```\n\n### Error Boundaries\n\n**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.\n**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('error boundary', () => {\n  test('shows fallback on crash', async ({page}) => {\n    await page.route('**/api/widgets', (route) => {\n      route.fulfill({\n        status: 200,\n        json: {widgets: null},\n      })\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n    await expect(page.getByRole('navigation')).toBeVisible()\n  })\n\n  test('retry recovers component', async ({page}) => {\n    let calls = 0\n    await page.route('**/api/widgets', (route) => {\n      calls++\n      if (calls === 1) {\n        route.fulfill({status: 200, json: {widgets: null}})\n      } else {\n        route.fulfill({status: 200, json: {widgets: [{id: 1, name: 'Chart'}]}})\n      }\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Retry'}).click()\n\n    await expect(page.getByText('Something went wrong')).toBeHidden()\n    await expect(page.getByText('Chart')).toBeVisible()\n  })\n})\n```\n\n### Component Testing (Experimental)\n\n**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.\n**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-react'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport { test, expect } from '@playwright/experimental-ct-react';\nimport Stepper from '../../src/components/Stepper';\n\ntest('increments on click', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} />);\n\n  await expect(component.getByText('Value: 0')).toBeVisible();\n  await component.getByRole('button', { name: '+' }).click();\n  await expect(component.getByText('Value: 1')).toBeVisible();\n});\n\ntest('fires onChange callback', async ({ mount }) => {\n  const values: number[] = [];\n  const component = await mount(\n    <Stepper initial={0} onChange={(v) => values.push(v)} />\n  );\n\n  await component.getByRole('button', { name: '+' }).click();\n  await component.getByRole('button', { name: '+' }).click();\n\n  expect(values).toEqual([1, 2]);\n});\n\ntest('respects min boundary', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} min={0} />);\n\n  await expect(component.getByRole('button', { name: '-' })).toBeDisabled();\n});\n```\n\n## Setup\n\n### E2E Config (Vite)\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### CRA vs Vite Differences\n\n| Aspect           | Create React App             | Vite                           |\n| ---------------- | ---------------------------- | ------------------------------ |\n| Default port     | `3000`                       | `5173`                         |\n| Build output     | `build/`                     | `dist/`                        |\n| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |\n| Env var prefix   | `REACT_APP_*`                | `VITE_*`                       |\n\n## Framework Tips\n\n### Strict Mode Double Effects\n\nReact Strict Mode runs effects twice in development. Tests should be resilient:\n\n- Don't assert exact API call counts in dev mode\n- Run against production build for call count assertions, or account for double invocations\n\n### Suspense and Lazy Components\n\n```typescript\ntest('lazy route loads content', async ({page}) => {\n  await page.goto('/')\n\n  await page.getByRole('link', {name: 'Analytics'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n})\n```\n\n### Detecting Memory Leaks\n\n```typescript\ntest('no unmounted state warnings', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('unmounted')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/panel')\n  await page.getByRole('link', {name: 'Settings'}).click()\n  await page.goBack()\n  await page.getByRole('link', {name: 'Profile'}).click()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Don't                                          | Problem                             | Do Instead                                                  |\n| ---------------------------------------------- | ----------------------------------- | ----------------------------------------------------------- |\n| `page.evaluate(() => store.getState())`        | Couples tests to implementation     | Assert on UI: `expect(badge).toHaveText('3')`               |\n| Import components in E2E tests                 | E2E runs in Node, not browser       | Use `@playwright/experimental-ct-react` for components      |\n| `page.waitForTimeout(500)` after state changes | Timing varies across machines       | `expect(locator).toHaveText('value')` auto-retries          |\n| `page.locator('.MuiButton-root')`              | Class names change between versions | `page.getByRole('button', { name: 'Submit' })`              |\n| Test every component with CT                   | Overhead for simple components      | CT for complex widgets, unit tests for logic, E2E for flows |\n| Skip keyboard navigation tests                 | Accessibility regressions common    | Test Tab, Enter, Escape, Arrow interactions                 |\n| Assert on `__REACT_FIBER__` internals          | Not stable across versions          | Only interact with rendered DOM                             |\n\n## Related\n\n- [locators.md](../core/locators.md) — locator strategies for any React component library\n- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes\n- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing\n- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions\n- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR\n",".agents/skills/playwright-best-practices/frameworks/vue.md":"# Vue and Nuxt Testing\n\n## Table of Contents\n\n1. [Commands](#commands)\n2. [Configuration](#configuration)\n3. [Patterns](#patterns)\n4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)\n5. [Component Testing Dependencies](#component-testing-dependencies)\n6. [Testing v-model](#testing-v-model)\n7. [Capturing Vue Warnings](#capturing-vue-warnings)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.\n\n## Commands\n\n```bash\nnpm init playwright@latest\nnpm install -D @playwright/experimental-ct-vue\nnpx playwright test\nnpx playwright test -c playwright-ct.config.ts\n```\n\n## Configuration\n\n### Vue with Vite\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Nuxt 3\n\nNuxt uses port 3000 and requires a build step before testing.\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n\n  webServer: {\n    command: process.env.CI ? 'npx nuxi build && npx nuxi preview' : 'npx nuxi dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',\n    },\n  },\n})\n```\n\n### Component Testing\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-vue'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n## Patterns\n\n### Component Testing with Experimental CT\n\n**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).\n\n**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport {test, expect} from '@playwright/experimental-ct-vue'\n\nimport Stepper from '../../src/components/Stepper.vue'\n\ntest('increments value on button click', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n  })\n\n  await expect(component.getByText('Value: 0')).toBeVisible()\n  await component.getByRole('button', {name: '+'}).click()\n  await expect(component.getByText('Value: 1')).toBeVisible()\n})\n\ntest('emits change event', async ({mount}) => {\n  const changes: number[] = []\n  const component = await mount(Stepper, {\n    props: {value: 10},\n    on: {\n      change: (val: number) => changes.push(val),\n    },\n  })\n\n  await component.getByRole('button', {name: '+'}).click()\n  await component.getByRole('button', {name: '+'}).click()\n\n  expect(changes).toEqual([11, 12])\n})\n\ntest('renders slot content', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n    slots: {\n      default: '<span class=\"label\">Quantity</span>',\n    },\n  })\n\n  await expect(component.getByText('Quantity')).toBeVisible()\n})\n```\n\n### Pinia Store Testing Through UI\n\n**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.\n\n**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('shopping cart store', () => {\n  test('adding products updates cart badge', async ({page}) => {\n    await page.goto('/shop')\n\n    const badge = page.getByTestId('cart-badge')\n    await expect(badge).toHaveText('0')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Cap'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('2')\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.waitForURL('/cart')\n\n    await expect(page.getByText('Hoodie')).toBeVisible()\n    await expect(page.getByText('Cap')).toBeVisible()\n  })\n\n  test('persisted state survives reload', async ({page}) => {\n    await page.goto('/shop')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.reload()\n\n    await expect(page.getByTestId('cart-badge')).toHaveText('1')\n  })\n})\n```\n\n### Vue Router Navigation\n\n**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('router navigation', () => {\n  test('client-side navigation preserves state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('dynamic route params render content', async ({page}) => {\n    await page.goto('/items/99')\n\n    await expect(page.getByRole('heading', {level: 1})).toBeVisible()\n    await expect(page.getByText('Item #99')).toBeVisible()\n  })\n\n  test('navigation guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/dashboard')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Login'})).toBeVisible()\n  })\n\n  test('browser history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await page.waitForURL('/contact')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/shop/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n\n    await page.goForward()\n    await expect(page).toHaveURL(/\\/shop/)\n  })\n\n  test('query params update reactive state', async ({page}) => {\n    await page.goto('/items?sort=price&type=clothing')\n\n    await expect(page.getByRole('heading', {name: 'Clothing'})).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Sort'}).selectOption('name')\n    await expect(page).toHaveURL(/sort=name/)\n  })\n\n  test('catch-all route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-page')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Teleport Components\n\n**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('teleported elements', () => {\n  test('modal is visible and interactive', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm'})\n    await expect(dialog).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('notification auto-dismisses', async ({page}) => {\n    await page.goto('/profile')\n\n    await page.getByRole('button', {name: 'Update'}).click()\n\n    const alert = page.getByRole('alert')\n    await expect(alert).toBeVisible()\n    await expect(alert).toContainText('Saved')\n\n    await expect(alert).toBeHidden({timeout: 10_000})\n  })\n\n  test('dropdown closes on outside click', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('button', {name: 'Menu'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await page.locator('body').click({position: {x: 10, y: 10}})\n    await expect(menu).toBeHidden()\n  })\n})\n```\n\n### Transitions and Animations\n\n**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('transitions', () => {\n  test('item appears after add', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Write tests')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Write tests')).toBeVisible()\n  })\n\n  test('item disappears after delete', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Temp item')\n    await page.getByRole('button', {name: 'Add'}).click()\n    await expect(page.getByText('Temp item')).toBeVisible()\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Temp item'})\n      .getByRole('button', {name: 'Remove'})\n      .click()\n\n    await expect(page.getByText('Temp item')).toBeHidden()\n  })\n\n  test('disable animations for faster tests', async ({page}) => {\n    await page.addStyleTag({\n      content: `\n        *, *::before, *::after {\n          animation-duration: 0s !important;\n          animation-delay: 0s !important;\n          transition-duration: 0s !important;\n          transition-delay: 0s !important;\n        }\n      `,\n    })\n\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Quick task')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Quick task')).toBeVisible()\n  })\n})\n```\n\n### Composition API Components\n\n**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('composition API', () => {\n  test('computed properties update reactively', async ({page}) => {\n    await page.goto('/pricing')\n\n    await page.getByLabel('Amount').fill('50')\n    await page.getByLabel('Qty').fill('4')\n\n    await expect(page.getByTestId('sum')).toHaveText('$200.00')\n\n    await page.getByLabel('Discount').fill('20')\n    await expect(page.getByTestId('sum')).toHaveText('$160.00')\n  })\n\n  test('watcher triggers on change', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Locale'}).selectOption('de')\n\n    await expect(page.getByRole('heading', {name: 'Einstellungen'})).toBeVisible()\n  })\n\n  test('composable provides debounced search', async ({page}) => {\n    await page.goto('/shop')\n\n    const input = page.getByRole('textbox', {name: 'Search'})\n    await input.pressSequentially('hoodie', {delay: 50})\n\n    await expect(page.getByRole('listitem')).toHaveCount(2)\n    await expect(page.getByText('Black Hoodie')).toBeVisible()\n  })\n\n  test('provide/inject updates all consumers', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('switch', {name: 'Dark theme'}).click()\n\n    await expect(page.locator('body')).toHaveClass(/dark/)\n  })\n})\n```\n\n### Nuxt-Specific Patterns\n\n**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('nuxt features', () => {\n  test('SSR renders server-fetched data', async ({page}) => {\n    await page.goto('/posts')\n\n    await expect(page.getByRole('article')).toHaveCount(10)\n    await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n  })\n\n  test('server route returns data', async ({request}) => {\n    const response = await request.get('/api/items')\n\n    expect(response.ok()).toBeTruthy()\n    const data = await response.json()\n    expect(data).toBeInstanceOf(Array)\n    expect(data[0]).toHaveProperty('id')\n  })\n\n  test('middleware redirects unauthorized', async ({page}) => {\n    await page.goto('/admin')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('NuxtLink enables SPA navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Posts'}).click()\n    await page.waitForURL('/posts')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('useHead sets meta tags', async ({page}) => {\n    await page.goto('/posts/hello-world')\n\n    const title = await page.title()\n    expect(title).toContain('Hello World')\n\n    const desc = await page.locator('meta[name=\"description\"]').getAttribute('content')\n    expect(desc).toBeTruthy()\n    expect(desc!.length).toBeGreaterThan(50)\n  })\n})\n```\n\n## Vue vs Nuxt Differences\n\n| Aspect             | Vue 3 (Vite)                        | Nuxt 3                                      |\n| ------------------ | ----------------------------------- | ------------------------------------------- |\n| Default port       | `5173`                              | `3000`                                      |\n| Dev command        | `npm run dev`                       | `npx nuxi dev`                              |\n| Build + preview    | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview`        |\n| SSR                | Optional                            | Built-in                                    |\n| API routes         | External backend                    | `/server/api/` built-in                     |\n| Env variables      | `VITE_*` prefix                     | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |\n| File-based routing | No                                  | Yes                                         |\n\n## Component Testing Dependencies\n\nComponents depending on Pinia or Vue Router need these provided:\n\n```typescript\n// playwright/index.ts\nimport {beforeMount} from '@playwright/experimental-ct-vue/hooks'\nimport {createPinia} from 'pinia'\nimport {createMemoryHistory, createRouter} from 'vue-router'\n\nbeforeMount(async ({app, hooksConfig}) => {\n  const pinia = createPinia()\n  app.use(pinia)\n\n  if (hooksConfig?.routes) {\n    const router = createRouter({\n      history: createMemoryHistory(),\n      routes: hooksConfig.routes,\n    })\n    app.use(router)\n  }\n})\n```\n\n## Testing v-model\n\n`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:\n\n```typescript\nawait page.getByLabel('Email').fill('user@test.com')\nawait page.getByRole('checkbox', {name: 'Subscribe'}).check()\nawait page.getByRole('combobox', {name: 'Country'}).selectOption('US')\n```\n\n## Capturing Vue Warnings\n\n```typescript\ntest('no Vue warnings during render', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Avoid                                                                 | Problem                                            | Instead                                                   |\n| --------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |\n| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades         | Assert on UI that state produces                          |\n| `page.locator('[data-v-abc123]')`                                     | Scoped style hashes change on every build          | Use `getByRole`, `getByText`, `getByTestId`               |\n| Import `.vue` files in E2E tests                                      | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |\n| `page.waitForTimeout(300)` for transitions                            | Arbitrary waits are fragile                        | `await expect(locator).toBeVisible()` auto-waits          |\n| Mock Pinia by patching `window.__pinia`                               | Fragile; may not trigger reactivity                | Control state through UI or mock API responses            |\n| Test composables via `page.evaluate`                                  | Composables need Vue's setup context               | Test through components or unit test with Vitest          |\n| `page.locator('.v-btn')` for Vuetify                                  | Class names change between versions                | `page.getByRole('button', { name: 'Submit' })`            |\n| Run Nuxt dev server in CI                                             | Dev mode is slower with hot reload overhead        | Use `npx nuxi build && npx nuxi preview`                  |\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md":"# CI/CD Integration\n\n## Table of Contents\n\n1. [GitHub Actions](#github-actions)\n2. [Docker](#docker)\n3. [Reporting](#reporting)\n4. [Sharding](#sharding)\n5. [Environment Management](#environment-management)\n6. [Caching](#caching)\n\n## GitHub Actions\n\n### Basic Workflow\n\n```yaml\n# .github/workflows/playwright.yml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 30\n```\n\n### With Sharding\n\n```yaml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shardIndex: [1, 2, 3, 4]\n        shardTotal: [4]\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n\n      - name: Upload blob report\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: blob-report-${{ matrix.shardIndex }}\n          path: blob-report\n          retention-days: 1\n\n  merge-reports:\n    if: ${{ !cancelled() }}\n    needs: [test]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter html ./all-blob-reports\n\n      - name: Upload HTML report\n        uses: actions/upload-artifact@v4\n        with:\n          name: html-report\n          path: playwright-report\n          retention-days: 14\n```\n\n### With Container\n\n```yaml\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    container:\n      # Use latest or more appropriate playwright version (match package.json)\n      image: mcr.microsoft.com/playwright:v1.40.0-jammy\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n```\n\n## Docker\n\n### Dockerfile\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.40.0-jammy\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\n### Docker Compose\n\n```yaml\n# docker-compose.yml\nversion: '3.8'\n\nservices:\n  playwright:\n    build: .\n    volumes:\n      - ./playwright-report:/app/playwright-report\n      - ./test-results:/app/test-results\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n\n  app:\n    build: ./app\n    ports:\n      - '3000:3000'\n```\n\n### Run with Docker\n\n```bash\n# Build and run\ndocker build -t playwright-tests .\ndocker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests\n\n# With docker-compose\ndocker-compose run --rm playwright\n```\n\n## Reporting\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: [\n    // Always generate\n    ['html', {outputFolder: 'playwright-report'}],\n\n    // Console output\n    ['list'],\n\n    // CI-friendly\n    ['github'], // GitHub Actions annotations\n\n    // JUnit for CI integration\n    ['junit', {outputFile: 'results.xml'}],\n\n    // JSON for custom processing\n    ['json', {outputFile: 'results.json'}],\n\n    // Blob for merging shards\n    ['blob', {outputDir: 'blob-report'}],\n  ],\n})\n```\n\n### CI-Specific Reporter\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n})\n```\n\n## Sharding\n\n### Command Line\n\n```bash\n# Split into 4 shards, run shard 1\nnpx playwright test --shard=1/4\n\n# Run shard 2\nnpx playwright test --shard=2/4\n```\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Evenly distribute tests across shards\n  fullyParallel: true,\n\n  // For blob reporter to merge later\n  reporter: process.env.CI ? [['blob']] : [['html']],\n})\n```\n\n### Merge Sharded Reports\n\n```bash\n# After all shards complete, merge blob reports\nnpx playwright merge-reports --reporter html ./all-blob-reports\n```\n\n## Environment Management\n\n### Environment Variables\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\n// Load env file based on environment\ndotenv.config({path: `.env.${process.env.NODE_ENV || 'development'}`})\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n### Multiple Environments\n\n```yaml\n# .github/workflows/playwright.yml\njobs:\n  test:\n    strategy:\n      matrix:\n        environment: [staging, production]\n    steps:\n      - name: Run tests\n        run: npx playwright test\n        env:\n          BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}\n          TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}\n```\n\n### Secrets Management\n\n```yaml\n# GitHub Actions secrets\n- name: Run tests\n  run: npx playwright test\n  env:\n    TEST_EMAIL: ${{ secrets.TEST_EMAIL }}\n    TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n```typescript\n// tests use environment variables\ntest('login', async ({page}) => {\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n})\n```\n\n## Caching\n\n### Cache Playwright Browsers\n\n```yaml\n- name: Cache Playwright browsers\n  uses: actions/cache@v4\n  id: playwright-cache\n  with:\n    path: ~/.cache/ms-playwright\n    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n- name: Install Playwright browsers\n  if: steps.playwright-cache.outputs.cache-hit != 'true'\n  run: npx playwright install --with-deps\n\n- name: Install system deps only\n  if: steps.playwright-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Cache Node Modules\n\n```yaml\n- uses: actions/setup-node@v4\n  with:\n    node-version: 22\n    cache: 'npm'\n\n- name: Install dependencies\n  run: npm ci\n```\n\n## Tag-Based Test Filtering\n\n### Run Specific Tags in CI\n\n```yaml\n# Run smoke tests on PR\n- name: Run smoke tests\n  run: npx playwright test --grep @smoke\n\n# Run full regression nightly\n- name: Run regression\n  run: npx playwright test --grep @regression\n\n# Exclude flaky tests\n- name: Run stable tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### PR vs Nightly Strategy\n\n```yaml\n# .github/workflows/pr.yml - Fast feedback\n- name: Run critical tests\n  run: npx playwright test --grep \"@smoke|@critical\"\n\n# .github/workflows/nightly.yml - Full coverage\n- name: Run all tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### Tag Filtering in Config\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n### Project-Based Tag Filtering\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n  ],\n})\n```\n\n## Best Practices\n\n| Practice                      | Benefit                   |\n| ----------------------------- | ------------------------- |\n| Use `npm ci`                  | Deterministic installs    |\n| Run headless in CI            | Faster, no display needed |\n| Set retries in CI only        | Handle flakiness          |\n| Upload artifacts on failure   | Debug failures            |\n| Use sharding for large suites | Faster execution          |\n| Cache browsers                | Faster setup              |\n| Use blob reporter for shards  | Merge reports correctly   |\n| Use tags for PR vs nightly    | Fast feedback + coverage  |\n| Exclude @flaky in CI          | Stable pipeline           |\n\n## CI Configuration Reference\n\n```typescript\n// playwright.config.ts - CI optimized\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'on-first-retry',\n  },\n})\n```\n\n## Related References\n\n- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns\n- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization\n- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md":"# Container-Based Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Decision Guide](#decision-guide)\n3. [Anti-Patterns](#anti-patterns)\n4. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.\n\n## Patterns\n\n### Official Image Usage\n\nRun tests without building a custom image:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -w /app \\\n  -e CI=true \\\n  -e BASE_URL=http://host.docker.internal:3000 \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\nExtract reports with bind mounts:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -v $(pwd)/playwright-report:/app/playwright-report \\\n  -v $(pwd)/test-results:/app/test-results \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\n### Custom Dockerfile\n\nBuild a custom image when you need additional dependencies or pre-installed packages:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\n\nWORKDIR /app\n\nCOPY package.json package-lock.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nChromium-only slim image:\n\n```dockerfile\nFROM node:latest-slim\n\nRUN npx playwright install --with-deps chromium\n\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\", \"--project=chromium\"]\n```\n\n### Docker Compose Stack\n\nFull application stack with database, cache, and test runner:\n\n```yaml\nservices:\n  app:\n    build: .\n    ports:\n      - '3000:3000'\n    environment:\n      - NODE_ENV=test\n      - DATABASE_URL=postgresql://postgres:postgres@db:5432/test\n    depends_on:\n      db:\n        condition: service_healthy\n\n  db:\n    image: postgres:latest-alpine\n    environment:\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: postgres\n      POSTGRES_DB: test\n    healthcheck:\n      test: ['CMD-SHELL', 'pg_isready -U postgres']\n      interval: 5s\n      timeout: 5s\n      retries: 5\n    tmpfs:\n      - /var/lib/postgresql/data\n\n  e2e:\n    image: mcr.microsoft.com/playwright:v1.48.0-noble\n    working_dir: /app\n    volumes:\n      - .:/app\n      - /app/node_modules\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n    command: bash -c \"npm ci && npx playwright test\"\n    profiles:\n      - test\n```\n\nRun commands:\n\n```bash\ndocker compose --profile test up --abort-on-container-exit --exit-code-from e2e\n\ndocker compose --profile test down -v\n```\n\n### CI Container Jobs\n\n**GitHub Actions:**\n\n```yaml\njobs:\n  test:\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n    steps:\n      - uses: actions/checkout@v4\n      - run: npm ci\n      - run: npx playwright test\n        env:\n          HOME: /root\n```\n\n**GitLab CI:**\n\n```yaml\ntest:\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  script:\n    - npm ci\n    - npx playwright test\n```\n\n**Jenkins:**\n\n```groovy\npipeline {\n    agent {\n        docker {\n            image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n            args '-u root'\n        }\n    }\n    stages {\n        stage('Test') {\n            steps {\n                sh 'npm ci'\n                sh 'npx playwright test'\n            }\n        }\n    }\n}\n```\n\n### Dev Container Setup\n\nVS Code Dev Container or GitHub Codespaces configuration:\n\n```json\n{\n  \"name\": \"Playwright Dev\",\n  \"image\": \"mcr.microsoft.com/playwright:v1.48.0-noble\",\n  \"features\": {\n    \"ghcr.io/devcontainers/features/node:latest\": {\n      \"version\": \"20\"\n    }\n  },\n  \"postCreateCommand\": \"npm ci\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"ms-playwright.playwright\"]\n    }\n  },\n  \"forwardPorts\": [3000, 9323],\n  \"remoteUser\": \"root\"\n}\n```\n\n## Decision Guide\n\n| Scenario                          | Approach                                       |\n| --------------------------------- | ---------------------------------------------- |\n| Simple CI pipeline                | Official image as CI container                 |\n| Tests need database + cache       | Docker Compose with app, db, e2e services      |\n| Team needs identical environments | Dev Container or custom Dockerfile             |\n| Only testing Chromium             | Slim image with `install --with-deps chromium` |\n| Cross-browser testing             | Official image (all browsers pre-installed)    |\n| Local development                 | Run directly on host for faster iteration      |\n\n## Anti-Patterns\n\n| Anti-Pattern                                     | Problem                            | Solution                                                        |\n| ------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------- |\n| Installing browsers at runtime                   | Wastes 60-90 seconds per run       | Use official image or bake browsers into custom image           |\n| Running as non-root without sandbox config       | Chromium sandbox permission errors | Run as root or disable sandbox                                  |\n| Bind-mounting `node_modules` from host           | Platform-specific binary crashes   | Use anonymous volume: `-v /app/node_modules`                    |\n| No health checks on dependent services           | Tests start before database ready  | Add `healthcheck` with `depends_on: condition: service_healthy` |\n| Building application inside Playwright container | Large image, slow builds           | Separate app and e2e containers                                 |\n\n## Troubleshooting\n\n### \"browserType.launch: Executable doesn't exist\"\n\nPlaywright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:\n\n```bash\nnpm ls @playwright/test\ndocker pull mcr.microsoft.com/playwright:v<matching-version>-noble\n```\n\n### \"net::ERR_CONNECTION_REFUSED\" in docker-compose\n\nTests trying to reach `localhost` instead of service name. Configure `baseURL`:\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n```yaml\ne2e:\n  environment:\n    - BASE_URL=http://app:3000\n```\n\n### Permission denied on mounted volumes\n\nMatch user IDs or run as root:\n\n```bash\ndocker run --rm -u $(id -u):$(id -g) \\\n  -v $(pwd):/app -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  npx playwright test\n```\n\n### Slow container tests on macOS/Windows\n\nDocker Desktop I/O overhead. Copy files instead of mounting:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\nWORKDIR /app\nCOPY . .\nRUN npm ci\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nOr use delegated mount:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app:delegated \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md":"# GitHub Actions for Playwright\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Workflow Patterns](#workflow-patterns)\n3. [Scenario Guide](#scenario-guide)\n4. [Common Mistakes](#common-mistakes)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.\n\n## CLI Commands\n\n```bash\nnpx playwright install --with-deps    # browsers + OS dependencies\nnpx playwright test --shard=1/4       # run shard 1 of 4\nnpx playwright test --reporter=github # PR annotations\nnpx playwright merge-reports ./blob-report  # combine shard reports\n```\n\n## Workflow Patterns\n\n### Basic Workflow\n\n**Use when**: Starting a new project or running a small test suite.\n\n```yaml\n# .github/workflows/e2e.yml\nname: E2E Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - run: npx playwright test\n\n      - name: Upload report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n\n      - name: Upload traces\n        uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: traces\n          path: test-results/\n          retention-days: 7\n```\n\n### Sharded Execution\n\n**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.\n**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.\n\n```yaml\n# .github/workflows/e2e-sharded.yml\nname: E2E Tests (Sharded)\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 20\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1/4, 2/4, 3/4, 4/4]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests (shard ${{ matrix.shard }})\n        run: npx playwright test --shard=${{ matrix.shard }}\n\n      - name: Upload blob report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: blob-${{ strategy.job-index }}\n          path: blob-report/\n          retention-days: 1\n\n  merge:\n    if: ${{ !cancelled() }}\n    needs: test\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blobs\n          pattern: blob-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter=html ./all-blobs\n\n      - name: Upload merged report\n        uses: actions/upload-artifact@v4\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Config for sharding**—enable blob reporter:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['github']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Container-Based Execution\n\n**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.\n**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.\n\n```yaml\n# .github/workflows/e2e-container.yml\nname: E2E Tests (Container)\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Environment Secrets\n\n**Use when**: Tests target staging/production with credentials.\n**Avoid when**: Tests only run against local dev server.\n\n```yaml\n# .github/workflows/e2e-staging.yml\nname: Staging Tests\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    environment: staging\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}\n      API_TOKEN: ${{ secrets.API_TOKEN }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run smoke tests\n        run: npx playwright test --grep @smoke\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: staging-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Scheduled Runs\n\n**Use when**: Full regression suite is too slow for every PR—run nightly instead.\n**Avoid when**: Suite runs under 15 minutes and can run on every PR.\n\n```yaml\n# .github/workflows/nightly.yml\nname: Nightly Regression\n\non:\n  schedule:\n    - cron: '0 3 * * 1-5'\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Install browsers\n        run: npx playwright install --with-deps\n\n      - name: Run full regression\n        run: npx playwright test --grep @regression\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: nightly-${{ github.run_number }}\n          path: playwright-report/\n          retention-days: 30\n\n      - name: Notify on failure\n        if: failure()\n        uses: slackapi/slack-github-action@latest\n        with:\n          payload: |\n            {\n              \"text\": \"Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"\n            }\n        env:\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n```\n\n### Reusable Workflow\n\n**Use when**: Multiple repositories share the same Playwright setup.\n**Avoid when**: Single repo with one workflow.\n\n```yaml\n# .github/workflows/pw-reusable.yml\nname: Playwright Reusable\n\non:\n  workflow_call:\n    inputs:\n      node-version:\n        type: string\n        default: 'lts/*'\n      test-command:\n        type: string\n        default: 'npx playwright test'\n    secrets:\n      BASE_URL:\n        required: false\n      TEST_PASSWORD:\n        required: false\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ secrets.BASE_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n          cache: npm\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests\n        run: ${{ inputs.test-command }}\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Calling the reusable workflow:**\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  e2e:\n    uses: ./.github/workflows/pw-reusable.yml\n    with:\n      node-version: 'lts/*'\n    secrets:\n      BASE_URL: ${{ secrets.STAGING_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n## Scenario Guide\n\n| Scenario                   | Approach                                   |\n| -------------------------- | ------------------------------------------ |\n| Small suite (< 5 min)      | Single job, no sharding                    |\n| Medium suite (5-20 min)    | 2-4 shards with matrix                     |\n| Large suite (20+ min)      | 4-8 shards + blob merge                    |\n| Cross-browser on PRs       | Chromium only on PRs; all browsers on main |\n| Staging/prod smoke tests   | Separate workflow with `environment:`      |\n| Nightly full regression    | `schedule` trigger + `workflow_dispatch`   |\n| Multiple repos, same setup | Reusable workflow with `workflow_call`     |\n| Reproducible env needed    | Container job with Playwright image        |\n\n## Common Mistakes\n\n| Mistake                         | Problem                      | Fix                                                         |\n| ------------------------------- | ---------------------------- | ----------------------------------------------------------- |\n| No `concurrency` group          | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |\n| `fail-fast: true` with sharding | One failure cancels others   | Set `fail-fast: false`                                      |\n| No browser caching              | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright`                              |\n| No `timeout-minutes`            | Stuck jobs run for 6 hours   | Set explicit timeout: 20-30 minutes                         |\n| Artifacts only on failure       | No report when tests pass    | Use `if: ${{ !cancelled() }}`                               |\n| Hardcoded secrets               | Security risk                | Use GitHub Secrets and Environments                         |\n| All browsers on every PR        | 3x CI cost                   | Chromium on PR; cross-browser on main                       |\n| No artifact retention           | Default 90-day fills storage | Set `retention-days: 7-14`                                  |\n| Missing `--with-deps`           | Browser launch failures      | Always use `npx playwright install --with-deps`             |\n\n## Troubleshooting\n\n### Browser launch fails: \"Missing dependencies\"\n\n**Cause**: Browsers restored from cache but OS dependencies weren't cached.\n\n**Fix**: Run `npx playwright install-deps` on cache hit:\n\n```yaml\n- name: Install OS dependencies\n  if: steps.browser-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Tests pass locally but timeout in CI\n\n**Cause**: CI runners have fewer resources than dev machines.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Sharded reports incomplete\n\n**Cause**: Artifact names collide or `merge-multiple` not set.\n\n**Fix**: Unique names per shard and enable merge:\n\n```yaml\n# Upload in each shard\n- uses: actions/upload-artifact@v4\n  with:\n    name: blob-${{ strategy.job-index }}\n    path: blob-report/\n\n# Download in merge job\n- uses: actions/download-artifact@v4\n  with:\n    path: all-blobs\n    pattern: blob-*\n    merge-multiple: true\n```\n\n### `webServer` fails: \"port already in use\"\n\n**Cause**: Zombie process from previous run.\n\n**Fix**: Kill stale processes before starting:\n\n```yaml\n- name: Kill stale processes\n  run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true\n```\n\n### No PR annotations\n\n**Cause**: `github` reporter not configured.\n\n**Fix**: Add `github` reporter for CI:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n})\n```\n\n## Related\n\n- [test-tags.md](../core/test-tags.md) — tagging and filtering tests\n- [parallel-sharding.md](parallel-sharding.md) — sharding strategies\n- [reporting.md](reporting.md) — reporter configuration\n- [docker.md](docker.md) — container images\n- [gitlab.md](gitlab.md) — GitLab CI equivalent\n- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md":"# GitLab CI/CD Configuration\n\n## Table of Contents\n\n1. [Key Commands](#key-commands)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.\n\n## Key Commands\n\n```bash\nnpx playwright install --with-deps    # install browsers + OS deps\nnpx playwright test --shard=1/4       # run 1 of 4 parallel shards\nnpx playwright merge-reports ./blob-report  # merge shard results\nnpx playwright test --reporter=dot    # minimal output for CI logs\n```\n\n## Patterns\n\n### Basic Pipeline Configuration\n\n**Use when**: Any GitLab project with Playwright tests.\n\n```yaml\n# .gitlab-ci.yml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Sharded Parallel Execution\n\n**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.\n**Avoid when**: Suite runs under 5 minutes.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  parallel: 4\n  script:\n    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL\n  artifacts:\n    when: always\n    paths:\n      - blob-report/\n    expire_in: 1 hour\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n\ncombine-reports:\n  stage: report\n  needs: [e2e]\n  when: always\n  script:\n    - npx playwright merge-reports --reporter=html ./blob-report\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 14 days\n```\n\n**Config for sharded pipelines:**\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Environment Variables and Secrets\n\n**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - test\n\nvariables:\n  CI: 'true'\n\ne2e:staging:\n  stage: test\n  variables:\n    BASE_URL: $STAGING_URL\n    TEST_PASSWORD: $TEST_PASSWORD\n    API_KEY: $API_KEY\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n    - when: manual\n      allow_failure: true\n```\n\n**Setting variables in GitLab:**\nNavigate to **Settings > CI/CD > Variables** and add:\n\n- `STAGING_URL` -- not masked, not protected\n- `TEST_PASSWORD` -- masked, protected\n- `API_KEY` -- masked, protected\n\n### Multi-Browser Matrix\n\n**Use when**: Running Chromium on MRs and all browsers on the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n\nvariables:\n  CI: 'true'\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:chromium:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test --project=chromium\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n\ne2e:all-browsers:\n  stage: test\n  needs: [setup]\n  parallel:\n    matrix:\n      - PROJECT: [chromium, firefox, webkit]\n  script:\n    - npx playwright test --project=$PROJECT\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services Integration (Database, Cache)\n\n**Use when**: Tests need the application running alongside Playwright, or you need external services.\n\n```yaml\nstages:\n  - test\n\ne2e:integration:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  services:\n    - name: postgres:latest\n      alias: db\n    - name: redis:latest\n      alias: cache\n  variables:\n    CI: 'true'\n    DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n    REDIS_URL: 'redis://cache:6379'\n    POSTGRES_PASSWORD: 'postgres'\n    POSTGRES_DB: 'testdb'\n  before_script:\n    - npm ci\n    - npx prisma db push\n    - npx prisma db seed\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Scheduled Nightly Regression\n\n**Use when**: Full regression is too slow for every MR.\n\n```yaml\ne2e:nightly:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test --grep @regression\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 30 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"schedule\"\n```\n\nSet up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).\n\n## Decision Guide\n\n| Scenario                             | Approach                                               | Why                                                 |\n| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |\n| Simple project, < 5 min suite        | Single `test` job using Playwright Docker image        | No sharding overhead; artifacts capture report      |\n| Suite > 10 min                       | `parallel: N` with `--shard`                           | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |\n| Merge request fast feedback          | Chromium only on MRs; all browsers on main             | 3x fewer pipeline minutes on MRs                    |\n| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images         | GitLab manages service lifecycle                    |\n| Secrets for staging environment      | GitLab CI/CD Variables (masked + protected)            | Never hardcode secrets in `.gitlab-ci.yml`          |\n| Full nightly regression              | Pipeline schedule (`CI_PIPELINE_SOURCE == \"schedule\"`) | Avoids blocking MR pipelines                        |\n| Report browsing                      | `artifacts:` with `paths: [playwright-report/]`        | Browse directly in GitLab job artifacts UI          |\n\n## Anti-Patterns\n\n| Anti-Pattern                                         | Problem                                                            | Do This Instead                                                           |\n| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |\n| Not using the Playwright Docker image                | Installing browsers every run adds 1-2 minutes                     | Use `mcr.microsoft.com/playwright:v1.48.0-noble` as base image            |\n| `artifacts: when: on_failure` only                   | No report when tests pass; can't verify results                    | Use `when: always` to capture reports regardless                          |\n| No `expire_in` on artifacts                          | Artifacts accumulate and consume storage                           | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |\n| Hardcoding `CI_NODE_TOTAL` in shard flag             | Breaks when you change `parallel:` value                           | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL`                               |\n| Skipping `needs:` between stages                     | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs                                |\n| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts                               | Key cache on `package-lock.json` hash                                     |\n\n## Troubleshooting\n\n### Browser launch fails: \"Failed to launch browser\"\n\n**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.\n\n**Fix**: Match the Docker image tag to your Playwright version:\n\n```yaml\n# Check your version: npm ls @playwright/test\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Tests hang in GitLab runner: \"Navigation timeout exceeded\"\n\n**Cause**: GitLab shared runners may have limited resources.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\nexport default defineConfig({\n  workers: process.env.CI ? 2 : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Pipeline runs on every push, not just merge requests\n\n**Cause**: Missing `rules:` configuration.\n\n**Fix**: Add explicit rules:\n\n```yaml\nrules:\n  - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services (Postgres/Redis) not reachable from tests\n\n**Cause**: Using `localhost` instead of the service alias.\n\n**Fix**: Use the service alias as hostname:\n\n```yaml\nservices:\n  - name: postgres:latest\n    alias: db\n\nvariables:\n  DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n```\n\n### Merged report is empty after sharded run\n\n**Cause**: Each shard job needs the `blob` reporter, not `html`.\n\n**Fix**: Configure blob reporter for CI:\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n",".agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fother-providers.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fparallel-sharding.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fperformance.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Freporting.md -->",".agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Ftest-coverage.md -->",".agents/skills/playwright-best-practices/testing-patterns/accessibility.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Faccessibility.md -->",".agents/skills/playwright-best-practices/testing-patterns/api-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fapi-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fbrowser-extensions.md -->",".agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcanvas-webgl.md -->",".agents/skills/playwright-best-practices/testing-patterns/component-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcomponent-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/drag-drop.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fdrag-drop.md -->",".agents/skills/playwright-best-practices/testing-patterns/electron.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Felectron.md -->",".agents/skills/playwright-best-practices/testing-patterns/file-operations.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-operations.md -->",".agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-upload-download.md -->",".agents/skills/playwright-best-practices/testing-patterns/forms-validation.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fforms-validation.md -->",".agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fgraphql-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/i18n.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fi18n.md -->",".agents/skills/playwright-best-practices/testing-patterns/performance-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fperformance-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/security-testing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fsecurity-testing.md -->",".agents/skills/playwright-best-practices/testing-patterns/visual-regression.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fvisual-regression.md -->",".agents/skills/playwright-cli/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2FSKILL.md -->",".agents/skills/playwright-cli/references/request-mocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frequest-mocking.md -->",".agents/skills/playwright-cli/references/running-code.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frunning-code.md -->",".agents/skills/playwright-cli/references/session-management.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fsession-management.md -->",".agents/skills/playwright-cli/references/storage-state.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fstorage-state.md -->",".agents/skills/playwright-cli/references/test-generation.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftest-generation.md -->",".agents/skills/playwright-cli/references/tracing.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftracing.md -->",".agents/skills/playwright-cli/references/video-recording.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fvideo-recording.md -->",".agents/skills/pr-description/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fpr-description%2FSKILL.md -->",".agents/skills/react-devtools/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2FSKILL.md -->",".agents/skills/react-devtools/references/commands.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fcommands.md -->",".agents/skills/react-devtools/references/profiling-guide.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fprofiling-guide.md -->",".agents/skills/react-devtools/references/setup.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fsetup.md -->",".agents/skills/react-rx-hook-audit/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-rx-hook-audit%2FSKILL.md -->",".agents/skills/rxjs-like-a-pro/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2FSKILL.md -->",".agents/skills/rxjs-like-a-pro/references/custom-operators.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fcustom-operators.md -->",".agents/skills/rxjs-like-a-pro/references/inner-observable-chains.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Finner-observable-chains.md -->",".agents/skills/rxjs-like-a-pro/references/loading-state-patterns.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Floading-state-patterns.md -->",".agents/skills/rxjs-like-a-pro/references/massive-observable.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fmassive-observable.md -->",".agents/skills/sanity-bench/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-bench%2FSKILL.md -->",".agents/skills/sanity-config-reducers/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-config-reducers%2FSKILL.md -->",".agents/skills/sanity-default-plugins/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-default-plugins%2FSKILL.md -->",".agents/skills/sanity-i18n-translate/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-i18n-translate%2FSKILL.md -->",".agents/skills/sanity-plugin-authoring/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-plugin-authoring%2FSKILL.md -->",".agents/skills/sanity-radar-investigate/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar-investigate%2FSKILL.md -->",".agents/skills/sanity-radar/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2FSKILL.md -->",".agents/skills/sanity-radar/references/groq-recipes.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2Freferences%2Fgroq-recipes.md -->",".agents/skills/sanity-tsdown-config/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-tsdown-config%2FSKILL.md -->",".agents/skills/sanity-ui-migration-progress/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-ui-migration-progress%2FSKILL.md -->",".agents/skills/sanity-visual-coverage/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-coverage%2FSKILL.md -->",".agents/skills/sanity-visual-regression/REFERENCE.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FREFERENCE.md -->",".agents/skills/sanity-visual-regression/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FSKILL.md -->",".agents/skills/stories/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstories%2FSKILL.md -->",".agents/skills/storybook-init/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-init%2FSKILL.md -->",".agents/skills/storybook-setup/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-setup%2FSKILL.md -->",".agents/skills/storybook-upgrade/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-upgrade%2FSKILL.md -->",".agents/skills/tdd/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2FSKILL.md -->",".agents/skills/tdd/deep-modules.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fdeep-modules.md -->",".agents/skills/tdd/interface-design.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Finterface-design.md -->",".agents/skills/tdd/mocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fmocking.md -->",".agents/skills/tdd/refactoring.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Frefactoring.md -->",".agents/skills/tdd/tests.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Ftests.md -->",".agents/skills/vercel-react-best-practices/AGENTS.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FAGENTS.md -->",".agents/skills/vercel-react-best-practices/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FSKILL.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-event-handler-refs.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-init-once.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-init-once.md -->",".agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-use-latest.md -->",".agents/skills/vercel-react-best-practices/rules/async-api-routes.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-api-routes.md -->",".agents/skills/vercel-react-best-practices/rules/async-defer-await.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-defer-await.md -->",".agents/skills/vercel-react-best-practices/rules/async-dependencies.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-dependencies.md -->",".agents/skills/vercel-react-best-practices/rules/async-parallel.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-parallel.md -->",".agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-suspense-boundaries.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-barrel-imports.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-conditional.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-conditional.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-defer-third-party.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-dynamic-imports.md -->",".agents/skills/vercel-react-best-practices/rules/bundle-preload.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-preload.md -->",".agents/skills/vercel-react-best-practices/rules/client-event-listeners.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-event-listeners.md -->",".agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-localstorage-schema.md -->",".agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-passive-event-listeners.md -->",".agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-swr-dedup.md -->",".agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-batch-dom-css.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-function-results.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-property-access.md -->",".agents/skills/vercel-react-best-practices/rules/js-cache-storage.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-storage.md -->",".agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-combine-iterations.md -->",".agents/skills/vercel-react-best-practices/rules/js-early-exit.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-early-exit.md -->",".agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-hoist-regexp.md -->",".agents/skills/vercel-react-best-practices/rules/js-index-maps.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-index-maps.md -->",".agents/skills/vercel-react-best-practices/rules/js-length-check-first.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-length-check-first.md -->",".agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-min-max-loop.md -->",".agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-set-map-lookups.md -->",".agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-tosorted-immutable.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-activity.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-activity.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-animate-svg-wrapper.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-conditional-render.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-content-visibility.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hoist-jsx.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-no-flicker.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-suppress-warning.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-svg-precision.md -->",".agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-usetransition-loading.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-defer-reads.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-dependencies.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state-no-effect.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-functional-setstate.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-lazy-state-init.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo-with-default-value.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-memo.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-move-effect-to-event.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-simple-expression-in-memo.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-transitions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-transitions.md -->",".agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-use-ref-transient-values.md -->",".agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-after-nonblocking.md -->",".agents/skills/vercel-react-best-practices/rules/server-auth-actions.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-auth-actions.md -->",".agents/skills/vercel-react-best-practices/rules/server-cache-lru.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-lru.md -->",".agents/skills/vercel-react-best-practices/rules/server-cache-react.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-react.md -->",".agents/skills/vercel-react-best-practices/rules/server-dedup-props.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-dedup-props.md -->",".agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-parallel-fetching.md -->",".agents/skills/vercel-react-best-practices/rules/server-serialization.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-serialization.md -->",".agents/skills/write-a-skill/SKILL.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fwrite-a-skill%2FSKILL.md -->","AGENTS.md":"# AGENTS.md - AI Agent Guidelines for Sanity Monorepo\n\nThis document helps AI agents work successfully with the Sanity monorepo.\n\n> **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.\n\n## Prerequisites\n\n- **Node.js**: v24 or latest LTS. Published packages must declare `\"engines\": { \"node\": \">=22.12\" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing).\n- **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json)\n\n## Quick Reference\n\n```bash\n# Install dependencies (pnpm ONLY - enforced)\npnpm install\n\n# Build all packages (required before testing)\npnpm build\n\n# Run dev studio (requires auth, see below)\npnpm dev\n\n# Format code (MUST pass CI)\npnpm chore:format:fix\n\n# Fix all lint issues (MUST pass CI) — includes TypeScript type checking via oxlint\npnpm lint:fix\n\n# Run tests\npnpm test\n\n# Update snapshots if tests fail due to expected changes\npnpm test -- -u\n\n# Lint + type check (oxlint typeAware + typeCheck; no separate tsc step)\npnpm check:oxlint\n```\n\n## CI Checks - What Must Pass\n\nThese checks run on every PR and **must pass**:\n\n| Check            | Command               | Notes                                                                                                                                                            |\n| ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Format**       | `pnpm check:format`   | Uses oxfmt. Fix with `pnpm chore:format:fix`                                                                                                                     |\n| **Oxlint**       | `pnpm check:oxlint`   | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix`                              |\n| **Unit Tests**   | `pnpm test`           | Vitest, sharded in CI                                                                                                                                            |\n| **Export Tests** | `pnpm test:exports`   | Ensures ESM/CJS/DTS work                                                                                                                                         |\n| **Dep Check**    | `pnpm depcheck`       | Finds unused/missing deps                                                                                                                                        |\n| **Zizmor**       | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. |\n| **PR Title**     | Conventional commits  | e.g., `feat(scope): description`                                                                                                                                 |\n\n### Before Committing\n\nRun these commands to avoid CI failures:\n\n```bash\n# Fix all formatting and lint issues\npnpm lint:fix\n\n# Verify tests pass (build first if needed)\npnpm build && pnpm test\n```\n\nIf tests fail due to **expected snapshot changes**, update them:\n\n```bash\npnpm test -- -u\n```\n\nSnapshot files are located in `__snapshots__` directories alongside test files.\n\n## Project Structure\n\n```\nsanity/\n├── packages/\n│   ├── sanity/           # Main Sanity studio package\n│   ├── @sanity/          # Scoped packages (cli, types, schema, etc.)\n│   └── @repo/            # Internal tooling (test-config, tsconfig, etc.)\n├── dev/                  # Development studios for testing\n│   ├── test-studio/      # Primary dev studio (pnpm dev runs this)\n│   ├── studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON\n│   └── preview-iframe/   # Presentation preview iframe (vanilla Vite, port 3334)\n├── e2e/                  # End-to-end Playwright tests\n├── perf/                 # Performance testing\n└── examples/             # Example studios\n```\n\n### Key Packages\n\n- **`packages/sanity`** - Core studio package with all UI components\n- **`packages/@sanity/types`** - TypeScript type definitions\n- **`packages/@sanity/schema`** - Schema compilation\n- **`packages/@sanity/mutator`** - Document mutation logic\n\n## Build System\n\n- **Package Manager**: pnpm (version 10.x, enforced via `preinstall`)\n- **Build Orchestration**: Turbo (caches builds)\n- **Versioning**: Lerna-lite with conventional commits\n\n### Build Commands\n\n```bash\npnpm build              # Build all packages\npnpm watch              # Watch mode for development\n```\n\n### Running the Dev Studio\n\n```bash\npnpm dev                # Starts dev studio at http://localhost:3333\n```\n\n**Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio.\n\n## Local Development\n\nThis section clarifies what requires authentication and what doesn't—critical for AI agents to avoid getting stuck on auth flows.\n\n### Running Tests (No Auth Required)\n\nUnit tests run in jsdom with mocks and **do not require any authentication**:\n\n```bash\n# Build first (required), then run all tests\npnpm build && pnpm test\n\n# Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests)\npnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts\n\n# Run a single test file with verbose output\npnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts\n\n# Watch mode for iterative development\npnpm test -- --watch\n\n# Run tests for a specific package\npnpm test -- --project=sanity\n```\n\n**Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file — it runs all tests across all projects. Use `pnpm vitest run --project=<project> <path>` instead.\n\nThe headless validation package is registered as `@sanity/validation`: run its full suite with\n`pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's\ncompilation path too: call `inferFromSchema` on the built-in schema before compiling custom types,\nthen call it on the compiled custom schema. Studio inherits already-normalized built-in rules.\n\nComponents that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes.\n\n### Running the Dev Studio (Auth Required)\n\n```bash\npnpm dev  # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334\n```\n\n- **Requires browser authentication** on first visit—you'll be prompted to log in with a Sanity account\n- Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`)\n- Uses staging API by default (`api.sanity.work`)\n- Session persists in browser, so subsequent visits won't require re-authentication\n- `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`.\n- Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`)\n\nUse the dev studio when you need to:\n\n- Visually verify UI changes\n- Test real document editing workflows\n- Debug issues that only appear with real data\n- Exercise Presentation / visual editing against the local preview iframe\n\n### Inspecting Production Builds with Vite DevTools\n\nThe test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` server—no restart needed.\n\n```bash\n# Builds the test studio with devtools enabled, then starts the dev server\n# (so there's a build session to inspect right away)\npnpm devtools:test-studio\n```\n\nOpen `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels.\n\nTo inspect a **new** build after making changes—while `pnpm devtools:test-studio` is still running—run in a second terminal:\n\n```bash\n# Creates a fresh build session that shows up in the running DevTools dock\npnpm devtools:test-studio:build\n```\n\nSessions can be compared against each other in the DevTools UI to diff bundle changes.\n\nHow it works:\n\n- Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools`\n- Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored)\n- The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes\n- Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag\n\n### Debugging presence with fake users\n\n**Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking).\n\n**URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: \"presence-debug\"`) and `show_own` (also show your own session).\n\n### Profiling studio re-renders with React DevTools (agent-react-devtools)\n\nThe test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI — made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling.\n\nFor `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern.\n\n```bash\n# 1. Start the daemon (port 8097)\npnpm --filter sanity-test-studio exec agent-react-devtools start\n\n# 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true)\npnpm react-devtools:test-studio\n\n# 3. Open http://localhost:3333 in a browser, then verify the app registered\npnpm --filter sanity-test-studio exec agent-react-devtools status\n\n# 4. Profile an interaction\npnpm --filter sanity-test-studio exec agent-react-devtools profile start\n# ... interact with the studio ...\npnpm --filter sanity-test-studio exec agent-react-devtools profile stop\npnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10\n```\n\nHow it works and gotchas:\n\n- `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) — it can never reach `sanity build` output.\n- **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs).\n- Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations — production studios don't run StrictMode.\n- Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas).\n\n### Analyzing the `sanity` package bundle\n\nThe `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`:\n\n```bash\npnpm analyze:sanity\n```\n\nThe report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown).\n\n### Auto-updating studio CSS (Lightning CSS `light-dark()`)\n\nThe CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance ≠ Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native — same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo.\n\n### Panda CSS\n\nThe static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in\n`.css.ts` modules next to the components, imported from the generated `#styled-system/css`\nruntime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on\n`pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the\n**only Panda project in the repository** — the `} satisfies Config)` pattern, no presets,\ncontent-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues`\n— and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity`\nwrites Panda CSS. The `migrate-styled-components-to-panda-css` skill\n(`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the\n`&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the\npackage, vanilla-extract stays where it is for now — the `.css.ts` modules of `@sanity/vision`\n(with its `vanillaExtract` tsdown option) and the test studio's\n`plugins/style-outline/styleOutline.css.ts` — until\n[#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps\nthat build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's\n`sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live\nin the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the\nVite apps that build the package from source get their Panda wiring from `@repo/pandacss`\n(`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those\nthemselves.\n\nHow the CSS reaches a page:\n\n- **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts`\n  (the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the\n  diff between two published versions stays readable, see below for the layers) rewrites every\n  `css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`,\n  injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional\n  `./bundle.css` export (`browser`/`style` → the stylesheet, `node`/`default` → a no-op shim).\n  The published code carries no Panda runtime, which is why the manifest resets `imports`\n  under `publishConfig`. A `css()` call the build cannot resolve statically (computed values,\n  loops) makes it warn and bundle the runtime — keep the style objects literal.\n- **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`,\n  Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace\n  packages to their sources (the `monorepo` export condition), so they bundle the package's\n  `.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite`\n  (`packages/@repo/pandacss`): `pandacss({cwd: <packages/sanity>, transform: true})` from\n  `@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its\n  `include` globs and the codegen `outdir` there), and\n  `removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from\n  `@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css`\n  (below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares\n  Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the\n  `@layer reset, base, tokens, recipes, utilities;` statement — the dev counterpart of the\n  `sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and\n  `packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts`\n  (`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import\n  injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin,\n  registered for dev only. A `sanity build` (and the viewer's build) resolves the built package\n  and its `bundle.css`, so nothing Panda runs there — which is also why a studio that installs\n  `sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS\n  support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and\n  keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from\n  every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the\n  test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's\n  modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is\n  wrapped so that its transform's hook filter excludes `packages/sanity/src/` — it would otherwise\n  compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css`\n  import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite`\n  directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test\n  asserts on styles.\n- **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`,\n  `recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats\n  every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and\n  selected states to any unlayered rule that also matches — styled-components output, a reset\n  like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's\n  write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after\n  Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before\n  its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and\n  the studio's rules cascade like the vanilla-extract output did: by specificity, then source\n  order. The same pass runs on the dev stylesheets and the CDN bundle through\n  `removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS\n  module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own\n  `panda.css`), and leaves other layers alone — which is why that file declares Panda's default\n  layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a\n  single `@layer`. Panda's `polyfill` option is\n  not an alternative: it emulates the layer order with `:not(#\\#…)` selector chains that inflate\n  every rule's specificity. The studio declares no `@layer` statement of its own either: with\n  `bundle.css` unlayered and `@sanity/ui` ≥ 4.4.8 (the catalog version) shipping its static styles\n  unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a\n  studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing\n  to order it against — the same situation as before the migration. What this means when\n  authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order\n  alone — a plain class beats a styled-components base rule that loads later only when it is more\n  specific, which is what the `&&` doubling is for.\n- **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct\n  `property: value` pair once, grouped by property with the values sorted; a modifier class that\n  must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`)\n  instead of relying on being declared later, and parent-state selectors key on data attributes\n  the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in\n  `ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted.\n\n### Studio performance benchmarks (perf/bench — No Auth Required)\n\nThe `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API — fully hermetic, no tokens, no network:\n\n```bash\npnpm build:bench                                   # build packages + bench studio (required first)\npnpm bench help                                    # list all bench CLI commands\npnpm bench run --scenario singleString             # absolute interaction benchmark\npnpm bench run --mode pageload --scenario singleString  # load vitals + bundle size\npnpm bench:unit                                    # mock-contract + stats unit tests\npnpm bench dev                                     # mock + `sanity dev` for interactive debugging\n```\n\nSee `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.)\n\nThree skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit).\n\n### Engine confidence score (No Auth Required locally)\n\nThe Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail (\"X of N known bugs caught\"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail.\n\n```bash\npnpm engine-confidence stryker --target mutator --full   # also: --target sanity; omit --full for an incremental run\npnpm engine-confidence catalogue                         # --suites unit:mutator,unit:engine skips fuzz; --only <ids>; --check-only\npnpm engine-confidence score                             # writes engine-confidence/reports/engine-confidence-run.json\npnpm engine-confidence store <file> --dry-run            # prints the document id and body, needs no token\npnpm engine-confidence pin                               # recomputes config.json's pinnedHash and bumps version when it changed\npnpm engine-confidence unit                              # the package's unit tests\n```\n\n- **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once.\n- **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards.\n- **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed.\n- **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check.\n- **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules.\n- **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only <id>` to confirm the verdict. `engine-confidence/README.md` has the steps.\n- **Where it shows:** the Radar \"Engine confidence\" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week.\n\n### E2E Tests (Token Required)\n\nE2E tests require authentication tokens. Add these to `.env.local` in the repo root:\n\n```bash\nSANITY_E2E_SESSION_TOKEN=<your-token>\nSANITY_E2E_PROJECT_ID=<project-id>\nSANITY_E2E_DATASET=<dataset-name>\n```\n\n**How to get a token:**\n\n```bash\n# Option 1: Use your CLI token\nsanity login\nsanity debug --secrets  # Look for \"Auth token\"\n\n# Option 2: Create a project token at https://sanity.io/manage\n# Navigate to: Project Settings → API → Tokens → Add API token\n```\n\nThen run E2E tests:\n\n```bash\npnpm e2e:build              # Build E2E studio\npnpm test:e2e               # Run E2E tests\npnpm test:e2e --ui          # Interactive mode\n```\n\n**Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests.\n\nWhen CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `<report-url>/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt).\n\n#### Diagnosing e2e flake: failure diagnostics and the flake report\n\nEvery failed or timed-out e2e test attempt attaches `studio-diagnostics.json` — the same JSON the Studio Diagnostics dialog's \"Copy output\" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog (\"Too many requests\", server error, network error) when it is showing — the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`.\n\nTo turn those captures into numbers (\"how many failed runs were platform-caused?\"), run the flake report:\n\n```bash\npnpm e2e:flake-report --days 7 --out flake-report.md   # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login`\npnpm e2e:flake-report --help\npnpm --filter e2e test:unit                              # classifier unit tests\n```\n\nIt lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact.\n\nStaging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does.\n\n### Important Note for AI Agents\n\n**What requires authentication:**\n\n- Running the dev studio (`pnpm dev`)\n- E2E tests (`pnpm test:e2e`)\n- Any command that connects to Sanity APIs\n\n**What does NOT require authentication:**\n\n- Building packages (`pnpm build`)\n- Running unit tests (`pnpm test`)\n- Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`)\n\n**Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed.\n\n## Coding Standards\n\nCoding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` — no separate `tsc` step. Check your code with:\n\n```bash\npnpm lint              # Check for issues (oxlint, includes type checking)\npnpm lint:fix          # Auto-fix issues (oxfmt + oxlint --fix)\n```\n\nAll packages use **ESM** (`\"type\": \"module\"`). TypeScript strict mode is enabled.\n\nRules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide — run `pnpm lint` and follow the reported messages, which explain the expected pattern.\n\n### Do Not Weaken the Linter\n\nFix the reported problem instead of silencing it. In order of preference:\n\n1. **Fix the code** so the rule passes. This is almost always the right answer.\n2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line <rule> -- <why>`. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green.\n3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative — an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description.\n\nFile-wide `/* oxlint-disable <rule> */` is reserved for files that are an exception as a whole — vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors.\n\n`options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI — drop suppressions when the code underneath them changes.\n\n### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n\nEvery declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the\n`@internal` TSDoc release tag is also exported from\n`sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n(`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`),\nan entry whose name spells out its contract: anything on it can change or disappear in any\nrelease. The public entries still export those symbols today; the entry is the migration target\nfor consumers that depend on them, ahead of the public entries dropping them in a future major.\n`packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of\n`pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry,\nwhen the two entries export different declarations under one name, or when the entry exports\nsomething that is not tagged `@internal`.\n\nRules that follow from this:\n\n- A new `@internal` export on a public barrel needs a matching line in the internals barrel\n  (grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity`\n  (`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from\n  `sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing\n  relatively: the entry re-exports modules from all three areas, so importing it from any of them\n  is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in\n  with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the\n  three areas.\n- The entry is generated from the tags, not curated: whether a symbol belongs there is decided by\n  its `@internal` tag. Retagging a symbol (`@internal` → `@beta`/`@public`, or the other way) is\n  the API decision; check sanity.io/docs and the published first-party plugins before demoting a\n  symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the\n  studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure\n  `renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`,\n  `generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one\n  deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its\n  `@internal @deprecated` tags because those tell customers to stop using it, it stays off the\n  entry so the deprecated `sanity` import remains the only one, and it is listed in\n  `PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by\n  the internals moving. Do not retag it.\n- The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly;\n  a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a\n  404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports`\n  regenerates the d.ts fixture for the entry after its export list changes.\n- A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`,\n  a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the\n  entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u`\n  regenerates it without a build.\n\n### An entry's type surface is what its barrel exports, nothing more\n\nA bundled `.d.ts` can only name declarations the entry barrel actually exports. Until\n`rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {…}` clause\nhit TypeScript's rule that every top-level declaration of such a declaration file is implicitly\nexported, so private helpers pulled in for a signature became part of the published type surface —\nincluding `const`s and `function`s that the emitted `.js` never exported, so importing them\ntype-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead\n([\"prevent implicit exports of private declarations\"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)),\nso only explicit exports are public.\n\nThat makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a\nname no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check\neach dropped name before regenerating:\n\n- A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as\n  a type-only re-export (`export {type Foo} from '…'`), which leaves the emitted `.js` untouched.\n- A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup\n  artifact that was never API — `sanity/media-library` emitted one because `VideoDefinition` is\n  declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared\n  twice, pick which one is public by convention, not by which one the old output left unsuffixed:\n  that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like\n  `ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`,\n  while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation.\n- A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported\n  it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost\n  more than it looks — re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into\n  `sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away.\n- Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from\n  `createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and\n  `lib-check.test.ts` runs with `skipLibCheck: false`.\n\nRegenerating needs a full `pnpm build` first: the generator reads each package's built\n`lib/**/*.d.ts`.\n\n### Effect events: use `use-effect-event`, not React's native hook\n\nImport `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook\nreturns first-render values when the calling component is wrapped in `forwardRef` or `memo`\n([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries).\n`eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that\nwraps the native hook, so check the implementation before trusting one.\n\n### react-rx: stable observables, explicit initial values\n\n`react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the\n`initialValue` (required — pass `undefined` explicitly when there is nothing better) until the\nsubscription started on commit delivers a value, and a synchronous emission arrives one pass later.\nTwo rules follow:\n\n- **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a\n  store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than\n  on arrays or objects recreated every render. An observable rebuilt each render is torn down and\n  re-subscribed each commit; when it synchronously replays a value that differs from the\n  `initialValue`, React aborts with \"Maximum update depth exceeded\"\n  (`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case).\n- **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a\n  first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's\n  exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render\n  when it depends on the observable's parameters (`useEditState`), since react-rx captures\n  `initialValue` once per hook instance.\n\n### Translate: never define `components` inline\n\nComponents passed to `<Translate>`'s `components` map must be stable, module-scope components —\ndefining them inline during render creates a new component identity every render and remounts\nthe subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain\nHTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule\n`@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces\nthis for object literals in the JSX attribute; maps built during render via `useMemo` or factory\nfunctions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate`\nskill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns.\n\n### Locale namespaces: plugin strings stay inside the plugin\n\nOnly `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always\nincluded) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`,\n`structure`, ...) is registered by a plugin that a studio can leave out, and a component that\nreads it while that plugin is not loaded renders raw keys. Strings for code that renders outside\na plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two\nin-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and\n`@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace\nthey are read from). Both read the namespace table in\n`packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or\nchanging which plugin registers one.\n\n### `<Activity mode=\"hidden\">`: effects are torn down, DOM and state stay\n\nReact's `<Activity>` is used for closed `@sanity/ui` overlays, collapsed document lists, and the\ntop-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs\nevery effect cleanup in the subtree and showing runs the effects again, without a remount, so:\n\n- Anything that lives only in an effect-created object is lost on reveal unless the effect can\n  rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup\n  and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for\n  that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal.\n- Loading states come back on reveal unless the code remembers it already loaded. A comlink\n  channel recreated by an effect handshakes as a brand new connection (the presentation machine\n  tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a\n  `startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes\n  it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context\n  populated by registering in an effect empties out while hidden, unmounting whatever renders\n  from it (`PresentationDocumentProvider` lists its own options at render time).\n- Never reorder keyed siblings that hold an `<iframe>`. React moves the DOM node when the key\n  order changes, and a re-inserted iframe reloads. `useMountedTools` renders tools in workspace\n  order for this reason even though eviction is least-recently-used.\n- `@sanity/ui`'s `useElementSize` (4.2.4) stops reporting after a reveal. Its shared\n  `_elementSizeObserver` keeps one `ResizeObserver` per element and disconnects it when the last\n  subscriber leaves, but never forgets the element, so the effect re-run after a reveal finds the\n  cached entry and creates no new observer: the hook keeps the size from before hiding, also for\n  later resizes. Any layout derived from it inside a tool (Vision's column/stacked breakpoints,\n  `PaneLayout`, `DocumentLayout`, the PTE toolbar) freezes. Vision's redesign measures with its\n  own `useContentSize`, which observes afresh on every effect run; the fix belongs in `@sanity/ui`.\n- Hidden content keeps `display: none` on its topmost host nodes only, and portals under the\n  boundary are hidden too (`hideOrUnhideNearestPortals`); descendants report their own `display`.\n\n### Refs: use `props.ref`, not `forwardRef`\n\nReact 19 passes `ref` as a regular prop. Do not use `forwardRef` — destructure `ref` from props\n(so it is not left in a `...rest` spread) and forward it like any other prop.\n`eslint/no-restricted-imports` bans importing `forwardRef` from `react`.\n\nPrefer a named function declaration over `const X = function …` / arrow wrappers:\n\n```ts\n// preferred\nexport function MyComponent(props: Props & RefAttributes<HTMLDivElement>) {\n  const {ref, ...rest} = props\n  return <div ref={ref} {...rest} />\n}\n\n// avoid\nexport const MyComponent = function MyComponent(props: …) { … }\nexport const MyComponent = (props: …) => { … }\n```\n\nWhen wrapping with `memo`, declare the component as a function first, then memoize:\n\n```ts\nfunction MyComponent(props: …) { … }\nexport const MyComponentMemo = memo(MyComponent)\n```\n\nDo not assign `.displayName` on components, HOCs, styled components, or `createContext`\nresults. That property write is a module-level side effect and keeps the export from being\ntree-shaken. Named function declarations already give React DevTools a name via\n`function.name`. Exception (temporary): document/badge hooks prefixed with `use` and the\nHookState collection still set `displayName` — leave those until that API is reworked.\n\nFor typings, include `ref` on the props type: stop omitting `'ref'` from `HTMLProps` /\n`ComponentProps`, or intersect with `RefAttributes<T>`. Avoid `PropsWithRef` — in `@types/react`\n19 it is a deprecated identity alias and trips `typescript/no-deprecated`.\n\n## Testing\n\n### Unit Tests (Vitest)\n\n```bash\npnpm test                    # Run all tests\npnpm test -- --watch        # Watch mode\npnpm test -- -u             # Update snapshots\npnpm test -- --project=sanity  # Run specific project\n```\n\nTests require a build first because some tests use compiled output:\n\n```bash\npnpm build && pnpm test\n```\n\nOn macOS, use `TMPDIR=/private/tmp pnpm test` if the E2E summary reporter test fails with a\n`/var` versus `/private/var` path mismatch. The test changes its working directory, which resolves\nthe symlink; using a canonical temporary path keeps its expected and actual paths consistent.\n\n#### Types in tests\n\nApplies to every package's `*.test.ts` / `*.test.tsx` (and `*.browser.test.tsx`) files only.\nDo not use `as unknown as T` to force a value into a narrower public type. If a helper's\nreturn type is not in the union the function accepts, cover that behaviour through a surface\nthat already accepts the value, or drop that case from the typed call. Do not widen a public\nunion solely so a test type-checks. A single `as T` on a deliberately incomplete object is\nfine when the test is the error path for a missing field. This rule does not apply to\nproduction source.\n\n#### Prefer real implementations over test-file doubles\n\nDrive the unit through its production API and the same factories production uses.\nDo not add mock functions, stub components, or a parallel compiler to the test file\njust to reach a branch.\n\n- Compile schemas with Studio `createSchema` plus `defineType` / `defineField`\n  (`packages/sanity/src/core/schema/createSchema.ts`). That path validates, parents\n  the builtin schema, and runs `inferValidation`. Do not hand-roll\n  `@sanity/schema` `Schema.compile({types: [...]})` unless the test is about that\n  compiler.\n- Call the production function (`createPrepareFormState()`, an exported mapper)\n  without `vi.fn` decorator spies unless the suite is about call counts.\n- Typed lookups of a production union (`fieldMember`, `errorMember`) are accessors,\n  not doubles. A schema-construction helper for a depth fixture is fine. A second\n  fake form tree, or a local `vi.mock` of the module under test, is not.\n- A harness that needs a mock client and a page of `vi.fn` callbacks is the wrong\n  seam when the unit is a pure functio\n\n<!-- [TRUNCATED AT 50KB] -->","CLAUDE.md":"@AGENTS.md\n","packages/@repo/ailf/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Failf%2FREADME.md -->","packages/@repo/debug-proxy/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fdebug-proxy%2FREADME.md -->","packages/@repo/pandacss/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fpandacss%2FREADME.md -->","packages/@repo/release-notes/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Frelease-notes%2FREADME.md -->","packages/@sanity/diff/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fdiff%2FREADME.md -->","packages/@sanity/mutator/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fmutator%2FREADME.md -->","packages/@sanity/schema/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2FREADME.md -->","packages/@sanity/schema/src/descriptors/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2Fsrc%2Fdescriptors%2FREADME.md -->","packages/@sanity/util/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Futil%2FREADME.md -->","packages/@sanity/validation/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvalidation%2FREADME.md -->","packages/@sanity/vision/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvision%2FREADME.md -->","packages/groq/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fgroq%2FREADME.md -->","packages/sanity/AGENTS.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FAGENTS.md -->","packages/sanity/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FREADME.md -->","packages/sanity/src/core/error/sentry/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Ferror%2Fsentry%2FREADME.md -->","packages/sanity/src/core/scheduled-publishing/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2FREADME.md -->","packages/sanity/src/core/scheduled-publishing/components/dateInputs/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2Fcomponents%2FdateInputs%2FREADME.md -->","packages/sanity/src/core/store/events/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fstore%2Fevents%2FREADME.md -->","packages/sanity/src/core/variants/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fvariants%2FREADME.md -->","packages/sanity/src/router/README.md":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Frouter%2FREADME.md -->"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md - AI Agent Guidelines for Sanity Monorepo\n\nThis document helps AI agents work successfully with the Sanity monorepo.\n\n> **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.\n\n## Prerequisites\n\n- **Node.js**: v24 or latest LTS. Published packages must declare `\"engines\": { \"node\": \">=22.12\" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing).\n- **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json)\n\n## Quick Reference\n\n```bash\n# Install dependencies (pnpm ONLY - enforced)\npnpm install\n\n# Build all packages (required before testing)\npnpm build\n\n# Run dev studio (requires auth, see below)\npnpm dev\n\n# Format code (MUST pass CI)\npnpm chore:format:fix\n\n# Fix all lint issues (MUST pass CI) — includes TypeScript type checking via oxlint\npnpm lint:fix\n\n# Run tests\npnpm test\n\n# Update snapshots if tests fail due to expected changes\npnpm test -- -u\n\n# Lint + type check (oxlint typeAware + typeCheck; no separate tsc step)\npnpm check:oxlint\n```\n\n## CI Checks - What Must Pass\n\nThese checks run on every PR and **must pass**:\n\n| Check            | Command               | Notes                                                                                                                                                            |\n| ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Format**       | `pnpm check:format`   | Uses oxfmt. Fix with `pnpm chore:format:fix`                                                                                                                     |\n| **Oxlint**       | `pnpm check:oxlint`   | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix`                              |\n| **Unit Tests**   | `pnpm test`           | Vitest, sharded in CI                                                                                                                                            |\n| **Export Tests** | `pnpm test:exports`   | Ensures ESM/CJS/DTS work                                                                                                                                         |\n| **Dep Check**    | `pnpm depcheck`       | Finds unused/missing deps                                                                                                                                        |\n| **Zizmor**       | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. |\n| **PR Title**     | Conventional commits  | e.g., `feat(scope): description`                                                                                                                                 |\n\n### Before Committing\n\nRun these commands to avoid CI failures:\n\n```bash\n# Fix all formatting and lint issues\npnpm lint:fix\n\n# Verify tests pass (build first if needed)\npnpm build && pnpm test\n```\n\nIf tests fail due to **expected snapshot changes**, update them:\n\n```bash\npnpm test -- -u\n```\n\nSnapshot files are located in `__snapshots__` directories alongside test files.\n\n## Project Structure\n\n```\nsanity/\n├── packages/\n│   ├── sanity/           # Main Sanity studio package\n│   ├── @sanity/          # Scoped packages (cli, types, schema, etc.)\n│   └── @repo/            # Internal tooling (test-config, tsconfig, etc.)\n├── dev/                  # Development studios for testing\n│   ├── test-studio/      # Primary dev studio (pnpm dev runs this)\n│   ├── studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON\n│   └── preview-iframe/   # Presentation preview iframe (vanilla Vite, port 3334)\n├── e2e/                  # End-to-end Playwright tests\n├── perf/                 # Performance testing\n└── examples/             # Example studios\n```\n\n### Key Packages\n\n- **`packages/sanity`** - Core studio package with all UI components\n- **`packages/@sanity/types`** - TypeScript type definitions\n- **`packages/@sanity/schema`** - Schema compilation\n- **`packages/@sanity/mutator`** - Document mutation logic\n\n## Build System\n\n- **Package Manager**: pnpm (version 10.x, enforced via `preinstall`)\n- **Build Orchestration**: Turbo (caches builds)\n- **Versioning**: Lerna-lite with conventional commits\n\n### Build Commands\n\n```bash\npnpm build              # Build all packages\npnpm watch              # Watch mode for development\n```\n\n### Running the Dev Studio\n\n```bash\npnpm dev                # Starts dev studio at http://localhost:3333\n```\n\n**Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio.\n\n## Local Development\n\nThis section clarifies what requires authentication and what doesn't—critical for AI agents to avoid getting stuck on auth flows.\n\n### Running Tests (No Auth Required)\n\nUnit tests run in jsdom with mocks and **do not require any authentication**:\n\n```bash\n# Build first (required), then run all tests\npnpm build && pnpm test\n\n# Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests)\npnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts\n\n# Run a single test file with verbose output\npnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts\n\n# Watch mode for iterative development\npnpm test -- --watch\n\n# Run tests for a specific package\npnpm test -- --project=sanity\n```\n\n**Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file — it runs all tests across all projects. Use `pnpm vitest run --project=<project> <path>` instead.\n\nThe headless validation package is registered as `@sanity/validation`: run its full suite with\n`pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's\ncompilation path too: call `inferFromSchema` on the built-in schema before compiling custom types,\nthen call it on the compiled custom schema. Studio inherits already-normalized built-in rules.\n\nComponents that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes.\n\n### Running the Dev Studio (Auth Required)\n\n```bash\npnpm dev  # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334\n```\n\n- **Requires browser authentication** on first visit—you'll be prompted to log in with a Sanity account\n- Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`)\n- Uses staging API by default (`api.sanity.work`)\n- Session persists in browser, so subsequent visits won't require re-authentication\n- `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`.\n- Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`)\n\nUse the dev studio when you need to:\n\n- Visually verify UI changes\n- Test real document editing workflows\n- Debug issues that only appear with real data\n- Exercise Presentation / visual editing against the local preview iframe\n\n### Inspecting Production Builds with Vite DevTools\n\nThe test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` server—no restart needed.\n\n```bash\n# Builds the test studio with devtools enabled, then starts the dev server\n# (so there's a build session to inspect right away)\npnpm devtools:test-studio\n```\n\nOpen `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels.\n\nTo inspect a **new** build after making changes—while `pnpm devtools:test-studio` is still running—run in a second terminal:\n\n```bash\n# Creates a fresh build session that shows up in the running DevTools dock\npnpm devtools:test-studio:build\n```\n\nSessions can be compared against each other in the DevTools UI to diff bundle changes.\n\nHow it works:\n\n- Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools`\n- Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored)\n- The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes\n- Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag\n\n### Debugging presence with fake users\n\n**Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking).\n\n**URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: \"presence-debug\"`) and `show_own` (also show your own session).\n\n### Profiling studio re-renders with React DevTools (agent-react-devtools)\n\nThe test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI — made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling.\n\nFor `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern.\n\n```bash\n# 1. Start the daemon (port 8097)\npnpm --filter sanity-test-studio exec agent-react-devtools start\n\n# 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true)\npnpm react-devtools:test-studio\n\n# 3. Open http://localhost:3333 in a browser, then verify the app registered\npnpm --filter sanity-test-studio exec agent-react-devtools status\n\n# 4. Profile an interaction\npnpm --filter sanity-test-studio exec agent-react-devtools profile start\n# ... interact with the studio ...\npnpm --filter sanity-test-studio exec agent-react-devtools profile stop\npnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10\n```\n\nHow it works and gotchas:\n\n- `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) — it can never reach `sanity build` output.\n- **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs).\n- Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations — production studios don't run StrictMode.\n- Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas).\n\n### Analyzing the `sanity` package bundle\n\nThe `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`:\n\n```bash\npnpm analyze:sanity\n```\n\nThe report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown).\n\n### Auto-updating studio CSS (Lightning CSS `light-dark()`)\n\nThe CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance ≠ Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native — same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo.\n\n### Panda CSS\n\nThe static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in\n`.css.ts` modules next to the components, imported from the generated `#styled-system/css`\nruntime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on\n`pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the\n**only Panda project in the repository** — the `} satisfies Config)` pattern, no presets,\ncontent-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues`\n— and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity`\nwrites Panda CSS. The `migrate-styled-components-to-panda-css` skill\n(`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the\n`&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the\npackage, vanilla-extract stays where it is for now — the `.css.ts` modules of `@sanity/vision`\n(with its `vanillaExtract` tsdown option) and the test studio's\n`plugins/style-outline/styleOutline.css.ts` — until\n[#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps\nthat build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's\n`sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live\nin the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the\nVite apps that build the package from source get their Panda wiring from `@repo/pandacss`\n(`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those\nthemselves.\n\nHow the CSS reaches a page:\n\n- **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts`\n  (the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the\n  diff between two published versions stays readable, see below for the layers) rewrites every\n  `css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`,\n  injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional\n  `./bundle.css` export (`browser`/`style` → the stylesheet, `node`/`default` → a no-op shim).\n  The published code carries no Panda runtime, which is why the manifest resets `imports`\n  under `publishConfig`. A `css()` call the build cannot resolve statically (computed values,\n  loops) makes it warn and bundle the runtime — keep the style objects literal.\n- **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`,\n  Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace\n  packages to their sources (the `monorepo` export condition), so they bundle the package's\n  `.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite`\n  (`packages/@repo/pandacss`): `pandacss({cwd: <packages/sanity>, transform: true})` from\n  `@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its\n  `include` globs and the codegen `outdir` there), and\n  `removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from\n  `@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css`\n  (below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares\n  Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the\n  `@layer reset, base, tokens, recipes, utilities;` statement — the dev counterpart of the\n  `sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and\n  `packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts`\n  (`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import\n  injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin,\n  registered for dev only. A `sanity build` (and the viewer's build) resolves the built package\n  and its `bundle.css`, so nothing Panda runs there — which is also why a studio that installs\n  `sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS\n  support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and\n  keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from\n  every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the\n  test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's\n  modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is\n  wrapped so that its transform's hook filter excludes `packages/sanity/src/` — it would otherwise\n  compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css`\n  import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite`\n  directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test\n  asserts on styles.\n- **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`,\n  `recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats\n  every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and\n  selected states to any unlayered rule that also matches — styled-components output, a reset\n  like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's\n  write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after\n  Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before\n  its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and\n  the studio's rules cascade like the vanilla-extract output did: by specificity, then source\n  order. The same pass runs on the dev stylesheets and the CDN bundle through\n  `removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS\n  module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own\n  `panda.css`), and leaves other layers alone — which is why that file declares Panda's default\n  layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a\n  single `@layer`. Panda's `polyfill` option is\n  not an alternative: it emulates the layer order with `:not(#\\#…)` selector chains that inflate\n  every rule's specificity. The studio declares no `@layer` statement of its own either: with\n  `bundle.css` unlayered and `@sanity/ui` ≥ 4.4.8 (the catalog version) shipping its static styles\n  unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a\n  studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing\n  to order it against — the same situation as before the migration. What this means when\n  authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order\n  alone — a plain class beats a styled-components base rule that loads later only when it is more\n  specific, which is what the `&&` doubling is for.\n- **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct\n  `property: value` pair once, grouped by property with the values sorted; a modifier class that\n  must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`)\n  instead of relying on being declared later, and parent-state selectors key on data attributes\n  the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in\n  `ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted.\n\n### Studio performance benchmarks (perf/bench — No Auth Required)\n\nThe `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API — fully hermetic, no tokens, no network:\n\n```bash\npnpm build:bench                                   # build packages + bench studio (required first)\npnpm bench help                                    # list all bench CLI commands\npnpm bench run --scenario singleString             # absolute interaction benchmark\npnpm bench run --mode pageload --scenario singleString  # load vitals + bundle size\npnpm bench:unit                                    # mock-contract + stats unit tests\npnpm bench dev                                     # mock + `sanity dev` for interactive debugging\n```\n\nSee `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.)\n\nThree skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit).\n\n### Engine confidence score (No Auth Required locally)\n\nThe Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail (\"X of N known bugs caught\"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail.\n\n```bash\npnpm engine-confidence stryker --target mutator --full   # also: --target sanity; omit --full for an incremental run\npnpm engine-confidence catalogue                         # --suites unit:mutator,unit:engine skips fuzz; --only <ids>; --check-only\npnpm engine-confidence score                             # writes engine-confidence/reports/engine-confidence-run.json\npnpm engine-confidence store <file> --dry-run            # prints the document id and body, needs no token\npnpm engine-confidence pin                               # recomputes config.json's pinnedHash and bumps version when it changed\npnpm engine-confidence unit                              # the package's unit tests\n```\n\n- **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once.\n- **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards.\n- **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed.\n- **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check.\n- **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules.\n- **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only <id>` to confirm the verdict. `engine-confidence/README.md` has the steps.\n- **Where it shows:** the Radar \"Engine confidence\" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week.\n\n### E2E Tests (Token Required)\n\nE2E tests require authentication tokens. Add these to `.env.local` in the repo root:\n\n```bash\nSANITY_E2E_SESSION_TOKEN=<your-token>\nSANITY_E2E_PROJECT_ID=<project-id>\nSANITY_E2E_DATASET=<dataset-name>\n```\n\n**How to get a token:**\n\n```bash\n# Option 1: Use your CLI token\nsanity login\nsanity debug --secrets  # Look for \"Auth token\"\n\n# Option 2: Create a project token at https://sanity.io/manage\n# Navigate to: Project Settings → API → Tokens → Add API token\n```\n\nThen run E2E tests:\n\n```bash\npnpm e2e:build              # Build E2E studio\npnpm test:e2e               # Run E2E tests\npnpm test:e2e --ui          # Interactive mode\n```\n\n**Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests.\n\nWhen CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `<report-url>/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt).\n\n#### Diagnosing e2e flake: failure diagnostics and the flake report\n\nEvery failed or timed-out e2e test attempt attaches `studio-diagnostics.json` — the same JSON the Studio Diagnostics dialog's \"Copy output\" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog (\"Too many requests\", server error, network error) when it is showing — the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`.\n\nTo turn those captures into numbers (\"how many failed runs were platform-caused?\"), run the flake report:\n\n```bash\npnpm e2e:flake-report --days 7 --out flake-report.md   # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login`\npnpm e2e:flake-report --help\npnpm --filter e2e test:unit                              # classifier unit tests\n```\n\nIt lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact.\n\nStaging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does.\n\n### Important Note for AI Agents\n\n**What requires authentication:**\n\n- Running the dev studio (`pnpm dev`)\n- E2E tests (`pnpm test:e2e`)\n- Any command that connects to Sanity APIs\n\n**What does NOT require authentication:**\n\n- Building packages (`pnpm build`)\n- Running unit tests (`pnpm test`)\n- Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`)\n\n**Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed.\n\n## Coding Standards\n\nCoding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` — no separate `tsc` step. Check your code with:\n\n```bash\npnpm lint              # Check for issues (oxlint, includes type checking)\npnpm lint:fix          # Auto-fix issues (oxfmt + oxlint --fix)\n```\n\nAll packages use **ESM** (`\"type\": \"module\"`). TypeScript strict mode is enabled.\n\nRules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide — run `pnpm lint` and follow the reported messages, which explain the expected pattern.\n\n### Do Not Weaken the Linter\n\nFix the reported problem instead of silencing it. In order of preference:\n\n1. **Fix the code** so the rule passes. This is almost always the right answer.\n2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line <rule> -- <why>`. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green.\n3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative — an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description.\n\nFile-wide `/* oxlint-disable <rule> */` is reserved for files that are an exception as a whole — vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors.\n\n`options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI — drop suppressions when the code underneath them changes.\n\n### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n\nEvery declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the\n`@internal` TSDoc release tag is also exported from\n`sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n(`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`),\nan entry whose name spells out its contract: anything on it can change or disappear in any\nrelease. The public entries still export those symbols today; the entry is the migration target\nfor consumers that depend on them, ahead of the public entries dropping them in a future major.\n`packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of\n`pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry,\nwhen the two entries export different declarations under one name, or when the entry exports\nsomething that is not tagged `@internal`.\n\nRules that follow from this:\n\n- A new `@internal` export on a public barrel needs a matching line in the internals barrel\n  (grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity`\n  (`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from\n  `sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing\n  relatively: the entry re-exports modules from all three areas, so importing it from any of them\n  is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in\n  with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the\n  three areas.\n- The entry is generated from the tags, not curated: whether a symbol belongs there is decided by\n  its `@internal` tag. Retagging a symbol (`@internal` → `@beta`/`@public`, or the other way) is\n  the API decision; check sanity.io/docs and the published first-party plugins before demoting a\n  symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the\n  studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure\n  `renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`,\n  `generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one\n  deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its\n  `@internal @deprecated` tags because those tell customers to stop using it, it stays off the\n  entry so the deprecated `sanity` import remains the only one, and it is listed in\n  `PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by\n  the internals moving. Do not retag it.\n- The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly;\n  a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a\n  404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports`\n  regenerates the d.ts fixture for the entry after its export list changes.\n- A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`,\n  a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the\n  entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u`\n  regenerates it without a build.\n\n### An entry's type surface is what its barrel exports, nothing more\n\nA bundled `.d.ts` can only name declarations the entry barrel actually exports. Until\n`rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {…}` clause\nhit TypeScript's rule that every top-level declaration of such a declaration file is implicitly\nexported, so private helpers pulled in for a signature became part of the published type surface —\nincluding `const`s and `function`s that the emitted `.js` never exported, so importing them\ntype-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead\n([\"prevent implicit exports of private declarations\"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)),\nso only explicit exports are public.\n\nThat makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a\nname no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check\neach dropped name before regenerating:\n\n- A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as\n  a type-only re-export (`export {type Foo} from '…'`), which leaves the emitted `.js` untouched.\n- A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup\n  artifact that was never API — `sanity/media-library` emitted one because `VideoDefinition` is\n  declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared\n  twice, pick which one is public by convention, not by which one the old output left unsuffixed:\n  that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like\n  `ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`,\n  while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation.\n- A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported\n  it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost\n  more than it looks — re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into\n  `sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away.\n- Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from\n  `createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and\n  `lib-check.test.ts` runs with `skipLibCheck: false`.\n\nRegenerating needs a full `pnpm build` first: the generator reads each package's built\n`lib/**/*.d.ts`.\n\n### Effect events: use `use-effect-event`, not React's native hook\n\nImport `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook\nreturns first-render values when the calling component is wrapped in `forwardRef` or `memo`\n([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries).\n`eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that\nwraps the native hook, so check the implementation before trusting one.\n\n### react-rx: stable observables, explicit initial values\n\n`react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the\n`initialValue` (required — pass `undefined` explicitly when there is nothing better) until the\nsubscription started on commit delivers a value, and a synchronous emission arrives one pass later.\nTwo rules follow:\n\n- **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a\n  store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than\n  on arrays or objects recreated every render. An observable rebuilt each render is torn down and\n  re-subscribed each commit; when it synchronously replays a value that differs from the\n  `initialValue`, React aborts with \"Maximum update depth exceeded\"\n  (`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case).\n- **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a\n  first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's\n  exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render\n  when it depends on the observable's parameters (`useEditState`), since react-rx captures\n  `initialValue` once per hook instance.\n\n### Translate: never define `components` inline\n\nComponents passed to `<Translate>`'s `components` map must be stable, module-scope components —\ndefining them inline during render creates a new component identity every render and remounts\nthe subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain\nHTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule\n`@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces\nthis for object literals in the JSX attribute; maps built during render via `useMemo` or factory\nfunctions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate`\nskill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns.\n\n### Locale namespaces: plugin strings stay inside the plugin\n\nOnly `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always\nincluded) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`,\n`structure`, ...) is registered by a plugin that a studio can leave out, and a component that\nreads it while that plugin is not loaded renders raw keys. Strings for code that renders outside\na plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two\nin-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and\n`@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace\nthey are read from). Both read the namespace table in\n`packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or\nchanging which plugin registers one.\n\n### `<Activity mode=\"hidden\">`: effects are torn down, DOM and state stay\n\nReact's `<Activity>` is used for closed `@sanity/ui` overlays, collapsed document lists, and the\ntop-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs\nevery effect cleanup in the subtree and showing runs the effects again, without a remount, so:\n\n- Anything that lives only in an effect-created object is lost on reveal unless the effect can\n  rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup\n  and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for\n  that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal.\n- Loading states come back on reveal unless the code remembers it already loaded. A comlink\n  channel recreated by an effect handshakes as a brand new connection (the presentation machine\n  tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a\n  `startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes\n  it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context\n  populated by registering in an effect empties out while hidden, unmounting whatever renders\n  from it (`PresentationDocumentProvider` lists its own options at render time).\n- Never reorder keyed siblings that hold an `<iframe>`. React moves the DOM node when the key\n  order changes, and a re-inserted iframe reloads. `useMountedTools` renders tools in workspace\n  order for this reason even though eviction is least-recently-used.\n- `@sanity/ui`'s `useElementSize` (4.2.4) stops reporting after a reveal. Its shared\n  `_elementSizeObserver` keeps one `ResizeObserver` per element and disconnects it when the last\n  subscriber leaves, but never forgets the element, so the effect re-run after a reveal finds the\n  cached entry and creates no new observer: the hook keeps the size from before hiding, also for\n  later resizes. Any layout derived from it inside a tool (Vision's column/stacked breakpoints,\n  `PaneLayout`, `DocumentLayout`, the PTE toolbar) freezes. Vision's redesign measures with its\n  own `useContentSize`, which observes afresh on every effect run; the fix belongs in `@sanity/ui`.\n- Hidden content keeps `display: none` on its topmost host nodes only, and portals under the\n  boundary are hidden too (`hideOrUnhideNearestPortals`); descendants report their own `display`.\n\n### Refs: use `props.ref`, not `forwardRef`\n\nReact 19 passes `ref` as a regular prop. Do not use `forwardRef` — destructure `ref` from props\n(so it is not left in a `...rest` spread) and forward it like any other prop.\n`eslint/no-restricted-imports` bans importing `forwardRef` from `react`.\n\nPrefer a named function declaration over `const X = function …` / arrow wrappers:\n\n```ts\n// preferred\nexport function MyComponent(props: Props & RefAttributes<HTMLDivElement>) {\n  const {ref, ...rest} = props\n  return <div ref={ref} {...rest} />\n}\n\n// avoid\nexport const MyComponent = function MyComponent(props: …) { … }\nexport const MyComponent = (props: …) => { … }\n```\n\nWhen wrapping with `memo`, declare the component as a function first, then memoize:\n\n```ts\nfunction MyComponent(props: …) { … }\nexport const MyComponentMemo = memo(MyComponent)\n```\n\nDo not assign `.displayName` on components, HOCs, styled components, or `createContext`\nresults. That property write is a module-level side effect and keeps the export from being\ntree-shaken. Named function declarations already give React DevTools a name via\n`function.name`. Exception (temporary): document/badge hooks prefixed with `use` and the\nHookState collection still set `displayName` — leave those until that API is reworked.\n\nFor typings, include `ref` on the props type: stop omitting `'ref'` from `HTMLProps` /\n`ComponentProps`, or intersect with `RefAttributes<T>`. Avoid `PropsWithRef` — in `@types/react`\n19 it is a deprecated identity alias and trips `typescript/no-deprecated`.\n\n## Testing\n\n### Unit Tests (Vitest)\n\n```bash\npnpm test                    # Run all tests\npnpm test -- --watch        # Watch mode\npnpm test -- -u             # Update snapshots\npnpm test -- --project=sanity  # Run specific project\n```\n\nTests require a build first because some tests use compiled output:\n\n```bash\npnpm build && pnpm test\n```\n\nOn macOS, use `TMPDIR=/private/tmp pnpm test` if the E2E summary reporter test fails with a\n`/var` versus `/private/var` path mismatch. The test changes its working directory, which resolves\nthe symlink; using a canonical temporary path keeps its expected and actual paths consistent.\n\n#### Types in tests\n\nApplies to every package's `*.test.ts` / `*.test.tsx` (and `*.browser.test.tsx`) files only.\nDo not use `as unknown as T` to force a value into a narrower public type. If a helper's\nreturn type is not in the union the function accepts, cover that behaviour through a surface\nthat already accepts the value, or drop that case from the typed call. Do not widen a public\nunion solely so a test type-checks. A single `as T` on a deliberately incomplete object is\nfine when the test is the error path for a missing field. This rule does not apply to\nproduction source.\n\n#### Prefer real implementations over test-file doubles\n\nDrive the unit through its production API and the same factories production uses.\nDo not add mock functions, stub components, or a parallel compiler to the test file\njust to reach a branch.\n\n- Compile schemas with Studio `createSchema` plus `defineType` / `defineField`\n  (`packages/sanity/src/core/schema/createSchema.ts`). That path validates, parents\n  the builtin schema, and runs `inferValidation`. Do not hand-roll\n  `@sanity/schema` `Schema.compile({types: [...]})` unless the test is about that\n  compiler.\n- Call the production function (`createPrepareFormState()`, an exported mapper)\n  without `vi.fn` decorator spies unless the suite is about call counts.\n- Typed lookups of a production union (`fieldMember`, `errorMember`) are accessors,\n  not doubles. A schema-construction helper for a depth fixture is fine. A second\n  fake form tree, or a local `vi.mock` of the module under test, is not.\n- A harness that needs a mock client and a page of `vi.fn` callbacks is the wrong\n  seam when the unit is a pure functio\n\n<!-- [TRUNCATED AT 50KB] -->","isInternal":false,"tokens":11916,"sizeBytes":50224},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"@AGENTS.md\n","isInternal":false,"tokens":5,"sizeBytes":11},{"name":"SKILL.md","path":".agents/skills/before-and-after/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/before-and-after/SKILL.md","title":"before-and-after","category":"anthropic-skill","format":"markdown","content":"---\nname: before-and-after\ndescription: Add existing screenshots or screen recordings to a GitHub pull request as a before/after or preview block. Use when a PR needs visual media attached to its description. Browser navigation and capture belong to agent-browser.\n---\n\n# Add visual media to a PR\n\nUse `agent-browser` to create the screenshots or recordings. This skill only owns the GitHub PR attachment workflow.\n\n## Capture\n\n1. Load the version-matched core instructions with `agent-browser skills get core --full` and follow them for sessions, navigation, page state, screenshots, and recordings.\n2. If a Vercel URL is protected, load `agent-browser skills get protected-vercel-deployments --full`. Do not reproduce its authentication workflow here.\n3. Save media under the repository with paths that contain no whitespace, for example:\n\n   ```text\n   captures/desktop-before.png\n   captures/desktop-after.png\n   captures/mobile-before.png\n   captures/mobile-after.png\n   ```\n\nThe formatter supports PNG, JPEG, GIF, WebP, MP4, MOV, and WebM files. Use an `--after` file without a matching `--before` file for a net-new preview.\n\n### Screen recordings\n\n`agent-browser record start` creates a fresh browser context. It preserves cookies and local storage, but an origin-scoped header used to open a protected Vercel Preview may not carry into that new context. Before recording a protected Preview:\n\n1. Load `agent-browser skills get protected-vercel-deployments --full`.\n2. Start the recording context on a blank page.\n3. Apply the authentication method from that skill inside the recording context.\n4. Navigate to the Preview only after authentication is active, then trim the navigation lead-in if necessary.\n\nDo not assume that a page which worked before `record start` will remain authenticated after it.\n\nAgent-browser `0.35.2` and `0.36.0` capture at a hardcoded 10 fps with no CLI or environment override. Inspect the installed version and its recording reference rather than assuming this stays true in later releases. Preserve the source cadence when transcoding: changing the container to 30 or 60 fps only duplicates frames and does not make motion smoother. For animation evidence that requires a higher real frame rate, use a genuinely higher-cadence capture path instead of upsampling agent-browser output.\n\n### Equal-height image pairs\n\nGitHub vertically centers a shorter image inside a Markdown table cell. For full-page before/after screenshots, make both files the same pixel height so their top edges align:\n\n1. Open both pages at the same viewport and state.\n2. Read `document.documentElement.scrollHeight` in both sessions.\n3. Append bottom-only space to the shorter page until both scroll heights match, then take both `--full` screenshots.\n\nThe padding may be transparent or use the capture tool's default canvas. Never add space above the page. For component or section comparisons, capture the same scoped region instead of padding unrelated page content.\n\nUse raw `agent-browser eval` for this DOM-only adjustment; do not add an image-processing dependency to this skill. Confirm the resulting files have equal pixel dimensions before publishing.\n\n## Format\n\nPass one `--before` and `--after` pair for each comparison. Repeat `--label` to identify multiple pairs:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  --before captures/mobile-before.png \\\n  --after captures/mobile-after.png \\\n  --label Desktop \\\n  --label Mobile \\\n  > /tmp/before-and-after.md\n```\n\nFor an after-only preview:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --after captures/new-page.png \\\n  > /tmp/before-and-after.md\n```\n\nAdd `--attribution \"<name>\"` to prefix the block with a `> Before/after by <name>` line when the PR should credit who produced the evidence.\n\nImages render in tables. Local videos initially render on their own lines so `gh --attach` can upload them and expose their final attachment URLs. Before/after video tables use the two-step workflow below because `gh --attach` does not rewrite local references inside `<video src>` attributes.\n\n## Place the evidence\n\nRead the existing PR description before inserting a new marked block. Put visual evidence near the top, after the short opening context and an existing Preview or deployment-link section when present, but before implementation-heavy sections such as Details, Changes, Testing, or Notes.\n\nUse this reading order inside the visual evidence:\n\n1. Put the real before/after or Preview evidence that proves the PR first.\n2. Put supplemental formats, alternate states, or demonstrations after the primary evidence.\n3. Label anything that demonstrates this skill rather than the PR itself as a demo, and state material limitations beside it. For example, note the installed agent-browser recorder's current 10 fps limit when motion smoothness matters.\n\nHeadings are semantic hints, not required names. Never invent or rewrite prose merely to create an anchor, and never split a paragraph, list, table, code block, or other Markdown structure. If no safe anchor is clear, append the block rather than risking damage. If a marked block already exists, move or replace that whole block only; preserve every byte of unrelated prose.\n\nAfter publishing, open the rendered PR and confirm the primary evidence appears before supplemental demos and before the implementation details.\n\n## Publish\n\nPreserve the existing PR description and replace only this skill's marked block:\n\n```bash\nPR=123\ngh pr view \"$PR\" --json body --jq .body > /tmp/pr-body.md\n\nnode skill/scripts/format.mjs \\\n  --body-file /tmp/pr-body.md \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  > /tmp/pr-body-next.md\n\nATTACH_ARGS=()\nwhile IFS= read -r file; do\n  ATTACH_ARGS+=(--attach \"$file\")\ndone < <(\n  node skill/scripts/format.mjs \\\n    --attach-list \\\n    --before captures/desktop-before.png \\\n    --after captures/desktop-after.png\n)\n\ngh pr edit \"$PR\" --body-file /tmp/pr-body-next.md \"${ATTACH_ARGS[@]}\"\n```\n\nRun the formatter and `gh` from the same directory. `gh --attach` uploads the local files to GitHub and rewrites their matching local references in the PR body.\n\nAfter publishing, fetch or open the PR description and confirm that no `./captures/...` references remain inside the marked block and that the evidence appears in the intended reading order.\n\n### Publish a video table\n\nVideo comparisons are a first-class two-step publish operation:\n\n1. Upload the local videos in a temporary PR comment using the normal own-line output and `gh pr comment --attach`.\n2. Fetch that comment through `gh api` and collect the stable `https://github.com/user-attachments/assets/...` URLs in before/after order.\n3. Generate the final HTML table from those URLs and replace the marked PR block:\n\n   ```bash\n   node skill/scripts/format.mjs \\\n     --body-file /tmp/pr-body.md \\\n     --before-video-url https://github.com/user-attachments/assets/BEFORE_ID \\\n     --after-video-url https://github.com/user-attachments/assets/AFTER_ID \\\n     --label \"Desktop hero\" \\\n     > /tmp/pr-body-next.md\n\n   gh pr edit \"$PR\" --body-file /tmp/pr-body-next.md\n   ```\n\n4. Fetch the edited PR body before deleting the temporary comment. Confirm both final URLs are present and no local video paths remain, then delete the comment.\n5. Open the rendered PR and confirm both `<video>` elements are inside the comparison table, reach a playable ready state, and show controls.\n\nIf URL extraction, formatting, or PR verification fails, keep the temporary comment so its uploaded attachments remain recoverable and retry from the last successful phase. Use own-line videos as the simple fallback.\n\nDo not publish captures containing Vercel OIDC tokens, bypass secrets, authenticated query parameters, or browser state files.\n\n## Script contract\n\n`scripts/format.mjs` is intentionally the only bundled script. It:\n\n- formats existing local media;\n- formats final GitHub video attachment URLs as HTML comparison tables;\n- labels after-only media as `Preview`;\n- emits the exact attachment path list;\n- inserts or replaces `<!-- before-and-after:start/end -->` without changing other PR prose.\n\nIts arguments version with this skill and are not a public library API.\n","frontmatter":{"name":"before-and-after","description":"Add existing screenshots or screen recordings to a GitHub pull request as a before/after or preview block. Use when a PR needs visual media attached to its description. Browser navigation and capture belong to agent-browser."},"isInternal":false,"tokens":1747,"sizeBytes":8298},{"name":"SKILL.md","path":".agents/skills/code-review-and-quality/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/code-review-and-quality/SKILL.md","title":"code-review-and-quality","category":"anthropic-skill","format":"markdown","content":"---\nname: code-review-and-quality\ndescription: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.\n---\n\n# Code Review and Quality\n\n## Overview\n\nMulti-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.\n\n**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.\n\n## When to Use\n\n- Before merging any PR or change\n- After completing a feature implementation\n- When another agent or model produced code you need to evaluate\n- When refactoring existing code\n- After any bug fix (review both the fix and the regression test)\n\n## The Five-Axis Review\n\nEvery review evaluates code across these dimensions:\n\n### 1. Correctness\n\nDoes the code do what it claims to do?\n\n- Does it match the spec or task requirements?\n- Are edge cases handled (null, empty, boundary values)?\n- Are error paths handled (not just the happy path)?\n- Does it pass all tests? Are the tests actually testing the right things?\n- Are there off-by-one errors, race conditions, or state inconsistencies?\n\n### 2. Readability & Simplicity\n\nCan another engineer (or agent) understand this code without the author explaining it?\n\n- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)\n- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?\n- Is the code organized logically (related code grouped, clear module boundaries)?\n- Are there any \"clever\" tricks that should be simplified?\n- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)\n- **Are abstractions earning their complexity?** (Don't generalize until the third use case)\n- Would comments help clarify non-obvious intent? (But don't comment obvious code.)\n- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?\n\n### 3. Architecture\n\nDoes the change fit the system's design?\n\n- Does it follow existing patterns or introduce a new one? If new, is it justified?\n- Does it maintain clean module boundaries?\n- Is there code duplication that should be shared?\n- Are dependencies flowing in the right direction (no circular dependencies)?\n- Is the abstraction level appropriate (not over-engineered, not too coupled)?\n\n### 4. Security\n\nFor detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?\n\n- Is user input validated and sanitized?\n- Are secrets kept out of code, logs, and version control?\n- Is authentication/authorization checked where needed?\n- Are SQL queries parameterized (no string concatenation)?\n- Are outputs encoded to prevent XSS?\n- Are dependencies from trusted sources with no known vulnerabilities?\n- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?\n- Are external data flows validated at system boundaries before use in logic or rendering?\n\n### 5. Performance\n\nFor detailed profiling and optimization, see `performance-optimization`. Does the change introduce performance problems?\n\n- Any N+1 query patterns?\n- Any unbounded loops or unconstrained data fetching?\n- Any synchronous operations that should be async?\n- Any unnecessary re-renders in UI components?\n- Any missing pagination on list endpoints?\n- Any large objects created in hot paths?\n\n## Change Sizing\n\nSmall, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:\n\n```\n~100 lines changed   → Good. Reviewable in one sitting.\n~300 lines changed   → Acceptable if it's a single logical change.\n~1000 lines changed  → Too large. Split it.\n```\n\n**What counts as \"one change\":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.\n\n**Splitting strategies when a change is too large:**\n\n| Strategy          | How                                                     | When                    |\n| ----------------- | ------------------------------------------------------- | ----------------------- |\n| **Stack**         | Submit a small change, start the next one based on it   | Sequential dependencies |\n| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns  |\n| **Horizontal**    | Create shared code/stubs first, then consumers          | Layered architecture    |\n| **Vertical**      | Break into smaller full-stack slices of the feature     | Feature work            |\n\n**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.\n\n**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.\n\n## Change Descriptions\n\nEvery change needs a description that stands alone in version control history.\n\n**First line:** Short, imperative, standalone. \"Delete the FizzBuzz RPC\" not \"Deleting the FizzBuzz RPC.\" Must be informative enough that someone searching history can understand the change without reading the diff.\n\n**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.\n\n**Anti-patterns:** \"Fix bug,\" \"Fix build,\" \"Add patch,\" \"Moving code from A to B,\" \"Phase 1,\" \"Add convenience functions.\"\n\n## Review Process\n\n### Step 1: Understand the Context\n\nBefore looking at code, understand the intent:\n\n```\n- What is this change trying to accomplish?\n- What spec or task does it implement?\n- What is the expected behavior change?\n```\n\n### Step 2: Review the Tests First\n\nTests reveal intent and coverage:\n\n```\n- Do tests exist for the change?\n- Do they test behavior (not implementation details)?\n- Are edge cases covered?\n- Do tests have descriptive names?\n- Would the tests catch a regression if the code changed?\n```\n\n### Step 3: Review the Implementation\n\nWalk through the code with the five axes in mind:\n\n```\nFor each file changed:\n1. Correctness: Does this code do what the test says it should?\n2. Readability: Can I understand this without help?\n3. Architecture: Does this fit the system?\n4. Security: Any vulnerabilities?\n5. Performance: Any bottlenecks?\n```\n\n### Step 4: Categorize Findings\n\nLabel every comment with its severity so the author knows what's required vs optional:\n\n| Prefix                        | Meaning            | Author Action                                           |\n| ----------------------------- | ------------------ | ------------------------------------------------------- |\n| _(no prefix)_                 | Required change    | Must address before merge                               |\n| **Critical:**                 | Blocks merge       | Security vulnerability, data loss, broken functionality |\n| **Nit:**                      | Minor, optional    | Author may ignore — formatting, style preferences       |\n| **Optional:** / **Consider:** | Suggestion         | Worth considering but not required                      |\n| **FYI**                       | Informational only | No action needed — context for future reference         |\n\nThis prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.\n\n### Step 5: Verify the Verification\n\nCheck the author's verification story:\n\n```\n- What tests were run?\n- Did the build pass?\n- Was the change tested manually?\n- Are there screenshots for UI changes?\n- Is there a before/after comparison?\n```\n\n## Multi-Model Review Pattern\n\nUse different models for different review perspectives:\n\n```\nModel A writes the code\n    │\n    ▼\nModel B reviews for correctness and architecture\n    │\n    ▼\nModel A addresses the feedback\n    │\n    ▼\nHuman makes the final call\n```\n\nThis catches issues that a single model might miss — different models have different blind spots.\n\n**Example prompt for a review agent:**\n\n```\nReview this code change for correctness, security, and adherence to\nour project conventions. The spec says [X]. The change should [Y].\nFlag any issues as Critical, Important, or Suggestion.\n```\n\n## Dead Code Hygiene\n\nAfter any refactoring or implementation change, check for orphaned code:\n\n1. Identify code that is now unreachable or unused\n2. List it explicitly\n3. **Ask before deleting:** \"Should I remove these now-unused elements: [list]?\"\n\nDon't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.\n\n```\nDEAD CODE IDENTIFIED:\n- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()\n- OldTaskCard component in src/components/ — replaced by TaskCard\n- LEGACY_API_URL constant in src/config.ts — no remaining references\n→ Safe to remove these?\n```\n\n## Review Speed\n\nSlow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.\n\n- **Respond within one business day** — this is the maximum, not the target\n- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day\n- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed\n- **Large changes:** Ask the author to split them rather than reviewing one massive changeset\n\n## Handling Disagreements\n\nWhen resolving review disputes, apply this hierarchy:\n\n1. **Technical facts and data** override opinions and preferences\n2. **Style guides** are the absolute authority on style matters\n3. **Software design** must be evaluated on engineering principles, not personal preference\n4. **Codebase consistency** is acceptable if it doesn't degrade overall health\n\n**Don't accept \"I'll clean it up later.\"** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.\n\n## Honesty in Review\n\nWhen reviewing code — whether written by you, another agent, or a human:\n\n- **Don't rubber-stamp.** \"LGTM\" without evidence of review helps no one.\n- **Don't soften real issues.** \"This might be a minor concern\" when it's a bug that will hit production is dishonest.\n- **Quantify problems when possible.** \"This N+1 query will add ~50ms per item in the list\" is better than \"this could be slow.\"\n- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.\n- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.\n\n## Dependency Discipline\n\nPart of code review is dependency review:\n\n**Before adding any dependency:**\n\n1. Does the existing stack solve this? (Often it does.)\n2. How large is the dependency? (Check bundle impact.)\n3. Is it actively maintained? (Check last commit, open issues.)\n4. Does it have known vulnerabilities? (`npm audit`)\n5. What's the license? (Must be compatible with the project.)\n\n**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.\n\n## The Review Checklist\n\n```markdown\n## Review: [PR/Change title]\n\n### Context\n\n- [ ] I understand what this change does and why\n\n### Correctness\n\n- [ ] Change matches spec/task requirements\n- [ ] Edge cases handled\n- [ ] Error paths handled\n- [ ] Tests cover the change adequately\n\n### Readability\n\n- [ ] Names are clear and consistent\n- [ ] Logic is straightforward\n- [ ] No unnecessary complexity\n\n### Architecture\n\n- [ ] Follows existing patterns\n- [ ] No unnecessary coupling or dependencies\n- [ ] Appropriate abstraction level\n\n### Security\n\n- [ ] No secrets in code\n- [ ] Input validated at boundaries\n- [ ] No injection vulnerabilities\n- [ ] Auth checks in place\n- [ ] External data sources treated as untrusted\n\n### Performance\n\n- [ ] No N+1 patterns\n- [ ] No unbounded operations\n- [ ] Pagination on list endpoints\n\n### Verification\n\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] Manual verification done (if applicable)\n\n### Verdict\n\n- [ ] **Approve** — Ready to merge\n- [ ] **Request changes** — Issues must be addressed\n```\n\n## See Also\n\n- For detailed security review guidance, see `references/security-checklist.md`\n- For performance review checks, see `references/performance-checklist.md`\n\n## Common Rationalizations\n\n| Rationalization                      | Reality                                                                                                                   |\n| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |\n| \"It works, that's good enough\"       | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds.                           |\n| \"I wrote it, so I know it's correct\" | Authors are blind to their own assumptions. Every change benefits from another set of eyes.                               |\n| \"We'll clean it up later\"            | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after.                      |\n| \"AI-generated code is probably fine\" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong.                                     |\n| \"The tests pass, so it's good\"       | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |\n\n## Red Flags\n\n- PRs merged without any review\n- Review that only checks if tests pass (ignoring other axes)\n- \"LGTM\" without evidence of actual review\n- Security-sensitive changes without security-focused review\n- Large PRs that are \"too big to review properly\" (split them)\n- No regression tests with bug fix PRs\n- Review comments without severity labels — makes it unclear what's required vs optional\n- Accepting \"I'll fix it later\" — it never happens\n\n## Verification\n\nAfter review is complete:\n\n- [ ] All Critical issues are resolved\n- [ ] All Important issues are resolved or explicitly deferred with justification\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] The verification story is documented (what changed, how it was verified)\n","frontmatter":{"name":"code-review-and-quality","description":"Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch."},"isInternal":false,"tokens":3121,"sizeBytes":15240},{"name":"SKILL.md","path":".agents/skills/code-simplification/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/code-simplification/SKILL.md","title":"code-simplification","category":"anthropic-skill","format":"markdown","content":"---\nname: code-simplification\ndescription: Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity.\n---\n\n# Code Simplification\n\n> Inspired by the [Claude Code Simplifier plugin](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/code-simplifier/agents/code-simplifier.md). Adapted here as a model-agnostic, process-driven skill for any AI coding agent.\n\n## Overview\n\nSimplify code by reducing complexity while preserving exact behavior. The goal is not fewer lines — it's code that is easier to read, understand, modify, and debug. Every simplification must pass a simple test: \"Would a new team member understand this faster than the original?\"\n\n## When to Use\n\n- After a feature is working and tests pass, but the implementation feels heavier than it needs to be\n- During code review when readability or complexity issues are flagged\n- When you encounter deeply nested logic, long functions, or unclear names\n- When refactoring code written under time pressure\n- When consolidating related logic scattered across files\n- After merging changes that introduced duplication or inconsistency\n\n**When NOT to use:**\n\n- Code is already clean and readable — don't simplify for the sake of it\n- You don't understand what the code does yet — comprehend before you simplify\n- The code is performance-critical and the \"simpler\" version would be measurably slower\n- You're about to rewrite the module entirely — simplifying throwaway code wastes effort\n\n## The Five Principles\n\n### 1. Preserve Behavior Exactly\n\nDon't change what the code does — only how it expresses it. All inputs, outputs, side effects, error behavior, and edge cases must remain identical. If you're not sure a simplification preserves behavior, don't make it.\n\n```\nASK BEFORE EVERY CHANGE:\n→ Does this produce the same output for every input?\n→ Does this maintain the same error behavior?\n→ Does this preserve the same side effects and ordering?\n→ Do all existing tests still pass without modification?\n```\n\n### 2. Follow Project Conventions\n\nSimplification means making code more consistent with the codebase, not imposing external preferences. Before simplifying:\n\n```\n1. Read CLAUDE.md / project conventions\n2. Study how neighboring code handles similar patterns\n3. Match the project's style for:\n   - Import ordering and module system\n   - Function declaration style\n   - Naming conventions\n   - Error handling patterns\n   - Type annotation depth\n```\n\nSimplification that breaks project consistency is not simplification — it's churn.\n\n### 3. Prefer Clarity Over Cleverness\n\nExplicit code is better than compact code when the compact version requires a mental pause to parse.\n\n```typescript\n// UNCLEAR: Dense ternary chain\nconst label = isNew ? 'New' : isUpdated ? 'Updated' : isArchived ? 'Archived' : 'Active'\n\n// CLEAR: Readable mapping\nfunction getStatusLabel(item: Item): string {\n  if (item.isNew) return 'New'\n  if (item.isUpdated) return 'Updated'\n  if (item.isArchived) return 'Archived'\n  return 'Active'\n}\n```\n\n```typescript\n// UNCLEAR: Chained reduces with inline logic\nconst result = items.reduce(\n  (acc, item) => ({\n    ...acc,\n    [item.id]: {...acc[item.id], count: (acc[item.id]?.count ?? 0) + 1},\n  }),\n  {},\n)\n\n// CLEAR: Named intermediate step\nconst countById = new Map<string, number>()\nfor (const item of items) {\n  countById.set(item.id, (countById.get(item.id) ?? 0) + 1)\n}\n```\n\n### 4. Maintain Balance\n\nSimplification has a failure mode: over-simplification. Watch for these traps:\n\n- **Inlining too aggressively** — removing a helper that gave a concept a name makes the call site harder to read\n- **Combining unrelated logic** — two simple functions merged into one complex function is not simpler\n- **Removing \"unnecessary\" abstraction** — some abstractions exist for extensibility or testability, not complexity\n- **Optimizing for line count** — fewer lines is not the goal; easier comprehension is\n\n### 5. Scope to What Changed\n\nDefault to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions.\n\n## The Simplification Process\n\n### Step 1: Understand Before Touching (Chesterton's Fence)\n\nBefore changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies.\n\n```\nBEFORE SIMPLIFYING, ANSWER:\n- What is this code's responsibility?\n- What calls it? What does it call?\n- What are the edge cases and error paths?\n- Are there tests that define the expected behavior?\n- Why might it have been written this way? (Performance? Platform constraint? Historical reason?)\n- Check git blame: what was the original context for this code?\n```\n\nIf you can't answer these, you're not ready to simplify. Read more context first.\n\n### Step 2: Identify Simplification Opportunities\n\nScan for these patterns — each one is a concrete signal, not a vague smell:\n\n**Structural complexity:**\n\n| Pattern                    | Signal                             | Simplification                                            |\n| -------------------------- | ---------------------------------- | --------------------------------------------------------- |\n| Deep nesting (3+ levels)   | Hard to follow control flow        | Extract conditions into guard clauses or helper functions |\n| Long functions (50+ lines) | Multiple responsibilities          | Split into focused functions with descriptive names       |\n| Nested ternaries           | Requires mental stack to parse     | Replace with if/else chains, switch, or lookup objects    |\n| Boolean parameter flags    | `doThing(true, false, true)`       | Replace with options objects or separate functions        |\n| Repeated conditionals      | Same `if` check in multiple places | Extract to a well-named predicate function                |\n\n**Naming and readability:**\n\n| Pattern                    | Signal                                         | Simplification                                                           |\n| -------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |\n| Generic names              | `data`, `result`, `temp`, `val`, `item`        | Rename to describe the content: `userProfile`, `validationErrors`        |\n| Abbreviated names          | `usr`, `cfg`, `btn`, `evt`                     | Use full words unless the abbreviation is universal (`id`, `url`, `api`) |\n| Misleading names           | Function named `get` that also mutates state   | Rename to reflect actual behavior                                        |\n| Comments explaining \"what\" | `// increment counter` above `count++`         | Delete the comment — the code is clear enough                            |\n| Comments explaining \"why\"  | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express                    |\n\n**Redundancy:**\n\n| Pattern                   | Signal                                                       | Simplification                                            |\n| ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |\n| Duplicated logic          | Same 5+ lines in multiple places                             | Extract to a shared function                              |\n| Dead code                 | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead)                 |\n| Unnecessary abstractions  | Wrapper that adds no value                                   | Inline the wrapper, call the underlying function directly |\n| Over-engineered patterns  | Factory-for-a-factory, strategy-with-one-strategy            | Replace with the simple direct approach                   |\n| Redundant type assertions | Casting to a type that's already inferred                    | Remove the assertion                                      |\n\n### Step 3: Apply Changes Incrementally\n\nMake one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them.\n\n```\nFOR EACH SIMPLIFICATION:\n1. Make the change\n2. Run the test suite\n3. If tests pass → commit (or continue to next simplification)\n4. If tests fail → revert and reconsider\n```\n\nAvoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it.\n\n**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review.\n\n### Step 4: Verify the Result\n\nAfter all simplifications, step back and evaluate the whole:\n\n```\nCOMPARE BEFORE AND AFTER:\n- Is the simplified version genuinely easier to understand?\n- Did you introduce any new patterns inconsistent with the codebase?\n- Is the diff clean and reviewable?\n- Would a teammate approve this change?\n```\n\nIf the \"simplified\" version is harder to understand or review, revert. Not every simplification attempt succeeds.\n\n## Language-Specific Guidance\n\n### TypeScript / JavaScript\n\n```typescript\n// SIMPLIFY: Unnecessary async wrapper\n// Before\nasync function getUser(id: string): Promise<User> {\n  return await userService.findById(id)\n}\n// After\nfunction getUser(id: string): Promise<User> {\n  return userService.findById(id)\n}\n\n// SIMPLIFY: Verbose conditional assignment\n// Before\nlet displayName: string\nif (user.nickname) {\n  displayName = user.nickname\n} else {\n  displayName = user.fullName\n}\n// After\nconst displayName = user.nickname || user.fullName\n\n// SIMPLIFY: Manual array building\n// Before\nconst activeUsers: User[] = []\nfor (const user of users) {\n  if (user.isActive) {\n    activeUsers.push(user)\n  }\n}\n// After\nconst activeUsers = users.filter((user) => user.isActive)\n\n// SIMPLIFY: Redundant boolean return\n// Before\nfunction isValid(input: string): boolean {\n  if (input.length > 0 && input.length < 100) {\n    return true\n  }\n  return false\n}\n// After\nfunction isValid(input: string): boolean {\n  return input.length > 0 && input.length < 100\n}\n```\n\n### Python\n\n```python\n# SIMPLIFY: Verbose dictionary building\n# Before\nresult = {}\nfor item in items:\n    result[item.id] = item.name\n# After\nresult = {item.id: item.name for item in items}\n\n# SIMPLIFY: Nested conditionals with early return\n# Before\ndef process(data):\n    if data is not None:\n        if data.is_valid():\n            if data.has_permission():\n                return do_work(data)\n            else:\n                raise PermissionError(\"No permission\")\n        else:\n            raise ValueError(\"Invalid data\")\n    else:\n        raise TypeError(\"Data is None\")\n# After\ndef process(data):\n    if data is None:\n        raise TypeError(\"Data is None\")\n    if not data.is_valid():\n        raise ValueError(\"Invalid data\")\n    if not data.has_permission():\n        raise PermissionError(\"No permission\")\n    return do_work(data)\n```\n\n### React / JSX\n\n```tsx\n// SIMPLIFY: Verbose conditional rendering\n// Before\nfunction UserBadge({user}: Props) {\n  if (user.isAdmin) {\n    return <Badge variant=\"admin\">Admin</Badge>\n  } else {\n    return <Badge variant=\"default\">User</Badge>\n  }\n}\n// After\nfunction UserBadge({user}: Props) {\n  const variant = user.isAdmin ? 'admin' : 'default'\n  const label = user.isAdmin ? 'Admin' : 'User'\n  return <Badge variant={variant}>{label}</Badge>\n}\n\n// SIMPLIFY: Prop drilling through intermediate components\n// Before — consider whether context or composition solves this better.\n// This is a judgment call — flag it, don't auto-refactor.\n```\n\n## Common Rationalizations\n\n| Rationalization                                      | Reality                                                                                                                                               |\n| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"It's working, no need to touch it\"                  | Working code that's hard to read will be hard to fix when it breaks. Simplifying now saves time on every future change.                               |\n| \"Fewer lines is always simpler\"                      | A 1-line nested ternary is not simpler than a 5-line if/else. Simplicity is about comprehension speed, not line count.                                |\n| \"I'll just quickly simplify this unrelated code too\" | Unscoped simplification creates noisy diffs and risks regressions in code you didn't intend to change. Stay focused.                                  |\n| \"The types make it self-documenting\"                 | Types document structure, not intent. A well-named function explains _why_ better than a type signature explains _what_.                              |\n| \"This abstraction might be useful later\"             | Don't preserve speculative abstractions. If it's not used now, it's complexity without value. Remove it and re-add when needed.                       |\n| \"The original author must have had a reason\"         | Maybe. Check git blame — apply Chesterton's Fence. But accumulated complexity often has no reason; it's just the residue of iteration under pressure. |\n| \"I'll refactor while adding this feature\"            | Separate refactoring from feature work. Mixed changes are harder to review, revert, and understand in history.                                        |\n\n## Red Flags\n\n- Simplification that requires modifying tests to pass (you likely changed behavior)\n- \"Simplified\" code that is longer and harder to follow than the original\n- Renaming things to match your preferences rather than project conventions\n- Removing error handling because \"it makes the code cleaner\"\n- Simplifying code you don't fully understand\n- Batching many simplifications into one large, hard-to-review commit\n- Refactoring code outside the scope of the current task without being asked\n\n## Verification\n\nAfter completing a simplification pass:\n\n- [ ] All existing tests pass without modification\n- [ ] Build succeeds with no new warnings\n- [ ] Linter/formatter passes (no style regressions)\n- [ ] Each simplification is a reviewable, incremental change\n- [ ] The diff is clean — no unrelated changes mixed in\n- [ ] Simplified code follows project conventions (checked against CLAUDE.md or equivalent)\n- [ ] No error handling was removed or weakened\n- [ ] No dead code was left behind (unused imports, unreachable branches)\n- [ ] A teammate or review agent would approve the change as a net improvement\n","frontmatter":{"name":"code-simplification","description":"Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity."},"isInternal":false,"tokens":3094,"sizeBytes":15277},{"name":"SKILL.md","path":".agents/skills/deslop/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/deslop/SKILL.md","title":"deslop","category":"anthropic-skill","format":"markdown","content":"---\nname: deslop\ndescription: Remove AI-generated code slop and clean up code style\n---\n\n# Remove AI code slop\n\nCheck the diff against main and remove AI-generated slop introduced in the branch.\n\n## Focus Areas\n\n- Extra comments that are unnecessary or inconsistent with local style\n- Defensive checks or try/catch blocks that are abnormal for trusted code paths\n- Casts to `any` used only to bypass type issues\n- Deeply nested code that should be simplified with early returns\n- Other patterns inconsistent with the file and surrounding codebase\n\n## Guardrails\n\n- Keep behavior unchanged unless fixing a clear bug.\n- Prefer minimal, focused edits over broad rewrites.\n- Keep the final summary concise (1-3 sentences).\n","frontmatter":{"name":"deslop","description":"Remove AI-generated code slop and clean up code style"},"isInternal":false,"tokens":152,"sizeBytes":719},{"name":"SKILL.md","path":".agents/skills/find-skills/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/find-skills/SKILL.md","title":"find-skills","category":"anthropic-skill","format":"markdown","content":"---\nname: find-skills\ndescription: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n---\n\n# Find Skills\n\nThis skill helps you discover and install skills from the open agent skills ecosystem.\n\n## When to Use This Skill\n\nUse this skill when the user:\n\n- Asks \"how do I do X\" where X might be a common task with an existing skill\n- Says \"find a skill for X\" or \"is there a skill for X\"\n- Asks \"can you do X\" where X is a specialized capability\n- Expresses interest in extending agent capabilities\n- Wants to search for tools, templates, or workflows\n- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)\n\n## What is the Skills CLI?\n\nThe Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.\n\n**Key commands:**\n\n- `npx skills find [query]` - Search for skills interactively or by keyword\n- `npx skills add <package>` - Install a skill from GitHub or other sources\n- `npx skills check` - Check for skill updates\n- `npx skills update` - Update all installed skills\n\n**Browse skills at:** https://skills.sh/\n\n## How to Help Users Find Skills\n\n### Step 1: Understand What They Need\n\nWhen a user asks for help with something, identify:\n\n1. The domain (e.g., React, testing, design, deployment)\n2. The specific task (e.g., writing tests, creating animations, reviewing PRs)\n3. Whether this is a common enough task that a skill likely exists\n\n### Step 2: Search for Skills\n\nRun the find command with a relevant query:\n\n```bash\nnpx skills find [query]\n```\n\nFor example:\n\n- User asks \"how do I make my React app faster?\" → `npx skills find react performance`\n- User asks \"can you help me with PR reviews?\" → `npx skills find pr review`\n- User asks \"I need to create a changelog\" → `npx skills find changelog`\n\nThe command will return results like:\n\n```\nInstall with npx skills add <owner/repo@skill>\n\nvercel-labs/agent-skills@vercel-react-best-practices\n└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 3: Present Options to the User\n\nWhen you find relevant skills, present them to the user with:\n\n1. The skill name and what it does\n2. The install command they can run\n3. A link to learn more at skills.sh\n\nExample response:\n\n```\nI found a skill that might help! The \"vercel-react-best-practices\" skill provides\nReact and Next.js performance optimization guidelines from Vercel Engineering.\n\nTo install it:\nnpx skills add vercel-labs/agent-skills@vercel-react-best-practices\n\nLearn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 4: Offer to Install\n\nIf the user wants to proceed, you can install the skill for them:\n\n```bash\nnpx skills add <owner/repo@skill> -g -y\n```\n\nThe `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.\n\n## Common Skill Categories\n\nWhen searching, consider these common categories:\n\n| Category        | Example Queries                          |\n| --------------- | ---------------------------------------- |\n| Web Development | react, nextjs, typescript, css, tailwind |\n| Testing         | testing, jest, playwright, e2e           |\n| DevOps          | deploy, docker, kubernetes, ci-cd        |\n| Documentation   | docs, readme, changelog, api-docs        |\n| Code Quality    | review, lint, refactor, best-practices   |\n| Design          | ui, ux, design-system, accessibility     |\n| Productivity    | workflow, automation, git                |\n\n## Tips for Effective Searches\n\n1. **Use specific keywords**: \"react testing\" is better than just \"testing\"\n2. **Try alternative terms**: If \"deploy\" doesn't work, try \"deployment\" or \"ci-cd\"\n3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`\n\n## When No Skills Are Found\n\nIf no relevant skills exist:\n\n1. Acknowledge that no existing skill was found\n2. Offer to help with the task directly using your general capabilities\n3. Suggest the user could create their own skill with `npx skills init`\n\nExample:\n\n```\nI searched for skills related to \"xyz\" but didn't find any matches.\nI can still help you with this task directly! Would you like me to proceed?\n\nIf this is something you do often, you could create your own skill:\nnpx skills init my-xyz-skill\n```\n","frontmatter":{"name":"find-skills","description":"Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill."},"isInternal":false,"tokens":1110,"sizeBytes":4635},{"name":"SKILL.md","path":".agents/skills/grill-me/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/grill-me/SKILL.md","title":"grill-me","category":"anthropic-skill","format":"markdown","content":"---\nname: grill-me\ndescription: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions \"grill me\".\n---\n\nInterview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.\n\nIf a question can be answered by exploring the codebase, explore the codebase instead.\n","frontmatter":{"name":"grill-me","description":"Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions \"grill me\"."},"isInternal":false,"tokens":114,"sizeBytes":601},{"name":"REFERENCE.md","path":".agents/skills/improve-codebase-architecture/REFERENCE.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/improve-codebase-architecture/REFERENCE.md","title":"Improve-codebase-architecture Skill","category":"anthropic-skill","format":"markdown","content":"# Reference\n\n## Dependency Categories\n\nWhen assessing a candidate for deepening, classify its dependencies:\n\n### 1. In-process\n\nPure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly.\n\n### 2. Local-substitutable\n\nDependencies that have local test stand-ins (e.g., PGLite for Postgres, in-memory filesystem). Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite.\n\n### 3. Remote but owned (Ports & Adapters)\n\nYour own services across a network boundary (microservices, internal APIs). Define a port (interface) at the module boundary. The deep module owns the logic; the transport is injected. Tests use an in-memory adapter. Production uses the real HTTP/gRPC/queue adapter.\n\nRecommendation shape: \"Define a shared interface (port), implement an HTTP adapter for production and an in-memory adapter for testing, so the logic can be tested as one deep module even though it's deployed across a network boundary.\"\n\n### 4. True external (Mock)\n\nThird-party services (Stripe, Twilio, etc.) you don't control. Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock implementation.\n\n## Testing Strategy\n\nThe core principle: **replace, don't layer.**\n\n- Old unit tests on shallow modules are waste once boundary tests exist — delete them\n- Write new tests at the deepened module's interface boundary\n- Tests assert on observable outcomes through the public interface, not internal state\n- Tests should survive internal refactors — they describe behavior, not implementation\n\n## Issue Template\n\n<issue-template>\n\n## Problem\n\nDescribe the architectural friction:\n\n- Which modules are shallow and tightly coupled\n- What integration risk exists in the seams between them\n- Why this makes the codebase harder to navigate and maintain\n\n## Proposed Interface\n\nThe chosen interface design:\n\n- Interface signature (types, methods, params)\n- Usage example showing how callers use it\n- What complexity it hides internally\n\n## Dependency Strategy\n\nWhich category applies and how dependencies are handled:\n\n- **In-process**: merged directly\n- **Local-substitutable**: tested with [specific stand-in]\n- **Ports & adapters**: port definition, production adapter, test adapter\n- **Mock**: mock boundary for external services\n\n## Testing Strategy\n\n- **New boundary tests to write**: describe the behaviors to verify at the interface\n- **Old tests to delete**: list the shallow module tests that become redundant\n- **Test environment needs**: any local stand-ins or adapters required\n\n## Implementation Recommendations\n\nDurable architectural guidance that is NOT coupled to current file paths:\n\n- What the module should own (responsibilities)\n- What it should hide (implementation details)\n- What it should expose (the interface contract)\n- How callers should migrate to the new interface\n\n</issue-template>\n","isInternal":false,"tokens":600,"sizeBytes":2969},{"name":"SKILL.md","path":".agents/skills/improve-codebase-architecture/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/improve-codebase-architecture/SKILL.md","title":"improve-codebase-architecture","category":"anthropic-skill","format":"markdown","content":"---\nname: improve-codebase-architecture\ndescription: Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable.\n---\n\n# Improve Codebase Architecture\n\nExplore a codebase like an AI would, surface architectural friction, discover opportunities for improving testability, and propose module-deepening refactors as GitHub issue RFCs.\n\nA **deep module** (John Ousterhout, \"A Philosophy of Software Design\") has a small interface hiding a large implementation. Deep modules are more testable, more AI-navigable, and let you test at the boundary instead of inside.\n\n## Process\n\n### 1. Explore the codebase\n\nUse the Agent tool with subagent_type=Explore to navigate the codebase naturally. Do NOT follow rigid heuristics — explore organically and note where you experience friction:\n\n- Where does understanding one concept require bouncing between many small files?\n- Where are modules so shallow that the interface is nearly as complex as the implementation?\n- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called?\n- Where do tightly-coupled modules create integration risk in the seams between them?\n- Which parts of the codebase are untested, or hard to test?\n\nThe friction you encounter IS the signal.\n\n### 2. Present candidates\n\nPresent a numbered list of deepening opportunities. For each candidate, show:\n\n- **Cluster**: Which modules/concepts are involved\n- **Why they're coupled**: Shared types, call patterns, co-ownership of a concept\n- **Dependency category**: See [REFERENCE.md](REFERENCE.md) for the four categories\n- **Test impact**: What existing tests would be replaced by boundary tests\n\nDo NOT propose interfaces yet. Ask the user: \"Which of these would you like to explore?\"\n\n### 3. User picks a candidate\n\n### 4. Frame the problem space\n\nBefore spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:\n\n- The constraints any new interface would need to satisfy\n- The dependencies it would need to rely on\n- A rough illustrative code sketch to make the constraints concrete — this is not a proposal, just a way to ground the constraints\n\nShow this to the user, then immediately proceed to Step 5. The user reads and thinks about the problem while the sub-agents work in parallel.\n\n### 5. Design multiple interfaces\n\nSpawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.\n\nPrompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category, what's being hidden). This brief is independent of the user-facing explanation in Step 4. Give each agent a different design constraint:\n\n- Agent 1: \"Minimize the interface — aim for 1-3 entry points max\"\n- Agent 2: \"Maximize flexibility — support many use cases and extension\"\n- Agent 3: \"Optimize for the most common caller — make the default case trivial\"\n- Agent 4 (if applicable): \"Design around the ports & adapters pattern for cross-boundary dependencies\"\n\nEach sub-agent outputs:\n\n1. Interface signature (types, methods, params)\n2. Usage example showing how callers use it\n3. What complexity it hides internally\n4. Dependency strategy (how deps are handled — see [REFERENCE.md](REFERENCE.md))\n5. Trade-offs\n\nPresent designs sequentially, then compare them in prose.\n\nAfter comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not just a menu.\n\n### 6. User picks an interface (or accepts recommendation)\n\n### 7. Create GitHub issue\n\nCreate a refactor RFC as a GitHub issue using `gh issue create`. Use the template in [REFERENCE.md](REFERENCE.md). Do NOT ask the user to review before creating — just create it and share the URL.\n","frontmatter":{"name":"improve-codebase-architecture","description":"Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable."},"isInternal":false,"tokens":866,"sizeBytes":4113},{"name":"SKILL.md","path":".agents/skills/migrate-styled-components-to-panda-css/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/migrate-styled-components-to-panda-css/SKILL.md","title":"migrate-styled-components-to-panda-css","category":"anthropic-skill","format":"markdown","content":"---\nname: migrate-styled-components-to-panda-css\ndescription: Step-by-step procedure for migrating components in the Sanity monorepo off styled-components to Panda CSS (zero-runtime, build-time extracted CSS). Use when converting styled() components to .css.ts files, creating .css.ts files, handling CSS specificity against @sanity/ui, styling child elements with Panda, or when asked to migrate styling to Panda CSS.\n---\n\n# Migrate styled-components to Panda CSS\n\nThe repeatable procedure for converting this monorepo's styling from `styled-components` (the\nStudio's legacy styling library) to [Panda CSS](https://panda-css.com). Adapted from the\n`sanity-io/plugins` skill for vanilla-extract, which this repo used before Panda.\n\n**The infrastructure is already wired — never touch it during a component migration.**\n`packages/sanity` builds its `.css.ts` files into `lib/bundle.css` through the `panda` option of\n`@sanity/tsdown-config` in its `tsdown.config.ts` (`panda.config.ts` next to it), exposes the\n`./bundle.css` export, registers `@pandacss/vite` in its `vitest.config.mts`, and the dev\nstudios, Storybook and the browser suite point their own `@pandacss/vite` instance at the\npackage (`packages/sanity/panda.config.ts` is the repository's only Panda project). A migration\nPR only converts component styles.\n\n## Ground rules\n\n- **Identical visual output is the goal.** This is a refactor, not a redesign — every rule and\n  specificity outcome must be preserved. Copy CSS values verbatim; do not \"improve\" them.\n- **Small PRs.** Migrate one component (or one tightly-coupled cluster) per PR, conventional-commit\n  titled, e.g. `refactor(structure): migrate Scroller to Panda CSS`.\n- **Reference implementations:**\n  `packages/sanity/src/core/changeIndicators/ElementWithChangeBar.*` (data attributes for\n  parent-state selectors), `packages/sanity/src/structure/panes/document/document-layout/DocumentLayout.*`,\n  and for the dynamic shapes `packages/sanity/src/core/components/popoverDialog/PopoverContainer.*`\n  (responsive widths through per-breakpoint variables) and\n  `packages/sanity/src/core/form/inputs/PortableText/text/TextBlock.styles.*` (per-level variants,\n  private custom properties, theme-driven colors).\n\n## Step 1: Inventory\n\nClassify each usage before touching code:\n\n```bash\nrg \"from 'styled-components'\" packages/sanity/src <target-dir>\n```\n\n| Usage                                               | Migration target                                                                                                          |\n| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| Static `styled.div` / `styled(Primitive)`           | `css()` in a colocated `.css.ts` + thin wrapper component (or plain `className`)                                          |\n| Overrides of `@sanity/ui` primitives' own styles    | `css()` with the `&&` specificity trick (see below)                                                                       |\n| Theme reads (`({theme}) => ...`, `getTheme_v2`)     | `--card-*` CSS custom properties if one exists, else a named custom property set through inline `style` + `useTheme_v2()` |\n| Prop-driven variants (`$isInvalid`, `css` branches) | one `css()` per state + conditional `clsx()` composition (the modifier doubled with `&&`), or an object of `css()` calls  |\n| `keyframes` animations                              | Panda `keyframes()` from `#styled-system/css`                                                                             |\n| Descendant selectors (`& img`, third-party classes) | class on the child directly, or a nested `'& img'` / `'& .third-party'` selector on the parent class                      |\n| `createGlobalStyle`                                 | nested selectors under a local class (never leak outside the component)                                                   |\n| `*.styled.tsx` modules                              | `.css.ts` + thin wrappers; keep the `.styled.tsx` file when call sites need it                                            |\n| Computed inline `style={{}}` objects                | static parts into `css()`, changing values through custom properties (Shape C below)                                      |\n\n## Step 2: Migrate the code\n\nCreate a `ComponentName.css.ts` next to the component. Only `.css.ts` files are Panda sources\n(`include` in `panda.config.ts`): a `css()` call anywhere else is neither extracted into\n`bundle.css` nor rewritten to class names, so its styles never render. Import from\n`#styled-system/css` (the generated runtime, mapped by the package's `imports`; it exists after\n`pnpm install`, which runs `panda codegen`). The build rewrites every statically resolvable call\ninto its class names, so the published code carries no Panda runtime — keep the style objects\nliteral (no loops, no values computed from imports) and pass the module's own constants only.\n\n**Shape A — keep the component layer.** When the styled element is used like a component, replace\nit with a `css()` rule plus a thin wrapper keeping the same name and API, so call sites don't\nchange. Type with `ComponentProps<typeof Primitive>` (or `ComponentProps<'div'>`) — never\n`forwardRef`; `ref` is a regular prop on React 19 and flows through the spread. **Merge, don't\nclobber:** a fixed `className={...}` after `{...props}` silently drops a caller's `className` —\nmerge with `clsx` (already a dependency):\n\n```tsx\nimport {Card} from '@sanity/ui'\nimport {clsx} from 'clsx'\nimport {type ComponentProps} from 'react'\n\nimport {floatingCard} from './FloatingCard.css'\n\nexport function FloatingCard(props: ComponentProps<typeof Card>) {\n  const {className, ...rest} = props\n  return <Card {...rest} className={clsx(floatingCard, className)} />\n}\n```\n\nRenaming a `Component.styled.ts` to `.tsx` (or `.ts` → `.tsx`) keeps extension-less import paths\nat call sites unchanged.\n\n**Shape B — flatten single-use wrappers.** When the styled element was an internal, single-use\n`styled.div` with no meaningful API, delete it and put the class directly on the element:\n`<div className={paragraph}>{children}</div>`.\n\n**Shape C — dynamic values through CSS variables.** First check whether `@sanity/ui` already\nexposes the value as a CSS custom property — prefer e.g. `var(--card-bg-color)` over JS theme\nreads (see `ElementWithChangeBar.css.ts`). Otherwise keep static parts in `css()` and bridge only\nchanging values with a named custom property the wrapper sets inline, reading the theme with\n`useTheme_v2()` (alias it `useTheme_v2 as useThemeV2` to satisfy the camelCase lint):\n\n```ts\n// Wrapper.css.ts\nimport {css} from '#styled-system/css'\n\n/** Set inline by `Wrapper` from the theme */\nexport const paddingVar = '--wrapper-padding'\nexport const wrapper = css({paddingBottom: `var(${paddingVar})`})\n```\n\n```tsx\n<div className={wrapper} style={{[paddingVar]: `${space[4]}px`} as CSSProperties} />\n```\n\nName the property after the component and the theme key (`--wrapper-space-4`), since it is\nglobal to the page; one class serves every instance.\n\n### Specificity against @sanity/ui: the `&&` trick\n\n`@sanity/ui` v4 still uses styled-components, which injects its styles at runtime — **after** the\nextracted stylesheet, so an equal-specificity class loses. When overriding a primitive's own\nstyles (e.g. `styled(Card)` overriding Card's background), double the class:\n\n```ts\nexport const transparentCard = css({\n  '&&': {background: 'none'},\n})\n```\n\nWhere styled-components silently won specificity battles via CSSOM insertion order, reach for\n`&&` rather than `!important`. Plain-element styles (`styled.div`) need no trick.\n\n### The cascade model (why order is not yours to rely on)\n\nThree stylesheets meet on a migrated element:\n\n| Sheet                                     | `sanity build`                                      | `sanity dev`                                                                                                |\n| ----------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Panda (`bundle.css`)                      | one static file, imported first by the entry chunks | one stylesheet (`@repo/pandacss/panda.css`) filled by `@pandacss/vite`, imported first by the runtime entry |\n| `@sanity/ui` (styled-components, runtime) | `<style data-styled>` appended on first render      | same                                                                                                        |\n| `ui5` (`styles.css`)                      | static file                                         | static file                                                                                                 |\n\nPanda writes its rules into cascade layers; the studio ships them without any (`cascadeLayers:\nfalse` for `bundle.css`, the same pass for the dev stylesheet — see the \"Panda CSS\" section of\nAGENTS.md). CSS outside any layer beats every layered rule regardless of specificity, so layers\nwould hand the studio's hover, focus and selected states to any unlayered rule that also matches.\nUnlayered, the studio's rules cascade like the vanilla-extract output did: by specificity, then\nsource order. Do not write `@layer` in a `.css.ts`.\n\nConsequences:\n\n- **Beat `@sanity/ui`'s rules by specificity, not by order.** `@sanity/ui`'s styled-components\n  rules are injected at runtime, after the static stylesheets, and `ui5`'s component classes load\n  as static CSS too; order is theirs, so a rule of yours that must win over one of theirs has to\n  be more specific: `&&` (0,2,0) beats a plain component class (0,1,0). If the primitive rule you\n  override already has two classes or a pseudo-class chain (e.g. Card's\n  `&[data-as='button']:not(:disabled)[data-selected]`), count its specificity and exceed it —\n  `&&&`, or `&&[data-as='button']`. Read the rule in `node_modules/@sanity/ui/dist/*.js` when\n  unsure. `!important` stays banned.\n- **Panda emits one rule per declaration, not one per class.** `css({display: 'flex', opacity: 1})`\n  returns two class names, one per declaration, and `bundle.css` holds every distinct\n  `property: value` pair once, grouped by property with the values **sorted alphabetically**. So\n  when two of your classes set the same property on one element (`clsx(rootBox, muted && rootBoxMuted)`),\n  the value that sorts last wins — `opacity: 1` beats `opacity: 0.8` whatever the declaration\n  order. A modifier that must win doubles its class: `rootBoxMuted = css({'&&': {opacity: 0.8}})`.\n  The same holds for a `styled(LocalStyledThing)` chain, which styled-components ordered for you.\n- **Shorthands come before longhands.** `padding: 0` in one class and `paddingLeft: '4px'` in\n  another on the same element always resolve to the longhand, regardless of order.\n- **Parent-state selectors key on data attributes, never on an empty class.** A Panda class name\n  is a list of atomic names, so `` `${parent}:hover &` `` cannot be built from it, and a class\n  that exists only to be targeted is not an option either. Have the parent render its identity\n  and state as data attributes (`data-change-bar-wrapper=\"\"`, `data-focused={hasFocus ? '' :\n  undefined}`) and write `'[data-change-bar-wrapper][data-focused] &': {...}` or\n  `'[data-change-bar-wrapper]:hover &': {...}` on the child — see `ElementWithChangeBar.css.ts`.\n  Read a state attribute together with the identity attribute: generic names like `data-focused`\n  and `data-disabled` are rendered by other components too, and `:not([data-changed])` on its own\n  matches every ancestor. Attributes a library component already renders (`'[data-ui=\"Card\"] &'`)\n  work the same way.\n- **`@media` blocks are grouped.** Panda emits a file's media queries after the base rules,\n  which is what styled-components effectively did too. Media conditions cannot read custom\n  properties, so theme breakpoints are written as literals from the default theme (table\n  below) with a comment naming the theme key they mirror.\n\n#### What each `@sanity/ui` primitive sets on itself\n\nUse `&&` when the migrated rule overrides one of these; plain `css()` otherwise.\n\n| Primitive                                                                                         | Own declarations (via its props or defaults)                                                                                                                                                                                                                                                                  |\n| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Box`                                                                                             | `display` (block, or the `display` prop), `box-sizing`, `flex`/`flex-shrink`/`flex-grow` props, `margin*`, `padding*`, `height`, `overflow`, `column`/`row` grid props, `list-style: none` for `as=\"ul\"/\"ol\"`                                                                                                 |\n| `Flex`                                                                                            | Box + `display: flex`, `flex-direction`, `flex-wrap`, `align-items`, `justify-content`, `gap`                                                                                                                                                                                                                 |\n| `Stack`                                                                                           | Box + `display: grid`, `grid-template-columns`, `grid-auto-rows`, `gap`                                                                                                                                                                                                                                       |\n| `Grid`, `Inline`                                                                                  | Box + `display`, `gap`, `grid-*`, `align-items`                                                                                                                                                                                                                                                               |\n| `Card`                                                                                            | Box + `background-color`, `color`, `color-scheme`, every `--card-*` variable, `border`/`border-color` (`border` prop), `border-radius` (`radius`), `box-shadow` (`shadow`, focus ring), button/link hover/pressed/selected/disabled variants, `outline`, `font`, `text-align`, `appearance`, `width` (button) |\n| `Container`                                                                                       | Box + `max-width`, `margin: 0 auto`                                                                                                                                                                                                                                                                           |\n| `Text`, `Label`, `Heading`, `Code`                                                                | `font-family`, `font-size`, `font-weight`, `line-height`, `letter-spacing`, `color` (`--card-fg-color` / muted / accent), `text-align`; descendant rules for `& code`, `& a` (color `--card-link-color`, focus ring), `& strong`, `& svg`; the inner `span` handles `text-overflow`                           |\n| `Button`                                                                                          | Card-like colors per `mode`/`tone`, `padding`, `border-radius`, `font`, `box-shadow`, `cursor`, `&:disabled`, `&:not(:disabled):hover`, `& [data-ui='Text']`                                                                                                                                                  |\n| `TextInput`, `TextArea`, `Select`                                                                 | wrapper `position: relative`; the inner `input`/`textarea`/`select` gets font, color, padding, border via presentation `span`; `--input-*` variables                                                                                                                                                          |\n| `Skeleton`, `TextSkeleton`, `LabelSkeleton`                                                       | `background-color`, `border-radius`, `animation`, `width`, `height`                                                                                                                                                                                                                                           |\n| `Popover`, `Tooltip`, `Layer`, `Dialog`                                                           | `position`, `z-index`, `pointer-events`, `max-width`; Popover/Tooltip content Card colors                                                                                                                                                                                                                     |\n| `Badge`, `KBD`, `Spinner`, `Switch`, `Checkbox`, `Radio`, `Avatar`, `Tab`, `TreeItem`, `MenuItem` | fully self-styled — treat every declaration as an override (`&&`)                                                                                                                                                                                                                                             |\n\n#### Theme reads → CSS variables\n\n`getTheme_v2(theme)` inside a styled template and `useTheme_v2()` inside the wrapper read the\nsame context, including the nearest `Card`'s `tone` and `scheme`. Prefer a variable `@sanity/ui`\nalready publishes on the nearest Card (they follow tone and scheme for free):\n\n| Theme read                                                        | CSS variable                                                                           |\n| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `color.bg`, `color.fg`, `color.border`, `color.icon`              | `--card-bg-color`, `--card-fg-color`, `--card-border-color`, `--card-icon-color`       |\n| `color.muted.bg`, `color.muted.fg`, `color.accent.fg`             | `--card-muted-bg-color`, `--card-muted-fg-color`, `--card-accent-fg-color`             |\n| `color.focusRing`, `color.backdrop`                               | `--card-focus-ring-color`, `--card-backdrop-color`                                     |\n| `color.badge.<tone>.{bg,fg,dot,icon}`                             | `--card-badge-<tone>-{bg,fg,dot,icon}-color`                                           |\n| `color.avatar.<hue>.{bg,fg}`                                      | `--card-avatar-<hue>-{bg,fg}-color`                                                    |\n| `color.link.fg`, `color.code.{bg,fg}`, `color.kbd.{bg,fg,border}` | `--card-link-fg-color`, `--card-code-{bg,fg}-color`, `--card-kbd-{bg,fg,border}-color` |\n| `color.shadow.{outline,umbra,penumbra,ambient}`                   | `--card-shadow-{outline,umbra,penumbra,ambient}-color`                                 |\n| `color.skeleton.{from,to}`                                        | `--card-skeleton-color-{from,to}`                                                      |\n| `color.hairline.{soft,hard}`                                      | `--card-hairline-{soft,hard}-color`                                                    |\n| card focus ring box-shadow (`focusRingStyle(...)`)                | `--card-focus-ring-box-shadow` (set by Card/Button on `:focus-visible`)                |\n\nEverything else — `color.selectable.*`, `color.solid.*`, `color.input.*`, `color.button.*`,\n`color.syntax.*`, `color._dark`, `space[n]`, `radius[n]`, `font.*`, `container[n]`,\n`avatar.sizes[n]`, `input.*`, `shadow[n]` — goes through Shape C: a named custom property in the\n`.css.ts`, `useTheme_v2()` + an inline `style` object in the wrapper. Keep the same theme key\nnames in the property names (`--wrapper-space-4`, `--wrapper-radius-2`) so the intent survives. Do\nnot hardcode scale values: a studio may build its own theme with different `space`, `radius`, or\n`font` scales.\n\nOnly `@media` conditions get literals, mirrored from the default theme:\n\n| Key         | Default values                         |\n| ----------- | -------------------------------------- |\n| `media`     | `[360, 600, 900, 1200, 1800, 2400]` px |\n| `container` | `[320, 640, 960, 1280, 1600, 1920]` px |\n\nWrite them as `'@media screen and (min-width: 600px)': {...}` with a comment such as\n`// media[1]`.\n\n### Wrapper contract\n\nA wrapper that replaces an exported styled component must keep call sites working without edits:\n\n- Same export name, same prop names — including transient `$props`, which become regular props\n  the wrapper consumes (do not rename them; callers in other directories still pass them).\n- `className` and `style` **merge** (`clsx`, spread) — never clobber.\n- `ref` flows through props (React 19). No `forwardRef`, no `displayName`.\n- `.attrs({...})` values become JSX props on the primitive inside the wrapper; `forwardedAs`\n  becomes `as`.\n- A prop-driven `css` branch becomes one `css()` per branch plus `clsx(cond && cls)`, the branch\n  doubled with `&&` when it overrides the base class (see the cascade model); a prop-driven\n  **value** becomes a custom property set inline. Prefer an object of `css()` calls for a closed\n  set of values (`root.narrow`, `root.wide`).\n- Shared `css` mixins (an `export const focusRingStyles = css` tagged template) become a\n  `css.raw({...})` object in a `.css.ts`; importers compose it with `css(mixin, {...})`, which\n  merges the objects before emitting the class names.\n- A test that asserted a computed style of a migrated component (`toHaveStyle`) cannot pass in\n  jsdom anymore (no stylesheet is loaded there). Assert the state through a `data-*` attribute the\n  wrapper sets instead; add the attribute if none exists.\n\n### Child and descendant selectors\n\nA nested key with `&` is a selector where `&` stands for the class's own element, in any\nposition: `'& img'`, `'& > span'`, `'& .third-party-class'`, `'[data-ui=\"Card\"] &'`. In order of\npreference:\n\n1. Put a class directly on the child if you render it yourself.\n2. `'[data-parent][data-state] &': {...}` on the child class for parent-state-dependent rules,\n   keyed on data attributes the parent renders (see the cascade model).\n3. `'& img': {...}` on the parent class when you don't control the child (library-rendered DOM) —\n   always scoped under a local class.\n\n### Strict property values\n\n`panda.config.ts` sets `strictPropertyValues`, which restricts enumerable properties (`display`,\n`position`, `scrollbarWidth`, …) to their CSS keywords and catches typos. A `var()` on such a\nproperty needs Panda's escape hatch, square brackets around the value:\n`scrollbarWidth: '[var(--scrollbar-width)]'` emits `scrollbar-width: var(--scrollbar-width)`.\n\n## Step 3: Verify\n\n```bash\npnpm lint:fix                                  # oxfmt + oxlint (includes type checking)\npnpm build && pnpm vitest run --project=sanity <affected test paths>\n```\n\nCheck `packages/sanity/lib/bundle.css` after the build: every migrated rule must appear there.\nThe build warns about a `css()` call it could not resolve statically; such a call keeps Panda's\nruntime in the bundle and must be rewritten with literal values.\n\nVerify **visual fidelity** in the dev studio (`pnpm dev`, see AGENTS.md for cloud-agent auth):\nexercise the migrated component and compare against the pre-migration rendering — theme tones,\nspacing, hover/focus states, stacking. Jsdom tests must not assert on Panda class names or\ncomputed styles (no stylesheet is loaded there — see AGENTS.md); assert on `data-testid`. For\nhigh-traffic components, add a Chromatic story per the `sanity-visual-regression` skill so the\nmigration is sentinel-protected.\n\n## End state\n\nThe monorepo's own code no longer imports `styled-components`; `eslint/no-restricted-imports` in\n`.oxlintrc.json` bans the module everywhere (the global rule and every override that repeats it).\nThe dependency itself stays declared because `@sanity/ui@4` requires it as a peer and still\ninjects its own styles at runtime — which is exactly why the cascade rules above still matter for\nevery new `.css.ts`. Use this skill for new components and for plugin or example code that still\ncarries styled-components.\n\n## Checklist\n\n- [ ] `styled-components` import removed from the migrated component; emptied `.styled.tsx`\n      modules deleted\n- [ ] `.css.ts` colocated; CSS values copied verbatim; component layer preserved where call sites\n      need it; `className` merged, not clobbered\n- [ ] `&&` used where the original relied on styled-components injection order to beat @sanity/ui,\n      and on modifier classes that override a base class on the same element\n- [ ] No build config, `package.json`, or exports changes (infra is already wired)\n- [ ] `pnpm lint:fix`, `pnpm build`, and affected tests pass; rules present in `lib/bundle.css`\n- [ ] Visual fidelity verified against the pre-migration rendering\n","frontmatter":{"name":"migrate-styled-components-to-panda-css","description":"Step-by-step procedure for migrating components in the Sanity monorepo off styled-components to Panda CSS (zero-runtime, build-time extracted CSS). Use when converting styled() components to .css.ts files, creating .css.ts files, handling CSS specificity against @sanity/ui, styling child elements with Panda, or when asked to migrate styling to Panda CSS."},"isInternal":false,"tokens":5228,"sizeBytes":25817},{"name":"SKILL.md","path":".agents/skills/performance-optimization/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/performance-optimization/SKILL.md","title":"performance-optimization","category":"anthropic-skill","format":"markdown","content":"---\nname: performance-optimization\ndescription: Optimizes application performance. Use when performance requirements exist, when you suspect performance regressions, or when Core Web Vitals or load times need improvement. Use when profiling reveals bottlenecks that need fixing.\n---\n\n# Performance Optimization\n\n## Overview\n\nMeasure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.\n\n## When to Use\n\n- Performance requirements exist in the spec (load time budgets, response time SLAs)\n- Users or monitoring report slow behavior\n- Core Web Vitals scores are below thresholds\n- You suspect a change introduced a regression\n- Building features that handle large datasets or high traffic\n\n**When NOT to use:** Don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains.\n\n## Core Web Vitals Targets\n\n| Metric                              | Good    | Needs Improvement | Poor    |\n| ----------------------------------- | ------- | ----------------- | ------- |\n| **LCP** (Largest Contentful Paint)  | ≤ 2.5s  | ≤ 4.0s            | > 4.0s  |\n| **INP** (Interaction to Next Paint) | ≤ 200ms | ≤ 500ms           | > 500ms |\n| **CLS** (Cumulative Layout Shift)   | ≤ 0.1   | ≤ 0.25            | > 0.25  |\n\n## The Optimization Workflow\n\n```\n1. MEASURE  → Establish baseline with real data\n2. IDENTIFY → Find the actual bottleneck (not assumed)\n3. FIX      → Address the specific bottleneck\n4. VERIFY   → Measure again, confirm improvement\n5. GUARD    → Add monitoring or tests to prevent regression\n```\n\n### Step 1: Measure\n\nTwo complementary approaches — use both:\n\n- **Synthetic (Lighthouse, DevTools Performance tab):** Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.\n- **RUM (web-vitals library, CrUX):** Real user data in real conditions. Required to validate that a fix actually improved user experience.\n\n**Frontend:**\n\n```bash\n# Synthetic: Lighthouse in Chrome DevTools (or CI)\n# Chrome DevTools → Performance tab → Record\n# Chrome DevTools MCP → Performance trace\n\n# RUM: Web Vitals library in code\nimport { onLCP, onINP, onCLS } from 'web-vitals';\n\nonLCP(console.log);\nonINP(console.log);\nonCLS(console.log);\n```\n\n**Backend:**\n\n```bash\n# Response time logging\n# Application Performance Monitoring (APM)\n# Database query logging with timing\n\n# Simple timing\nconsole.time('db-query');\nconst result = await db.query(...);\nconsole.timeEnd('db-query');\n```\n\n### Where to Start Measuring\n\nUse the symptom to decide what to measure first:\n\n```\nWhat is slow?\n├── First page load\n│   ├── Large bundle? --> Measure bundle size, check code splitting\n│   ├── Slow server response? --> Measure TTFB in DevTools Network waterfall\n│   │   ├── DNS long? --> Add dns-prefetch / preconnect for known origins\n│   │   ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive\n│   │   └── Waiting (server) long? --> Profile backend, check queries and caching\n│   └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking\n├── Interaction feels sluggish\n│   ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)\n│   ├── Form input lag? --> Check re-renders, controlled component overhead\n│   └── Animation jank? --> Check layout thrashing, forced reflows\n├── Page after navigation\n│   ├── Data loading? --> Measure API response times, check for waterfalls\n│   └── Client rendering? --> Profile component render time, check for N+1 fetches\n└── Backend / API\n    ├── Single endpoint slow? --> Profile database queries, check indexes\n    ├── All endpoints slow? --> Check connection pool, memory, CPU\n    └── Intermittent slowness? --> Check for lock contention, GC pauses, external deps\n```\n\n### Step 2: Identify the Bottleneck\n\nCommon bottlenecks by category:\n\n**Frontend:**\n\n| Symptom           | Likely Cause                                                 | Investigation                         |\n| ----------------- | ------------------------------------------------------------ | ------------------------------------- |\n| Slow LCP          | Large images, render-blocking resources, slow server         | Check network waterfall, image sizes  |\n| High CLS          | Images without dimensions, late-loading content, font shifts | Check layout shift attribution        |\n| Poor INP          | Heavy JavaScript on main thread, large DOM updates           | Check long tasks in Performance trace |\n| Slow initial load | Large bundle, many network requests                          | Check bundle size, code splitting     |\n\n**Backend:**\n\n| Symptom            | Likely Cause                                         | Investigation                    |\n| ------------------ | ---------------------------------------------------- | -------------------------------- |\n| Slow API responses | N+1 queries, missing indexes, unoptimized queries    | Check database query log         |\n| Memory growth      | Leaked references, unbounded caches, large payloads  | Heap snapshot analysis           |\n| CPU spikes         | Synchronous heavy computation, regex backtracking    | CPU profiling                    |\n| High latency       | Missing caching, redundant computation, network hops | Trace requests through the stack |\n\n### Step 3: Fix Common Anti-Patterns\n\n#### N+1 Queries (Backend)\n\n```typescript\n// BAD: N+1 — one query per task for the owner\nconst tasks = await db.tasks.findMany()\nfor (const task of tasks) {\n  task.owner = await db.users.findUnique({where: {id: task.ownerId}})\n}\n\n// GOOD: Single query with join/include\nconst tasks = await db.tasks.findMany({\n  include: {owner: true},\n})\n```\n\n#### Unbounded Data Fetching\n\n```typescript\n// BAD: Fetching all records\nconst allTasks = await db.tasks.findMany()\n\n// GOOD: Paginated with limits\nconst tasks = await db.tasks.findMany({\n  take: 20,\n  skip: (page - 1) * 20,\n  orderBy: {createdAt: 'desc'},\n})\n```\n\n#### Missing Image Optimization (Frontend)\n\n```html\n<!-- BAD: No dimensions, no format optimization -->\n<img src=\"/hero.jpg\" />\n\n<!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority -->\n<!--\n  Two techniques combined:\n  - Art direction (media): different crop/composition per breakpoint\n  - Resolution switching (srcset + sizes): right file size per screen density\n-->\n<picture>\n  <!-- Mobile: portrait crop (8:10) -->\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/avif\"\n  />\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/webp\"\n  />\n  <!-- Desktop: landscape crop (2:1) -->\n  <source\n    srcset=\"/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/avif\"\n  />\n  <source\n    srcset=\"/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/webp\"\n  />\n  <img\n    src=\"/hero-desktop.jpg\"\n    width=\"1200\"\n    height=\"600\"\n    fetchpriority=\"high\"\n    alt=\"Hero image description\"\n  />\n</picture>\n\n<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->\n<img\n  src=\"/content.webp\"\n  width=\"800\"\n  height=\"400\"\n  loading=\"lazy\"\n  decoding=\"async\"\n  alt=\"Content image description\"\n/>\n```\n\n#### Unnecessary Re-renders (React)\n\n```tsx\n// BAD: Creates new object on every render, causing children to re-render\nfunction TaskList() {\n  return <TaskFilters options={{sortBy: 'date', order: 'desc'}} />\n}\n\n// GOOD: Stable reference\nconst DEFAULT_OPTIONS = {sortBy: 'date', order: 'desc'} as const\nfunction TaskList() {\n  return <TaskFilters options={DEFAULT_OPTIONS} />\n}\n\n// Use React.memo for expensive components\nconst TaskItem = React.memo(function TaskItem({task}: Props) {\n  return <div>{/* expensive render */}</div>\n})\n\n// Use useMemo for expensive computations\nfunction TaskStats({tasks}: Props) {\n  const stats = useMemo(() => calculateStats(tasks), [tasks])\n  return (\n    <div>\n      {stats.completed} / {stats.total}\n    </div>\n  )\n}\n```\n\n#### Large Bundle Size\n\n```typescript\n// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,\n// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.\n// Profile before changing import styles — the real gains come from splitting and lazy loading.\n\n// GOOD: Dynamic import for heavy, rarely-used features\nconst ChartLibrary = lazy(() => import('./ChartLibrary'));\n\n// GOOD: Route-level code splitting wrapped in Suspense\nconst SettingsPage = lazy(() => import('./pages/Settings'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spinner />}>\n      <SettingsPage />\n    </Suspense>\n  );\n}\n```\n\n#### Missing Caching (Backend)\n\n```typescript\n// Cache frequently-read, rarely-changed data\nconst CACHE_TTL = 5 * 60 * 1000 // 5 minutes\nlet cachedConfig: AppConfig | null = null\nlet cacheExpiry = 0\n\nasync function getAppConfig(): Promise<AppConfig> {\n  if (cachedConfig && Date.now() < cacheExpiry) {\n    return cachedConfig\n  }\n  cachedConfig = await db.config.findFirst()\n  cacheExpiry = Date.now() + CACHE_TTL\n  return cachedConfig\n}\n\n// HTTP caching headers for static assets\napp.use(\n  '/static',\n  express.static('public', {\n    maxAge: '1y', // Cache for 1 year\n    immutable: true, // Never revalidate (use content hashing in filenames)\n  }),\n)\n\n// Cache-Control for API responses\nres.set('Cache-Control', 'public, max-age=300') // 5 minutes\n```\n\n## Performance Budget\n\nSet budgets and enforce them:\n\n```\nJavaScript bundle: < 200KB gzipped (initial load)\nCSS: < 50KB gzipped\nImages: < 200KB per image (above the fold)\nFonts: < 100KB total\nAPI response time: < 200ms (p95)\nTime to Interactive: < 3.5s on 4G\nLighthouse Performance score: ≥ 90\n```\n\n**Enforce in CI:**\n\n```bash\n# Bundle size check\nnpx bundlesize --config bundlesize.config.json\n\n# Lighthouse CI\nnpx lhci autorun\n```\n\n## See Also\n\nFor detailed performance checklists, optimization commands, and anti-pattern reference, see `references/performance-checklist.md`.\n\n## Common Rationalizations\n\n| Rationalization                     | Reality                                                                                |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| \"We'll optimize later\"              | Performance debt compounds. Fix obvious anti-patterns now, defer micro-optimizations.  |\n| \"It's fast on my machine\"           | Your machine isn't the user's. Profile on representative hardware and networks.        |\n| \"This optimization is obvious\"      | If you didn't measure, you don't know. Profile first.                                  |\n| \"Users won't notice 100ms\"          | Research shows 100ms delays impact conversion rates. Users notice more than you think. |\n| \"The framework handles performance\" | Frameworks prevent some issues but can't fix N+1 queries or oversized bundles.         |\n\n## Red Flags\n\n- Optimization without profiling data to justify it\n- N+1 query patterns in data fetching\n- List endpoints without pagination\n- Images without dimensions, lazy loading, or responsive sizes\n- Bundle size growing without review\n- No performance monitoring in production\n- `React.memo` and `useMemo` everywhere (overusing is as bad as underusing)\n\n## Verification\n\nAfter any performance-related change:\n\n- [ ] Before and after measurements exist (specific numbers)\n- [ ] The specific bottleneck is identified and addressed\n- [ ] Core Web Vitals are within \"Good\" thresholds\n- [ ] Bundle size hasn't increased significantly\n- [ ] No N+1 queries in new data fetching code\n- [ ] Performance budget passes in CI (if configured)\n- [ ] Existing tests still pass (optimization didn't break behavior)\n","frontmatter":{"name":"performance-optimization","description":"Optimizes application performance. Use when performance requirements exist, when you suspect performance regressions, or when Core Web Vitals or load times need improvement. Use when profiling reveals bottlenecks that need fixing."},"isInternal":false,"tokens":2887,"sizeBytes":12441},{"name":"authentication-flows.md","path":".agents/skills/playwright-best-practices/advanced/authentication-flows.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/authentication-flows.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Complex Authentication Flow Patterns\n\n## Table of Contents\n\n1. [Email Verification Flows](#email-verification-flows)\n2. [Password Reset](#password-reset)\n3. [Session Timeout](#session-timeout)\n4. [Remember Me Persistence](#remember-me-persistence)\n5. [Logout Patterns](#logout-patterns)\n6. [Tips](#tips)\n7. [Related](#related)\n\n> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).\n\n---\n\n## Email Verification Flows\n\n### Capturing Verification Tokens\n\nIntercept API responses to capture verification tokens for testing:\n\n```typescript\ntest('completes registration with email verification', async ({page}) => {\n  let capturedToken = ''\n\n  await page.route('**/api/auth/register', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    capturedToken = body.verificationToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Name').fill('New User')\n  await page.getByLabel('Email').fill('newuser@test.com')\n  await page.getByLabel('Password', {exact: true}).fill('SecurePass!')\n  await page.getByLabel('Confirm password').fill('SecurePass!')\n  await page.getByRole('button', {name: 'Create account'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  expect(capturedToken).toBeTruthy()\n  await page.goto(`/verify?token=${capturedToken}`)\n\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n### Fully Mocked Verification\n\n```typescript\ntest('verifies email with mocked endpoints', async ({page}) => {\n  const mockToken = 'test-verification-abc123'\n\n  await page.route('**/api/auth/register', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Verification sent', verificationToken: mockToken}),\n    })\n  })\n\n  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({verified: true}),\n    })\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password', {exact: true}).fill('Password123!')\n  await page.getByRole('button', {name: 'Sign up'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  await page.goto(`/verify?token=${mockToken}`)\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n---\n\n## Password Reset\n\n### Complete Reset Flow\n\n```typescript\ntest('resets password through email link', async ({page}) => {\n  let resetToken = ''\n\n  await page.route('**/api/auth/forgot-password', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    resetToken = body.resetToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/forgot-password')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByRole('button', {name: 'Send link'}).click()\n\n  await expect(page.getByText('Reset email sent')).toBeVisible()\n\n  expect(resetToken).toBeTruthy()\n  await page.goto(`/reset-password?token=${resetToken}`)\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPassword456!')\n  await page.getByLabel('Confirm password').fill('NewPassword456!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText('Password updated')).toBeVisible()\n})\n```\n\n### Expired Token Handling\n\n```typescript\ntest('shows error for expired reset token', async ({page}) => {\n  await page.goto('/reset-password?token=expired-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPass!')\n  await page.getByLabel('Confirm password').fill('NewPass!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i)\n})\n```\n\n### Password Strength Validation\n\n```typescript\ntest('enforces password requirements on reset', async ({page}) => {\n  await page.goto('/reset-password?token=valid-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('weak')\n  await page.getByLabel('Confirm password').fill('weak')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()\n})\n```\n\n---\n\n## Session Timeout\n\n### Detecting Expired Sessions\n\n```typescript\ntest('redirects to signin after session expires', async ({page, context}) => {\n  await page.goto('/signin')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByLabel('Password').fill('Password!')\n  await page.getByRole('button', {name: 'Sign in'}).click()\n  await expect(page).toHaveURL('/home')\n\n  const cookies = await context.cookies()\n  const sessionCookie = cookies.find((c) => c.name.includes('session'))\n\n  if (sessionCookie) {\n    await context.clearCookies({name: sessionCookie.name})\n  }\n\n  await page.goto('/profile')\n  await expect(page).toHaveURL(/\\/signin/)\n  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible()\n})\n```\n\n### Session Extension Warning\n\n```typescript\ntest('shows warning before session expires', async ({page}) => {\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByText(/session.*expir/i)).toBeVisible({timeout: 10000})\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible()\n})\n```\n\n### Session Extension Action\n\n```typescript\ntest('extends session when user clicks extend', async ({page}) => {\n  let sessionExtended = false\n\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.route('**/api/auth/refresh', async (route) => {\n    sessionExtended = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 3600}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible({\n    timeout: 10000,\n  })\n  await page.getByRole('button', {name: /extend|stay signed in/i}).click()\n\n  expect(sessionExtended).toBe(true)\n  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()\n})\n```\n\n---\n\n## Remember Me Persistence\n\n### Persistent Session\n\n```typescript\ntest('persists session with remember me enabled', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  await page1.getByLabel('Keep me signed in').check()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  const state = await ctx1.storageState()\n  await ctx1.close()\n\n  const ctx2 = await browser.newContext({storageState: state})\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n  await expect(page2).toHaveURL('/home')\n  await expect(page2.getByText('Welcome')).toBeVisible()\n\n  await ctx2.close()\n})\n```\n\n### Session-Only Login\n\n```typescript\ntest('session-only login does not persist across browser restarts', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  // Leave \"Remember me\" unchecked\n  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  // Only keep persistent cookies (filter out session cookies)\n  const cookies = await ctx1.cookies()\n  await ctx1.close()\n\n  const persistentCookies = cookies.filter((c) => c.expires > 0)\n  const ctx2 = await browser.newContext()\n  await ctx2.addCookies(persistentCookies)\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n\n  // Should redirect to login since session was not persisted\n  await expect(page2).toHaveURL(/\\/signin/)\n\n  await ctx2.close()\n})\n```\n\n---\n\n## Logout Patterns\n\n### Standard Logout with Session Cleanup\n\n```typescript\ntest.use({storageState: '.auth/user.json'})\n\ntest('logs out and clears session', async ({page, context}) => {\n  await page.goto('/home')\n\n  await page.getByRole('button', {name: /account|menu/i}).click()\n  await page.getByRole('menuitem', {name: 'Sign out'}).click()\n\n  await expect(page).toHaveURL('/signin')\n\n  const cookies = await context.cookies()\n  const sessionCookies = cookies.filter(\n    (c) => c.name.includes('session') || c.name.includes('token'),\n  )\n  expect(sessionCookies).toHaveLength(0)\n\n  await page.goto('/home')\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n### Logout from All Devices\n\n```typescript\ntest('logs out from all devices', async ({page}) => {\n  let logoutAllCalled = false\n\n  await page.route('**/api/auth/logout-all', async (route) => {\n    logoutAllCalled = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Logged out everywhere'}),\n    })\n  })\n\n  await page.goto('/settings/security')\n\n  await page.getByRole('button', {name: 'Sign out everywhere'}).click()\n  await page.getByRole('dialog').getByRole('button', {name: 'Confirm'}).click()\n\n  expect(logoutAllCalled).toBe(true)\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n---\n\n## Tips\n\n1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests\n2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens\n3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared\n4. **Test the full flow end-to-end** — Password reset should verify login with new password works\n\n---\n\n## Related\n\n- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures\n- [third-party.md](./third-party.md) — Mocking external auth providers\n","isInternal":false,"tokens":2555,"sizeBytes":10645},{"name":"authentication.md","path":".agents/skills/playwright-best-practices/advanced/authentication.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/authentication.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Authentication Testing\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.\n\n## Quick Reference\n\n```typescript\n// Storage state reuse — the #1 pattern for fast auth\nawait page.goto('/login')\nawait page.getByLabel('Username').fill('testuser@example.com')\nawait page.getByLabel('Password').fill('secretPass123')\nawait page.getByRole('button', {name: 'Log in'}).click()\nawait page.context().storageState({path: '.auth/session.json'})\n\n// Reuse in config — every test starts authenticated\n{\n  use: {\n    storageState: '.auth/session.json'\n  }\n}\n\n// API login — skip the UI entirely\nconst context = await browser.newContext()\nconst response = await context.request.post('/api/auth/login', {\n  data: {email: 'testuser@example.com', password: 'secretPass123'},\n})\nawait context.storageState({path: '.auth/session.json'})\n```\n\n## Patterns\n\n### Storage State Reuse\n\n**Use when**: You need authenticated tests and want to avoid logging in before every test.\n**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.\n\n`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.\n\n```typescript\n// scripts/generate-auth.ts — run once to generate the state file\nimport {chromium} from '@playwright/test'\n\nasync function generateAuthState() {\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto('http://localhost:4000/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\ngenerateAuthState()\n```\n\n```typescript\n// playwright.config.ts — load saved state for all tests\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\n```typescript\n// tests/home.spec.ts — test starts already logged in\nimport {test, expect} from '@playwright/test'\n\ntest('authenticated user sees home page', async ({page}) => {\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n### Global Setup Authentication\n\n**Use when**: You want to authenticate once before the entire test suite runs.\n**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.\n\n```typescript\n// global-setup.ts\nimport {chromium, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto(`${baseURL}/login`)\n  await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('**/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\nAdd `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.\n\n### Per-Worker Authentication\n\n**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.\n**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.\n\n> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.\n\n```typescript\n// fixtures/auth.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedContext: BrowserContext\n}\n\nexport const test = base.extend<{}, AuthFixtures>({\n  authenticatedContext: [\n    async ({browser}, use) => {\n      const context = await browser.newContext()\n      const page = await context.newPage()\n\n      await page.goto('/login')\n      await page.getByLabel('Username').fill(`worker-${test.info().parallelIndex}@example.com`)\n      await page.getByLabel('Password').fill('secretPass123')\n      await page.getByRole('button', {name: 'Log in'}).click()\n      await page.waitForURL('/home')\n      await page.close()\n\n      await use(context)\n      await context.close()\n    },\n    {scope: 'worker'},\n  ],\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/settings.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('update display name', async ({authenticatedContext}) => {\n  const page = await authenticatedContext.newPage()\n  await page.goto('/settings/profile')\n  await page.getByLabel('Display name').fill('Updated Name')\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expect(page.getByText('Profile saved')).toBeVisible()\n})\n```\n\n### Multiple Roles\n\n**Use when**: Your app has role-based access control and you need to test different permission levels.\n**Avoid when**: Your app has a single user role.\n\n```typescript\n// global-setup.ts — authenticate all roles\nimport {chromium, type FullConfig} from '@playwright/test'\n\nconst accounts = [\n  {\n    role: 'admin',\n    email: 'admin@example.com',\n    password: process.env.ADMIN_PASSWORD!,\n  },\n  {\n    role: 'member',\n    email: 'member@example.com',\n    password: process.env.MEMBER_PASSWORD!,\n  },\n  {\n    role: 'guest',\n    email: 'guest@example.com',\n    password: process.env.GUEST_PASSWORD!,\n  },\n]\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  for (const {role, email, password} of accounts) {\n    const browser = await chromium.launch()\n    const context = await browser.newContext()\n    const page = await context.newPage()\n\n    await page.goto(`${baseURL}/login`)\n    await page.getByLabel('Username').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('**/home')\n\n    await context.storageState({path: `.auth/${role}.json`})\n    await browser.close()\n  }\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts — one project per role\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  projects: [\n    {\n      name: 'admin',\n      use: {storageState: '.auth/admin.json'},\n      testMatch: '**/*.admin.spec.ts',\n    },\n    {\n      name: 'member',\n      use: {storageState: '.auth/member.json'},\n      testMatch: '**/*.member.spec.ts',\n    },\n    {\n      name: 'guest',\n      use: {storageState: '.auth/guest.json'},\n      testMatch: '**/*.guest.spec.ts',\n    },\n    {\n      name: 'anonymous',\n      use: {storageState: {cookies: [], origins: []}},\n      testMatch: '**/*.anon.spec.ts',\n    },\n  ],\n})\n```\n\n```typescript\n// tests/admin-panel.admin.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('admin can access user management', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByRole('heading', {name: 'User Management'})).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Remove user'})).toBeEnabled()\n})\n```\n\n```typescript\n// tests/admin-panel.guest.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('guest cannot access admin panel', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n})\n```\n\n**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth.ts — role-based fixture\nimport {test as base, type Page} from '@playwright/test'\n\ntype RoleFixtures = {\n  loginAs: (role: 'admin' | 'member' | 'guest') => Promise<Page>\n}\n\nexport const test = base.extend<RoleFixtures>({\n  loginAs: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (role) => {\n      const statePath = `.auth/${role}.json`\n      if (!fs.existsSync(statePath)) {\n        throw new Error(`Auth state for role \"${role}\" not found at ${statePath}`)\n      }\n      const context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/role-comparison.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('admin sees remove button, guest does not', async ({loginAs}) => {\n  const adminPage = await loginAs('admin')\n  await adminPage.goto('/admin/users')\n  await expect(adminPage.getByRole('button', {name: 'Remove user'})).toBeVisible()\n\n  const guestPage = await loginAs('guest')\n  await guestPage.goto('/admin/users')\n  await expect(guestPage.getByText('Access denied')).toBeVisible()\n})\n```\n\n### OAuth/SSO Mocking\n\n**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.\n**Avoid when**: You have a dedicated test tenant on the OAuth provider.\n\nA typical OAuth flow works like this:\n\n1. User clicks \"Sign in with Provider\" → browser navigates to `https://accounts.provider.com/authorize?...`\n2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)\n3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page\n\nIn tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.\n\nFor cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.\n\n```typescript\n// tests/oauth-login.spec.ts — mock the callback route\nimport {test, expect} from '@playwright/test'\n\ntest('login via mocked OAuth flow', async ({page}) => {\n  await page.route('https://accounts.provider.com/**', async (route) => {\n    const callbackUrl = new URL('http://localhost:4000/auth/callback')\n    callbackUrl.searchParams.set('code', 'mock-auth-code-xyz')\n    callbackUrl.searchParams.set('state', 'expected-state-value')\n    await route.fulfill({\n      status: 302,\n      headers: {location: callbackUrl.toString()},\n    })\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Provider'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n```typescript\n// tests/oauth-login.spec.ts — API-based session injection\nimport {test, expect} from '@playwright/test'\n\ntest('bypass OAuth entirely via API session injection', async ({page}) => {\n  // Call a test-only endpoint that creates a session without OAuth\n  const response = await page.request.post('/api/test/create-session', {\n    data: {\n      email: 'oauth-user@example.com',\n      provider: 'provider',\n      role: 'member',\n    },\n  })\n  expect(response.ok()).toBeTruthy()\n\n  await page.context().storageState({path: '.auth/oauth-user.json'})\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.\n\n### MFA Handling\n\n**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).\n**Avoid when**: MFA is optional and you can disable it for test accounts.\n\n**Strategy 1**: Generate real TOTP codes from a shared secret.\n\n```typescript\n// helpers/totp.ts\nimport * as OTPAuth from 'otpauth'\n\nexport function generateTOTP(secret: string): string {\n  const totp = new OTPAuth.TOTP({\n    secret: OTPAuth.Secret.fromBase32(secret),\n    digits: 6,\n    period: 30,\n    algorithm: 'SHA1',\n  })\n  return totp.generate()\n}\n```\n\n```typescript\n// tests/mfa-login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {generateTOTP} from '../helpers/totp'\n\ntest('login with TOTP two-factor auth', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('mfa-user@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n\n  await expect(page.getByText('Enter your authentication code')).toBeVisible()\n\n  const code = generateTOTP(process.env.MFA_TOTP_SECRET!)\n  await page.getByLabel('Authentication code').fill(code)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.\n\n**Strategy 3**: Disable MFA for test accounts at the infrastructure level.\n\n### Session Refresh\n\n**Use when**: Your tokens expire during long test runs.\n**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth-with-refresh.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<AuthFixtures>({\n  authenticatedPage: async ({browser}, use) => {\n    const statePath = '.auth/session.json'\n\n    let context: BrowserContext\n    if (fs.existsSync(statePath)) {\n      context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n\n      const response = await page.request.get('/api/auth/me')\n      if (response.ok()) {\n        await use(page)\n        await context.close()\n        return\n      }\n      await context.close()\n    }\n\n    context = await browser.newContext()\n    const page = await context.newPage()\n    await page.goto('/login')\n    await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n    await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('/home')\n\n    await context.storageState({path: statePath})\n\n    await use(page)\n    await context.close()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Login Page Object\n\n**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.\n**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.\n\n```typescript\n// page-objects/LoginPage.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly usernameInput: Locator\n  readonly passwordInput: Locator\n  readonly loginButton: Locator\n  readonly errorMessage: Locator\n  readonly forgotPasswordLink: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.usernameInput = page.getByLabel('Username')\n    this.passwordInput = page.getByLabel('Password')\n    this.loginButton = page.getByRole('button', {name: 'Log in'})\n    this.errorMessage = page.getByRole('alert')\n    this.forgotPasswordLink = page.getByRole('link', {\n      name: 'Forgot password',\n    })\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n    await expect(this.loginButton).toBeVisible()\n  }\n\n  async login(username: string, password: string) {\n    await this.usernameInput.fill(username)\n    await this.passwordInput.fill(password)\n    await this.loginButton.click()\n  }\n\n  async loginAndWaitForHome(username: string, password: string) {\n    await this.login(username, password)\n    await this.page.waitForURL('/home')\n  }\n\n  async expectError(message: string | RegExp) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n\n  async expectFieldError(field: 'username' | 'password', message: string) {\n    const input = field === 'username' ? this.usernameInput : this.passwordInput\n    await expect(input).toHaveAttribute('aria-invalid', 'true')\n    const errorId = await input.getAttribute('aria-describedby')\n    if (errorId) {\n      await expect(this.page.locator(`#${errorId}`)).toContainText(message)\n    }\n  }\n}\n```\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../page-objects/LoginPage'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('login page', () => {\n  let loginPage: LoginPage\n\n  test.beforeEach(async ({page}) => {\n    loginPage = new LoginPage(page)\n    await loginPage.goto()\n  })\n\n  test('successful login redirects to home', async ({page}) => {\n    await loginPage.loginAndWaitForHome('testuser@example.com', 'secretPass123')\n    await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  })\n\n  test('wrong password shows error', async () => {\n    await loginPage.login('testuser@example.com', 'wrong-password')\n    await loginPage.expectError('Invalid username or password')\n  })\n\n  test('empty fields show validation errors', async () => {\n    await loginPage.loginButton.click()\n    await loginPage.expectFieldError('username', 'Username is required')\n  })\n\n  test('forgot password link navigates correctly', async ({page}) => {\n    await loginPage.forgotPasswordLink.click()\n    await page.waitForURL('/forgot-password')\n    await expect(page.getByRole('heading', {name: 'Reset password'})).toBeVisible()\n  })\n})\n```\n\n### API-Based Login\n\n**Use when**: You want the fastest possible authentication without any browser interaction.\n**Avoid when**: You are specifically testing the login UI.\n\nAPI login is typically 5-10x faster than UI login.\n\n```typescript\n// global-setup.ts — API-based login (fastest)\nimport {request, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  const requestContext = await request.newContext({baseURL})\n\n  const response = await requestContext.post('/api/auth/login', {\n    data: {\n      email: process.env.TEST_USER_EMAIL!,\n      password: process.env.TEST_USER_PASSWORD!,\n    },\n  })\n\n  if (!response.ok()) {\n    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)\n  }\n\n  await requestContext.storageState({path: '.auth/session.json'})\n  await requestContext.dispose()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// fixtures/api-auth.ts — fixture version for per-test authentication\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend({\n  authenticatedPage: async ({browser, playwright}, use) => {\n    const apiContext = await playwright.request.newContext({\n      baseURL: 'http://localhost:4000',\n    })\n\n    await apiContext.post('/api/auth/login', {\n      data: {\n        email: 'testuser@example.com',\n        password: 'secretPass123',\n      },\n    })\n\n    const state = await apiContext.storageState()\n    const context = await browser.newContext({storageState: state})\n    const page = await context.newPage()\n\n    await use(page)\n\n    await context.close()\n    await apiContext.dispose()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Unauthenticated Tests\n\n**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.\n**Avoid when**: The test requires a logged-in user.\n\nWhen your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.\n\n```typescript\n// tests/public-pages.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('unauthenticated access', () => {\n  test('homepage is accessible without login', async ({page}) => {\n    await page.goto('/')\n    await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()\n    await expect(page.getByRole('link', {name: 'Log in'})).toBeVisible()\n  })\n\n  test('protected route redirects to login', async ({page}) => {\n    await page.goto('/home')\n    await page.waitForURL('**/login**')\n    expect(page.url()).toContain('redirect=%2Fhome')\n  })\n\n  test('expired session shows re-login prompt', async ({page, context}) => {\n    await page.goto('/home')\n    await context.clearCookies()\n\n    await page.goto('/settings')\n    await page.waitForURL('**/login**')\n    await expect(page.getByText('Your session has expired')).toBeVisible()\n  })\n\n  test('signup flow creates account', async ({page}) => {\n    await page.goto('/signup')\n    await page.getByLabel('Name').fill('New User')\n    await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`)\n    await page.getByLabel('Password', {exact: true}).fill('secretPass123')\n    await page.getByLabel('Confirm password').fill('secretPass123')\n    await page.getByRole('button', {name: 'Create account'}).click()\n\n    await page.waitForURL('/onboarding')\n    await expect(page.getByText('Welcome, New User')).toBeVisible()\n  })\n})\n```\n\n## Decision Guide\n\n| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                 |\n| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |\n| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project                               |\n| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data          |\n| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/member/guest roles                               |\n| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` |\n| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI                           |\n| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes or use a test-mode bypass             |\n| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use                       |\n| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles                                 |\n| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth                                     |\n\n### UI Login vs API Login vs Storage State\n\n```text\nNeed to test the login page itself?\n├── Yes → UI login with LoginPage POM, no storageState\n└── No → Do you have a login API endpoint?\n    ├── Yes → API login in global setup, save storageState (fastest)\n    └── No → UI login in global setup, save storageState\n              └── Tokens expire quickly?\n                  ├── Yes → Add session refresh fixture\n                  └── No → Standard storageState reuse is fine\n```\n\n## Anti-Patterns\n\n| Don't Do This                                                             | Problem                                     | Do This Instead                                                           |\n| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |\n| Log in via UI before every test                                           | Adds 2-5 seconds per test                   | Use `storageState` to skip login entirely                                 |\n| Share a single auth state file across parallel workers that mutate state  | Race conditions                             | Use per-worker fixtures with `{ scope: 'worker' }`                        |\n| Hardcode credentials in test files                                        | Security risk                               | Use environment variables and `.env` files                                |\n| Ignore token expiration                                                   | Tests fail intermittently with 401 errors   | Add a session validity check in your auth fixture                         |\n| Hit real OAuth providers in CI                                            | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection                      |\n| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay                             | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |\n| Store `.auth/*.json` files in git                                         | Tokens in version control                   | Add `.auth/` to `.gitignore`                                              |\n| Create one \"god\" test account with all permissions                        | Cannot test role-based access control       | Create separate accounts per role                                         |\n| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated        | Pass `storageState` when creating the context                             |\n| Test MFA by disabling it everywhere                                       | You never test the MFA flow                 | Use TOTP generation for at least one test                                 |\n\n## Troubleshooting\n\n### Global setup fails with \"Target page, context or browser has been closed\"\n\n**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.\n\n**Fix**:\n\n- Add `await page.waitForURL()` after the login action\n- Check that `baseURL` in your config matches the actual server URL and protocol\n- Add error handling to global setup:\n\n```typescript\nconst response = await page.waitForResponse('**/api/auth/**')\nif (!response.ok()) {\n  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)\n}\n```\n\n### Tests fail with 401 Unauthorized after running for a while\n\n**Cause**: The session token saved in `storageState` has expired.\n\n**Fix**:\n\n- Use the session refresh fixture pattern\n- Increase token expiry in test environment configuration\n- Switch to API-based login in a worker-scoped fixture\n\n### `storageState` file is empty or contains no cookies\n\n**Cause**: `storageState()` was called before the login response set cookies.\n\n**Fix**:\n\n- Wait for the post-login page to load: `await page.waitForURL('/home')`\n- Verify cookies exist before saving:\n\n```typescript\nconst cookies = await context.cookies()\nif (cookies.length === 0) {\n  throw new Error('No cookies found after login')\n}\nawait context.storageState({path: '.auth/session.json'})\n```\n\n### Different browsers get different cookies\n\n**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.\n\n**Fix**:\n\n- Generate separate auth state files per browser project\n- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:\n\n```typescript\nprojects: [\n  {\n    name: 'chromium',\n    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },\n  },\n  {\n    name: 'firefox',\n    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },\n  },\n],\n```\n\n### Parallel tests interfere with each other's sessions\n\n**Cause**: Multiple workers share the same test account and one worker's actions affect others.\n\n**Fix**:\n\n- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`\n- Use the per-worker authentication fixture pattern\n- Make tests idempotent\n\n### OAuth mock does not work — still redirects to real provider\n\n**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.\n\n**Fix**:\n\n- Register route handlers before any navigation: call `page.route()` before `page.goto()`\n- Log the actual redirect URL to verify the pattern:\n\n```typescript\npage.on('request', (req) => {\n  if (req.url().includes('oauth') || req.url().includes('accounts.provider')) {\n    console.log('OAuth request:', req.url())\n  }\n})\n```\n\n## Related\n\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown\n- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration\n- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies\n- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking\n- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login\n- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness\n","isInternal":false,"tokens":6660,"sizeBytes":30280},{"name":"clock-mocking.md","path":".agents/skills/playwright-best-practices/advanced/clock-mocking.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/clock-mocking.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Date, Time & Clock Mocking\n\n## Table of Contents\n\n1. [Clock API Basics](#clock-api-basics)\n2. [Fixed Time Testing](#fixed-time-testing)\n3. [Time Advancement](#time-advancement)\n4. [Timezone Testing](#timezone-testing)\n5. [Timer Mocking](#timer-mocking)\n\n## Clock API Basics\n\n### Install Clock\n\n```typescript\ntest('mock current time', async ({page}) => {\n  // Install clock before navigating\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  await page.goto('/dashboard')\n\n  // Page sees January 15, 2025 as current date\n  await expect(page.getByText('January 15, 2025')).toBeVisible()\n})\n```\n\n### Clock with Fixture\n\n```typescript\n// fixtures/clock.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClockFixtures = {\n  mockTime: (date: Date | string) => Promise<void>\n}\n\nexport const test = base.extend<ClockFixtures>({\n  mockTime: async ({page}, use) => {\n    await use(async (date) => {\n      const time = typeof date === 'string' ? new Date(date) : date\n      await page.clock.install({time})\n    })\n  },\n})\n\n// Usage\ntest('subscription expiry', async ({page, mockTime}) => {\n  await mockTime('2025-12-31T23:59:00')\n  await page.goto('/subscription')\n\n  await expect(page.getByText('Expires today')).toBeVisible()\n})\n```\n\n## Fixed Time Testing\n\n### Test Date-Dependent Features\n\n```typescript\ntest('show holiday banner in December', async ({page}) => {\n  await page.clock.install({time: new Date('2025-12-20T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeVisible()\n})\n\ntest('no holiday banner in January', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeHidden()\n})\n```\n\n### Test Relative Time Display\n\n```typescript\ntest('shows relative time correctly', async ({page}) => {\n  // Fix time to control \"posted 2 hours ago\" text\n  await page.clock.install({time: new Date('2025-06-15T14:00:00')})\n\n  // Mock API to return post with known timestamp\n  await page.route('**/api/posts/1', (route) =>\n    route.fulfill({\n      json: {\n        id: 1,\n        title: 'Test Post',\n        createdAt: '2025-06-15T12:00:00Z', // 2 hours before mock time\n      },\n    }),\n  )\n\n  await page.goto('/posts/1')\n\n  await expect(page.getByText('2 hours ago')).toBeVisible()\n})\n```\n\n### Test Date Boundaries\n\n```typescript\ntest.describe('end of month billing', () => {\n  test('shows billing on last day of month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-31T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('Payment due today')).toBeVisible()\n  })\n\n  test('shows days remaining mid-month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('16 days until payment')).toBeVisible()\n  })\n})\n```\n\n## Time Advancement\n\n### Advance Time Manually\n\n```typescript\ntest('session timeout warning', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/dashboard')\n\n  // Advance 25 minutes (session timeout at 30 min)\n  await page.clock.fastForward('25:00')\n\n  await expect(page.getByText('Session expires in 5 minutes')).toBeVisible()\n\n  // Advance 5 more minutes\n  await page.clock.fastForward('05:00')\n\n  await expect(page.getByText('Session expired')).toBeVisible()\n})\n```\n\n### Pause and Resume Time\n\n```typescript\ntest('countdown timer', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/sale')\n\n  // Initial state\n  await expect(page.getByText('Sale ends in 2:00:00')).toBeVisible()\n\n  // Advance 1 hour\n  await page.clock.fastForward('01:00:00')\n\n  await expect(page.getByText('Sale ends in 1:00:00')).toBeVisible()\n\n  // Advance past end\n  await page.clock.fastForward('01:00:01')\n\n  await expect(page.getByText('Sale ended')).toBeVisible()\n})\n```\n\n### Run Pending Timers\n\n```typescript\ntest('debounced search', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/search')\n\n  await page.getByLabel('Search').fill('playwright')\n\n  // Search is debounced by 300ms, won't fire yet\n  await expect(page.getByTestId('search-results')).toBeHidden()\n\n  // Fast forward past debounce\n  await page.clock.fastForward(300)\n\n  // Now search should execute\n  await expect(page.getByTestId('search-results')).toBeVisible()\n})\n```\n\n## Timezone Testing\n\n### Test Different Timezones\n\n```typescript\ntest.describe('timezone display', () => {\n  test('shows correct time in PST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'America/Los_Angeles',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 9 AM PST\n    await expect(page.getByText('9:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n\n  test('shows correct time in JST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'Asia/Tokyo',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 2 AM next day JST\n    await expect(page.getByText('2:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n})\n```\n\n### Timezone Fixture\n\n```typescript\n// fixtures/timezone.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype TimezoneFixtures = {\n  pageInTimezone: (timezone: string) => Promise<Page>\n}\n\nexport const test = base.extend<TimezoneFixtures>({\n  pageInTimezone: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (timezone) => {\n      const context = await browser.newContext({timezoneId: timezone})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    // Cleanup\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n```\n\n## Timer Mocking\n\n### Mock setInterval\n\n```typescript\ntest('auto-refresh data', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  let apiCalls = 0\n  await page.route('**/api/data', (route) => {\n    apiCalls++\n    route.fulfill({json: {value: apiCalls}})\n  })\n\n  await page.goto('/live-data') // Sets up 30s refresh interval\n\n  expect(apiCalls).toBe(1) // Initial load\n\n  // Advance 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(2) // First refresh\n\n  // Advance another 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(3) // Second refresh\n})\n```\n\n### Mock setTimeout Chains\n\n```typescript\ntest('notification queue', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/notifications')\n\n  // Trigger 3 notifications that show sequentially\n  await page.getByRole('button', {name: 'Show All'}).click()\n\n  // First notification appears immediately\n  await expect(page.getByText('Notification 1')).toBeVisible()\n\n  // Second appears after 2 seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 2')).toBeVisible()\n\n  // Third appears after 2 more seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 3')).toBeVisible()\n})\n```\n\n### Test Animation Frames\n\n```typescript\ntest('animation completes', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/animation-demo')\n\n  await page.getByRole('button', {name: 'Animate'}).click()\n\n  // Animation runs for 500ms\n  const element = page.getByTestId('animated-box')\n  await expect(element).toHaveCSS('opacity', '0')\n\n  // Fast forward through animation\n  await page.clock.fastForward(500)\n\n  await expect(element).toHaveCSS('opacity', '1')\n})\n```\n\n## Best Practices\n\n### Always Install Clock Before Navigation\n\n```typescript\n// Good\ntest('date test', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15')})\n  await page.goto('/') // Page loads with mocked time\n})\n\n// Bad - time already captured by page\ntest('date test', async ({page}) => {\n  await page.goto('/')\n  await page.clock.install({time: new Date('2025-01-15')}) // Too late!\n})\n```\n\n### Use ISO Strings for Clarity\n\n```typescript\n// Good - explicit timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00Z')})\n\n// Ambiguous - uses local timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00')})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                             | Problem                         | Solution                               |\n| ---------------------------------------- | ------------------------------- | -------------------------------------- |\n| Installing clock after navigation        | Page already captured real time | Install clock before `goto()`          |\n| Hardcoded relative dates                 | Tests break over time           | Use fixed dates with clock mock        |\n| Not accounting for timezone              | Tests fail in different regions | Use explicit UTC times or set timezone |\n| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers    | Use `fastForward` instead              |\n\n## Related References\n\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures\n","isInternal":false,"tokens":2502,"sizeBytes":9656},{"name":"mobile-testing.md","path":".agents/skills/playwright-best-practices/advanced/mobile-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/mobile-testing.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Mobile & Responsive Testing\n\n## Table of Contents\n\n1. [Device Emulation](#device-emulation)\n2. [Touch Gestures](#touch-gestures)\n3. [Viewport Testing](#viewport-testing)\n4. [Mobile-Specific UI](#mobile-specific-ui)\n5. [Responsive Breakpoints](#responsive-breakpoints)\n\n## Device Emulation\n\n### Use Built-in Devices\n\n```typescript\nimport {test, devices} from '@playwright/test'\n\n// Configure in playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'Desktop Chrome', use: {...devices['Desktop Chrome']}},\n    {name: 'Mobile Safari', use: {...devices['iPhone 14']}},\n    {name: 'Mobile Chrome', use: {...devices['Pixel 7']}},\n    {name: 'Tablet', use: {...devices['iPad Pro 11']}},\n  ],\n})\n```\n\n### Custom Device Configuration\n\n```typescript\ntest.use({\n  viewport: {width: 390, height: 844},\n  deviceScaleFactor: 3,\n  isMobile: true,\n  hasTouch: true,\n  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',\n})\n\ntest('custom mobile device', async ({page}) => {\n  await page.goto('/')\n  // Test runs with custom device settings\n})\n```\n\n### Test Across Multiple Devices\n\n```typescript\nconst mobileDevices = ['iPhone 14', 'Pixel 7', 'Galaxy S21']\n\nfor (const deviceName of mobileDevices) {\n  test(`checkout on ${deviceName}`, async ({browser}) => {\n    const device = devices[deviceName]\n    const context = await browser.newContext({...device})\n    const page = await context.newPage()\n\n    await page.goto('/checkout')\n    await expect(page.getByRole('button', {name: 'Pay'})).toBeVisible()\n\n    await context.close()\n  })\n}\n```\n\n## Touch Gestures\n\n### Tap\n\n```typescript\ntest.use({hasTouch: true})\n\ntest('tap to interact', async ({page}) => {\n  await page.goto('/gallery')\n\n  // Tap is like click but for touch devices\n  await page.getByRole('img', {name: 'Photo 1'}).tap()\n\n  await expect(page.getByRole('dialog')).toBeVisible()\n})\n```\n\n### Swipe\n\n```typescript\ntest('swipe carousel', async ({page}) => {\n  await page.goto('/carousel')\n\n  const carousel = page.getByTestId('carousel')\n  const box = await carousel.boundingBox()\n\n  if (box) {\n    // Swipe left\n    await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2)\n    await page.mouse.move(box.x + 50, box.y + box.height / 2)\n\n    // Or use drag\n    await carousel.dragTo(carousel, {\n      sourcePosition: {x: box.width - 50, y: box.height / 2},\n      targetPosition: {x: 50, y: box.height / 2},\n    })\n  }\n\n  await expect(page.getByText('Slide 2')).toBeVisible()\n})\n```\n\n### Swipe Fixture\n\n```typescript\n// fixtures/touch.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype TouchFixtures = {\n  swipe: (element: Locator, direction: 'left' | 'right' | 'up' | 'down') => Promise<void>\n}\n\nexport const test = base.extend<TouchFixtures>({\n  swipe: async ({page}, use) => {\n    await use(async (element, direction) => {\n      const box = await element.boundingBox()\n      if (!box) throw new Error('Element not visible')\n\n      const centerX = box.x + box.width / 2\n      const centerY = box.y + box.height / 2\n      const distance = 100\n\n      const moves = {\n        left: {\n          startX: centerX + distance,\n          endX: centerX - distance,\n          y: centerY,\n        },\n        right: {\n          startX: centerX - distance,\n          endX: centerX + distance,\n          y: centerY,\n        },\n        up: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY + distance,\n          endY: centerY - distance,\n        },\n        down: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY - distance,\n          endY: centerY + distance,\n        },\n      }\n\n      const move = moves[direction]\n      await page.touchscreen.tap(move.startX, move.startY ?? move.y)\n      await page.mouse.move(move.endX, move.endY ?? move.y, {steps: 10})\n      await page.mouse.up()\n    })\n  },\n})\n\n// Usage\ntest('swipe to delete', async ({page, swipe}) => {\n  await page.goto('/inbox')\n\n  const message = page.getByTestId('message-1')\n  await swipe(message, 'left')\n\n  await expect(page.getByRole('button', {name: 'Delete'})).toBeVisible()\n})\n```\n\n### Long Press\n\n```typescript\ntest('long press for context menu', async ({page}) => {\n  await page.goto('/files')\n\n  const file = page.getByText('document.pdf')\n  const box = await file.boundingBox()\n\n  if (box) {\n    // Touch down\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2)\n\n    // Hold for 500ms\n    await page.waitForTimeout(500)\n\n    // Context menu should appear\n    await expect(page.getByRole('menu')).toBeVisible()\n  }\n})\n```\n\n### Pinch Zoom\n\n```typescript\ntest('pinch to zoom image', async ({page}) => {\n  await page.goto('/map')\n\n  // Pinch zoom requires two touch points\n  // Playwright doesn't have native pinch support, so we simulate via evaluate\n  await page.evaluate(() => {\n    const element = document.querySelector('#map')\n    if (element) {\n      // Simulate wheel event as fallback for zoom\n      element.dispatchEvent(\n        new WheelEvent('wheel', {\n          deltaY: -100, // Negative = zoom in\n          ctrlKey: true, // Ctrl+wheel = pinch on many apps\n        }),\n      )\n    }\n  })\n\n  // Or trigger the app's zoom function directly\n  await page.evaluate(() => {\n    ;(window as any).mapInstance?.setZoom(15)\n  })\n})\n```\n\n## Viewport Testing\n\n### Test Different Sizes\n\n```typescript\nconst viewports = [\n  {name: 'mobile', width: 375, height: 667},\n  {name: 'tablet', width: 768, height: 1024},\n  {name: 'desktop', width: 1920, height: 1080},\n]\n\nfor (const {name, width, height} of viewports) {\n  test(`navigation on ${name}`, async ({page}) => {\n    await page.setViewportSize({width, height})\n    await page.goto('/')\n\n    if (width < 768) {\n      // Mobile: should have hamburger menu\n      await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n    } else {\n      // Desktop: should have visible nav links\n      await expect(page.getByRole('link', {name: 'Products'})).toBeVisible()\n    }\n  })\n}\n```\n\n### Dynamic Viewport Changes\n\n```typescript\ntest('responsive layout change', async ({page}) => {\n  await page.setViewportSize({width: 1200, height: 800})\n  await page.goto('/dashboard')\n\n  // Desktop: sidebar visible\n  await expect(page.getByRole('complementary')).toBeVisible()\n\n  // Resize to mobile\n  await page.setViewportSize({width: 375, height: 667})\n\n  // Mobile: sidebar hidden, hamburger visible\n  await expect(page.getByRole('complementary')).toBeHidden()\n  await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n})\n```\n\n## Mobile-Specific UI\n\n### Hamburger Menu\n\n```typescript\ntest('mobile navigation', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/')\n\n  // Open hamburger menu\n  await page.getByRole('button', {name: 'Menu'}).click()\n\n  // Navigation drawer should appear\n  const nav = page.getByRole('navigation')\n  await expect(nav).toBeVisible()\n\n  // Navigate via mobile menu\n  await nav.getByRole('link', {name: 'Products'}).click()\n\n  await expect(page).toHaveURL('/products')\n  // Menu should close after navigation\n  await expect(nav).toBeHidden()\n})\n```\n\n### Bottom Sheet\n\n```typescript\ntest('bottom sheet interaction', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/product/123')\n\n  await page.getByRole('button', {name: 'Add to Cart'}).click()\n\n  // Bottom sheet appears\n  const sheet = page.getByRole('dialog')\n  await expect(sheet).toBeVisible()\n\n  // Select options\n  await sheet.getByRole('combobox', {name: 'Size'}).selectOption('Large')\n  await sheet.getByRole('button', {name: 'Confirm'}).click()\n\n  await expect(page.getByText('Added to cart')).toBeVisible()\n})\n```\n\n### Pull to Refresh\n\n```typescript\ntest('pull to refresh', async ({page}) => {\n  await page.goto('/feed')\n\n  const feed = page.getByTestId('feed')\n  const initialFirstItem = await feed.locator('> *').first().textContent()\n\n  // Simulate pull down\n  const box = await feed.boundingBox()\n  if (box) {\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + 50)\n    await page.mouse.move(box.x + box.width / 2, box.y + 200, {steps: 20})\n    await page.mouse.up()\n  }\n\n  // Wait for refresh\n  await expect(page.getByTestId('loading')).toBeVisible()\n  await expect(page.getByTestId('loading')).toBeHidden()\n\n  // Content should be updated (in a real app)\n})\n```\n\n## Responsive Breakpoints\n\n### Test All Breakpoints\n\n```typescript\nconst breakpoints = {\n  'xs': 320,\n  'sm': 640,\n  'md': 768,\n  'lg': 1024,\n  'xl': 1280,\n  '2xl': 1536,\n}\n\ntest.describe('responsive header', () => {\n  for (const [name, width] of Object.entries(breakpoints)) {\n    test(`header at ${name} (${width}px)`, async ({page}) => {\n      await page.setViewportSize({width, height: 800})\n      await page.goto('/')\n\n      if (width < 768) {\n        await expect(page.getByTestId('mobile-menu-button')).toBeVisible()\n        await expect(page.getByTestId('desktop-nav')).toBeHidden()\n      } else {\n        await expect(page.getByTestId('mobile-menu-button')).toBeHidden()\n        await expect(page.getByTestId('desktop-nav')).toBeVisible()\n      }\n    })\n  }\n})\n```\n\n### Visual Regression at Breakpoints\n\n```typescript\ntest.describe('visual regression', () => {\n  const sizes = [\n    {width: 375, height: 667, name: 'mobile'},\n    {width: 768, height: 1024, name: 'tablet'},\n    {width: 1440, height: 900, name: 'desktop'},\n  ]\n\n  for (const {width, height, name} of sizes) {\n    test(`homepage at ${name}`, async ({page}) => {\n      await page.setViewportSize({width, height})\n      await page.goto('/')\n\n      await expect(page).toHaveScreenshot(`homepage-${name}.png`)\n    })\n  }\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                   | Solution                         |\n| --------------------------- | ------------------------- | -------------------------------- |\n| Only testing one viewport   | Misses responsive bugs    | Test multiple breakpoints        |\n| Ignoring touch events       | Features broken on mobile | Test tap, swipe, long press      |\n| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()`     |\n| Not testing orientation     | Landscape bugs missed     | Test both portrait and landscape |\n\n## Related References\n\n- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing\n- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors\n- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)\n- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements\n","isInternal":false,"tokens":2732,"sizeBytes":10755},{"name":"multi-context.md","path":".agents/skills/playwright-best-practices/advanced/multi-context.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/multi-context.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Multi-Tab, Window & Popup Testing\n\nThis file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).\n\n## Table of Contents\n\n1. [Popup Handling](#popup-handling)\n2. [New Tab Navigation](#new-tab-navigation)\n3. [OAuth Flows](#oauth-flows)\n4. [Multiple Windows](#multiple-windows)\n5. [Tab Coordination](#tab-coordination)\n\n## Popup Handling\n\n### Basic Popup\n\n```typescript\ntest('handle popup window', async ({page}) => {\n  await page.goto('/')\n\n  // Start waiting for popup before triggering it\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Open Support Chat'}).click()\n  const popup = await popupPromise\n\n  // Wait for popup to load\n  await popup.waitForLoadState()\n\n  // Interact with popup\n  await popup.getByLabel('Message').fill('Need help')\n  await popup.getByRole('button', {name: 'Send'}).click()\n\n  await expect(popup.getByText('Message sent')).toBeVisible()\n\n  // Close popup\n  await popup.close()\n})\n```\n\n### Popup with Authentication\n\n```typescript\ntest('popup login flow', async ({page}) => {\n  await page.goto('/dashboard')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Connect Account'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Complete login in popup\n  await popup.getByLabel('Email').fill('user@example.com')\n  await popup.getByLabel('Password').fill('password123')\n  await popup.getByRole('button', {name: 'Log In'}).click()\n\n  // Popup should close automatically after auth\n  await popup.waitForEvent('close')\n\n  // Main page should reflect connected state\n  await expect(page.getByText('Account connected')).toBeVisible()\n})\n```\n\n### Handle Blocked Popups\n\n```typescript\ntest('handle popup blocker', async ({page}) => {\n  await page.goto('/share')\n\n  // Listen for console messages about blocked popup\n  page.on('console', (msg) => {\n    if (msg.text().includes('popup blocked')) {\n      console.log('Popup was blocked')\n    }\n  })\n\n  const popupPromise = page.waitForEvent('popup').catch(() => null)\n  await page.getByRole('button', {name: 'Share to Twitter'}).click()\n  const popup = await popupPromise\n\n  if (!popup) {\n    // Popup blocked - app should show fallback\n    await expect(page.getByText('Copy share link instead')).toBeVisible()\n  }\n})\n```\n\n## New Tab Navigation\n\n### Link Opens in New Tab\n\n```typescript\ntest('external link opens in new tab', async ({page, context}) => {\n  await page.goto('/resources')\n\n  // Wait for new page in context\n  const pagePromise = context.waitForEvent('page')\n  await page.getByRole('link', {name: 'Documentation'}).click()\n  const newPage = await pagePromise\n\n  await newPage.waitForLoadState()\n\n  expect(newPage.url()).toContain('docs.example.com')\n  await expect(newPage.getByRole('heading', {level: 1})).toBeVisible()\n\n  // Original page still there\n  expect(page.url()).toContain('/resources')\n\n  await newPage.close()\n})\n```\n\n### Intercept New Tab\n\n```typescript\ntest('prevent new tab for testing', async ({page}) => {\n  await page.goto('/links')\n\n  // Remove target=\"_blank\" to keep navigation in same tab\n  await page.evaluate(() => {\n    document.querySelectorAll('a[target=\"_blank\"]').forEach((a) => {\n      a.removeAttribute('target')\n    })\n  })\n\n  // Now link opens in same tab\n  await page.getByRole('link', {name: 'External Site'}).click()\n\n  // Can test the destination page\n  await expect(page).toHaveURL(/external-site\\.com/)\n})\n```\n\n## OAuth Flows\n\n### Google OAuth Popup\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  await page.goto('/login')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Handle Google's OAuth flow\n  await popup.getByLabel('Email or phone').fill('test@gmail.com')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  await popup.getByLabel('Enter your password').fill('password')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for redirect back and popup close\n  await popup.waitForEvent('close')\n\n  // Verify logged in on main page\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### Mock OAuth (Recommended)\n\n```typescript\ntest('mock OAuth flow', async ({page, context}) => {\n  // Mock the OAuth callback instead of real flow\n  await page.route('**/auth/callback**', async (route) => {\n    // Simulate successful OAuth\n    const url = new URL(route.request().url())\n    url.searchParams.set('code', 'mock-auth-code')\n    await route.fulfill({\n      status: 302,\n      headers: {Location: '/dashboard'},\n    })\n  })\n\n  // Mock token exchange\n  await page.route('**/api/auth/token', (route) =>\n    route.fulfill({\n      json: {\n        access_token: 'mock-token',\n        user: {name: 'Test User', email: 'test@example.com'},\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // Should redirect to dashboard without actual OAuth\n  await expect(page).toHaveURL('/dashboard')\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.\n\n## Multiple Windows\n\n### Test Across Multiple Windows\n\n```typescript\ntest('sync between windows', async ({context}) => {\n  // Open two pages\n  const page1 = await context.newPage()\n  const page2 = await context.newPage()\n\n  await page1.goto('/dashboard')\n  await page2.goto('/dashboard')\n\n  // Make change in first window\n  await page1.getByRole('button', {name: 'Add Item'}).click()\n  await page1.getByLabel('Name').fill('New Item')\n  await page1.getByRole('button', {name: 'Save'}).click()\n\n  // Should sync to second window (if app supports real-time sync)\n  await expect(page2.getByText('New Item')).toBeVisible({timeout: 10000})\n})\n```\n\n### Different Users in Different Windows\n\n> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.\n\n## Tab Coordination\n\n### Switch Between Tabs\n\n```typescript\ntest('manage multiple tabs', async ({context}) => {\n  const page1 = await context.newPage()\n  await page1.goto('/editor')\n\n  const page2 = await context.newPage()\n  await page2.goto('/preview')\n\n  // Edit in first tab\n  await page1.bringToFront()\n  await page1.getByLabel('Content').fill('Hello World')\n\n  // Check preview in second tab\n  await page2.bringToFront()\n  await page2.reload() // If preview needs refresh\n  await expect(page2.getByText('Hello World')).toBeVisible()\n})\n```\n\n### Close All Tabs Except One\n\n```typescript\ntest('cleanup tabs after test', async ({context}) => {\n  const mainPage = await context.newPage()\n  await mainPage.goto('/')\n\n  // Open several popups during test\n  for (let i = 0; i < 3; i++) {\n    const popup = await context.newPage()\n    await popup.goto(`/popup/${i}`)\n  }\n\n  // Close all except main page\n  for (const page of context.pages()) {\n    if (page !== mainPage) {\n      await page.close()\n    }\n  }\n\n  expect(context.pages()).toHaveLength(1)\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern            | Problem                        | Solution                                   |\n| ----------------------- | ------------------------------ | ------------------------------------------ |\n| Not waiting for popup   | Race condition                 | Use `waitForEvent(\"popup\")` before trigger |\n| Testing real OAuth      | Slow, flaky, needs credentials | Mock OAuth endpoints                       |\n| Assuming popup opens    | May be blocked                 | Handle both open and blocked cases         |\n| Not closing extra pages | Resource leak                  | Close pages in cleanup                     |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth\n","isInternal":false,"tokens":1967,"sizeBytes":8422},{"name":"multi-user.md","path":".agents/skills/playwright-best-practices/advanced/multi-user.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/multi-user.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Multi-User & Collaboration Testing\n\n## Table of Contents\n\n1. [Multiple Browser Contexts](#multiple-browser-contexts)\n2. [Real-Time Collaboration](#real-time-collaboration)\n3. [Role-Based Testing](#role-based-testing)\n4. [Concurrent Actions](#concurrent-actions)\n5. [Chat & Messaging](#chat--messaging)\n\n## Multiple Browser Contexts\n\n### Two Users in Same Test\n\n```typescript\ntest(\"two users see each other's changes\", async ({browser}) => {\n  // Create two isolated contexts (like two browsers)\n  const userAContext = await browser.newContext()\n  const userBContext = await browser.newContext()\n\n  const userAPage = await userAContext.newPage()\n  const userBPage = await userBContext.newPage()\n\n  // Both users go to the same document\n  await userAPage.goto('/doc/shared-123')\n  await userBPage.goto('/doc/shared-123')\n\n  // User A types\n  await userAPage.getByLabel('Content').fill('Hello from User A')\n\n  // User B should see the change\n  await expect(userBPage.getByText('Hello from User A')).toBeVisible()\n\n  // Cleanup\n  await userAContext.close()\n  await userBContext.close()\n})\n```\n\n### Multiple Users with Auth States\n\n```typescript\ntest('admin and user interaction', async ({browser}) => {\n  // Load different auth states\n  const adminContext = await browser.newContext({\n    storageState: '.auth/admin.json',\n  })\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n\n  const adminPage = await adminContext.newPage()\n  const userPage = await userContext.newPage()\n\n  // User submits request\n  await userPage.goto('/support')\n  await userPage.getByLabel('Message').fill('Need help!')\n  await userPage.getByRole('button', {name: 'Submit'}).click()\n\n  // Admin sees and responds\n  await adminPage.goto('/admin/tickets')\n  await expect(adminPage.getByText('Need help!')).toBeVisible()\n  await adminPage.getByRole('button', {name: 'Reply'}).click()\n  await adminPage.getByLabel('Response').fill('How can I help?')\n  await adminPage.getByRole('button', {name: 'Send'}).click()\n\n  // User sees response\n  await expect(userPage.getByText('How can I help?')).toBeVisible()\n\n  await adminContext.close()\n  await userContext.close()\n})\n```\n\n### Multi-User Fixture\n\n```typescript\n// fixtures/multi-user.fixture.ts\nimport {test as base, Browser, BrowserContext, Page} from '@playwright/test'\n\ntype UserSession = {\n  context: BrowserContext\n  page: Page\n}\n\ntype MultiUserFixtures = {\n  createUser: (authState?: string) => Promise<UserSession>\n}\n\nexport const test = base.extend<MultiUserFixtures>({\n  createUser: async ({browser}, use) => {\n    const sessions: UserSession[] = []\n\n    await use(async (authState) => {\n      const context = await browser.newContext({\n        storageState: authState,\n      })\n      const page = await context.newPage()\n      sessions.push({context, page})\n      return {context, page}\n    })\n\n    // Cleanup all sessions\n    for (const session of sessions) {\n      await session.context.close()\n    }\n  },\n})\n\n// Usage\ntest('3 users collaborate', async ({createUser}) => {\n  const alice = await createUser('.auth/alice.json')\n  const bob = await createUser('.auth/bob.json')\n  const charlie = await createUser('.auth/charlie.json')\n\n  // All navigate to same room\n  await alice.page.goto('/room/123')\n  await bob.page.goto('/room/123')\n  await charlie.page.goto('/room/123')\n\n  // Test interactions...\n})\n```\n\n## Real-Time Collaboration\n\n### Collaborative Document\n\n```typescript\ntest('real-time collaborative editing', async ({browser}) => {\n  const user1 = await browser.newContext()\n  const user2 = await browser.newContext()\n\n  const page1 = await user1.newPage()\n  const page2 = await user2.newPage()\n\n  await page1.goto('/docs/shared')\n  await page2.goto('/docs/shared')\n\n  // User 1 types at the beginning\n  const editor1 = page1.getByRole('textbox')\n  await editor1.click()\n  await editor1.press('Home')\n  await editor1.type('User 1: ')\n\n  // User 2 types at the end\n  const editor2 = page2.getByRole('textbox')\n  await editor2.click()\n  await editor2.press('End')\n  await editor2.type(' - User 2')\n\n  // Both should see combined result\n  await expect(page1.getByRole('textbox')).toContainText('User 1:')\n  await expect(page1.getByRole('textbox')).toContainText('- User 2')\n  await expect(page2.getByRole('textbox')).toContainText('User 1:')\n  await expect(page2.getByRole('textbox')).toContainText('- User 2')\n\n  await user1.close()\n  await user2.close()\n})\n```\n\n### Cursor Presence\n\n```typescript\ntest('shows other user cursors', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Mock to identify users\n  await page1.route('**/api/me', (route) => route.fulfill({json: {id: 'user-1', name: 'Alice'}}))\n  await page2.route('**/api/me', (route) => route.fulfill({json: {id: 'user-2', name: 'Bob'}}))\n\n  await page1.goto('/whiteboard/123')\n  await page2.goto('/whiteboard/123')\n\n  // Move cursor on page1\n  await page1.mouse.move(200, 200)\n\n  // Page2 should see Alice's cursor\n  await expect(page2.getByTestId('cursor-user-1')).toBeVisible()\n  await expect(page2.getByText('Alice')).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Role-Based Testing\n\n### Test RBAC\n\n```typescript\nconst roles = [\n  {role: 'admin', canDelete: true, canEdit: true, canView: true},\n  {role: 'editor', canDelete: false, canEdit: true, canView: true},\n  {role: 'viewer', canDelete: false, canEdit: false, canView: true},\n]\n\nfor (const {role, canDelete, canEdit, canView} of roles) {\n  test(`${role} permissions`, async ({browser}) => {\n    const context = await browser.newContext({\n      storageState: `.auth/${role}.json`,\n    })\n    const page = await context.newPage()\n\n    await page.goto('/document/123')\n\n    // Check view permission\n    if (canView) {\n      await expect(page.getByTestId('content')).toBeVisible()\n    } else {\n      await expect(page.getByText('Access denied')).toBeVisible()\n    }\n\n    // Check edit permission\n    const editButton = page.getByRole('button', {name: 'Edit'})\n    if (canEdit) {\n      await expect(editButton).toBeEnabled()\n    } else {\n      await expect(editButton).toBeDisabled()\n    }\n\n    // Check delete permission\n    const deleteButton = page.getByRole('button', {name: 'Delete'})\n    if (canDelete) {\n      await expect(deleteButton).toBeVisible()\n    } else {\n      await expect(deleteButton).toBeHidden()\n    }\n\n    await context.close()\n  })\n}\n```\n\n### Permission Escalation Test\n\n```typescript\ntest('cannot access admin routes as user', async ({browser}) => {\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n  const page = await userContext.newPage()\n\n  // Try to access admin page directly\n  await page.goto('/admin/users')\n\n  // Should redirect or show error\n  await expect(page).not.toHaveURL('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n\n  await userContext.close()\n})\n```\n\n## Concurrent Actions\n\n### Race Condition Testing\n\n```typescript\ntest('handles concurrent edits', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  await page1.goto('/item/123')\n  await page2.goto('/item/123')\n\n  // Both click edit at the same time\n  await Promise.all([\n    page1.getByRole('button', {name: 'Edit'}).click(),\n    page2.getByRole('button', {name: 'Edit'}).click(),\n  ])\n\n  // Both try to save different values\n  await page1.getByLabel('Name').fill('Value from User 1')\n  await page2.getByLabel('Name').fill('Value from User 2')\n\n  await Promise.all([\n    page1.getByRole('button', {name: 'Save'}).click(),\n    page2.getByRole('button', {name: 'Save'}).click(),\n  ])\n\n  // One should succeed, one should get conflict error\n  const page1HasConflict = await page1.getByText('Conflict').isVisible()\n  const page2HasConflict = await page2.getByText('Conflict').isVisible()\n\n  // Exactly one should have conflict\n  expect(page1HasConflict || page2HasConflict).toBe(true)\n  expect(page1HasConflict && page2HasConflict).toBe(false)\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n### Optimistic Locking Test\n\n```typescript\ntest('optimistic locking prevents overwrites', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Both load the same version\n  await page1.goto('/record/123')\n  await page2.goto('/record/123')\n\n  // User 1 edits and saves first\n  await page1.getByRole('button', {name: 'Edit'}).click()\n  await page1.getByLabel('Value').fill('Updated by User 1')\n  await page1.getByRole('button', {name: 'Save'}).click()\n  await expect(page1.getByText('Saved')).toBeVisible()\n\n  // User 2 tries to save with stale version\n  await page2.getByRole('button', {name: 'Edit'}).click()\n  await page2.getByLabel('Value').fill('Updated by User 2')\n  await page2.getByRole('button', {name: 'Save'}).click()\n\n  // Should fail with version conflict\n  await expect(page2.getByText('Someone else modified this')).toBeVisible()\n  await expect(page2.getByRole('button', {name: 'Reload'})).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Chat & Messaging\n\n### Real-Time Chat\n\n```typescript\ntest('chat messages sync between users', async ({browser}) => {\n  const aliceCtx = await browser.newContext()\n  const bobCtx = await browser.newContext()\n\n  const alicePage = await aliceCtx.newPage()\n  const bobPage = await bobCtx.newPage()\n\n  // Setup user identities\n  await alicePage.route('**/api/me', (r) => r.fulfill({json: {name: 'Alice'}}))\n  await bobPage.route('**/api/me', (r) => r.fulfill({json: {name: 'Bob'}}))\n\n  await alicePage.goto('/chat/room-1')\n  await bobPage.goto('/chat/room-1')\n\n  // Alice sends message\n  await alicePage.getByLabel('Message').fill('Hi Bob!')\n  await alicePage.getByRole('button', {name: 'Send'}).click()\n\n  // Bob sees it\n  await expect(bobPage.getByText('Alice: Hi Bob!')).toBeVisible()\n\n  // Bob replies\n  await bobPage.getByLabel('Message').fill('Hey Alice!')\n  await bobPage.getByRole('button', {name: 'Send'}).click()\n\n  // Alice sees it\n  await expect(alicePage.getByText('Bob: Hey Alice!')).toBeVisible()\n\n  await aliceCtx.close()\n  await bobCtx.close()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                       | Solution                     |\n| ----------------------------- | ----------------------------- | ---------------------------- |\n| Sharing context between users | State leaks, not isolated     | Create separate contexts     |\n| Not closing contexts          | Memory leak, browser overload | Always close in cleanup      |\n| Hardcoded timing for sync     | Flaky tests                   | Use `expect().toBeVisible()` |\n| Testing only single user      | Misses collaboration bugs     | Test multi-user scenarios    |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n","isInternal":false,"tokens":2816,"sizeBytes":11226},{"name":"network-advanced.md","path":".agents/skills/playwright-best-practices/advanced/network-advanced.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/network-advanced.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Advanced Network Interception\n\n## Table of Contents\n\n1. [Request Modification](#request-modification)\n2. [GraphQL Mocking](#graphql-mocking)\n3. [HAR Recording & Playback](#har-recording--playback)\n4. [Conditional Mocking](#conditional-mocking)\n5. [Network Throttling](#network-throttling)\n\n## Request Modification\n\n### Modify Request Headers\n\n```typescript\ntest('add auth header to requests', async ({page}) => {\n  await page.route('**/api/**', (route) => {\n    const headers = {\n      ...route.request().headers(),\n      'Authorization': 'Bearer test-token',\n      'X-Test-Header': 'test-value',\n    }\n    route.continue({headers})\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### Modify Request Body\n\n```typescript\ntest('modify POST body', async ({page}) => {\n  await page.route('**/api/orders', async (route) => {\n    if (route.request().method() === 'POST') {\n      const postData = route.request().postDataJSON()\n\n      // Add test metadata\n      const modifiedData = {\n        ...postData,\n        testMode: true,\n        testTimestamp: Date.now(),\n      }\n\n      await route.continue({\n        postData: JSON.stringify(modifiedData),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n})\n```\n\n### Transform Response\n\n```typescript\ntest('modify API response', async ({page}) => {\n  await page.route('**/api/products', async (route) => {\n    // Fetch real response\n    const response = await route.fetch()\n    const json = await response.json()\n\n    // Modify response\n    const modified = json.map((product: any) => ({\n      ...product,\n      price: product.price * 0.9, // 10% discount\n      testMode: true,\n    }))\n\n    await route.fulfill({\n      response,\n      json: modified,\n    })\n  })\n\n  await page.goto('/products')\n})\n```\n\n## GraphQL Mocking\n\n### Mock by Operation Name\n\n```typescript\ntest('mock GraphQL query', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'GetUser') {\n      return route.fulfill({\n        json: {\n          data: {\n            user: {\n              id: '1',\n              name: 'Test User',\n              email: 'test@example.com',\n            },\n          },\n        },\n      })\n    }\n\n    if (postData.operationName === 'GetProducts') {\n      return route.fulfill({\n        json: {\n          data: {\n            products: [\n              {id: '1', name: 'Product A', price: 29.99},\n              {id: '2', name: 'Product B', price: 49.99},\n            ],\n          },\n        },\n      })\n    }\n\n    // Pass through unmocked operations\n    return route.continue()\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### GraphQL Mock Fixture\n\n```typescript\n// fixtures/graphql.fixture.ts\ntype GraphQLMock = {\n  operation: string\n  variables?: Record<string, any>\n  response: {data?: any; errors?: any[]}\n}\n\ntype GraphQLFixtures = {\n  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>\n}\n\nexport const test = base.extend<GraphQLFixtures>({\n  mockGraphQL: async ({page}, use) => {\n    await use(async (mocks) => {\n      await page.route('**/graphql', async (route) => {\n        const postData = route.request().postDataJSON()\n\n        const mock = mocks.find((m) => {\n          if (m.operation !== postData.operationName) return false\n\n          // Optionally match variables\n          if (m.variables) {\n            return JSON.stringify(m.variables) === JSON.stringify(postData.variables)\n          }\n          return true\n        })\n\n        if (mock) {\n          return route.fulfill({json: mock.response})\n        }\n\n        return route.continue()\n      })\n    })\n  },\n})\n\n// Usage\ntest('dashboard with mocked GraphQL', async ({page, mockGraphQL}) => {\n  await mockGraphQL([\n    {\n      operation: 'GetDashboardStats',\n      response: {\n        data: {stats: {users: 100, revenue: 50000}},\n      },\n    },\n    {\n      operation: 'GetUser',\n      variables: {id: '1'},\n      response: {\n        data: {user: {id: '1', name: 'John'}},\n      },\n    },\n  ])\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('100 users')).toBeVisible()\n})\n```\n\n### Mock GraphQL Mutations\n\n```typescript\ntest('mock GraphQL mutation', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'CreateOrder') {\n      const {input} = postData.variables\n\n      return route.fulfill({\n        json: {\n          data: {\n            createOrder: {\n              id: 'order-123',\n              status: 'PENDING',\n              items: input.items,\n              total: input.items.reduce(\n                (sum: number, item: any) => sum + item.price * item.quantity,\n                0,\n              ),\n            },\n          },\n        },\n      })\n    }\n\n    return route.continue()\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  await expect(page.getByText('Order #order-123')).toBeVisible()\n})\n```\n\n## HAR Recording & Playback\n\n### Record HAR File\n\n```typescript\n// Record network traffic\ntest('record HAR', async ({page, context}) => {\n  // Start recording\n  await context.routeFromHAR('./recordings/checkout.har', {\n    update: true, // Create/update HAR file\n    url: '**/api/**',\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  // HAR file is saved automatically\n})\n```\n\n### Playback HAR File\n\n```typescript\n// Use recorded HAR for offline testing\ntest('playback HAR', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/checkout.har', {\n    url: '**/api/**',\n    update: false, // Don't update, just playback\n  })\n\n  await page.goto('/checkout')\n\n  // All API calls served from HAR file\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### HAR with Fallback\n\n```typescript\ntest('HAR with live fallback', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/api.har', {\n    url: '**/api/**',\n    update: false,\n    notFound: 'fallback', // Use real network if not in HAR\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n## Conditional Mocking\n\n### Mock Based on Request Body\n\n```typescript\ntest('conditional mock by body', async ({page}) => {\n  await page.route('**/api/search', async (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.query === 'error') {\n      return route.fulfill({\n        status: 500,\n        json: {error: 'Search failed'},\n      })\n    }\n\n    if (body.query === 'empty') {\n      return route.fulfill({\n        json: {results: []},\n      })\n    }\n\n    // Default response\n    return route.fulfill({\n      json: {\n        results: [{id: 1, title: `Result for: ${body.query}`}],\n      },\n    })\n  })\n\n  await page.goto('/search')\n\n  // Test different scenarios\n  await page.getByLabel('Search').fill('error')\n  await page.getByLabel('Search').press('Enter')\n  await expect(page.getByText('Search failed')).toBeVisible()\n})\n```\n\n### Mock Nth Request\n\n```typescript\ntest('different response on retry', async ({page}) => {\n  let callCount = 0\n\n  await page.route('**/api/status', (route) => {\n    callCount++\n\n    if (callCount < 3) {\n      return route.fulfill({\n        status: 503,\n        json: {error: 'Service unavailable'},\n      })\n    }\n\n    // Succeed on 3rd attempt\n    return route.fulfill({\n      json: {status: 'ok'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // App should retry and eventually succeed\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n### Mock with Delay\n\n```typescript\ntest('slow network simulation', async ({page}) => {\n  await page.route('**/api/data', async (route) => {\n    // Simulate 2 second delay\n    await new Promise((resolve) => setTimeout(resolve, 2000))\n\n    return route.fulfill({\n      json: {data: 'loaded'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Loading state should appear\n  await expect(page.getByText('Loading...')).toBeVisible()\n\n  // Then data appears\n  await expect(page.getByText('loaded')).toBeVisible()\n})\n```\n\n## Network Throttling\n\n### Slow 3G Simulation\n\n```typescript\ntest('slow network experience', async ({page, context}) => {\n  // Create CDP session for network throttling\n  const client = await context.newCDPSession(page)\n\n  await client.send('Network.emulateNetworkConditions', {\n    offline: false,\n    downloadThroughput: (500 * 1024) / 8, // 500 Kbps\n    uploadThroughput: (500 * 1024) / 8,\n    latency: 400, // 400ms\n  })\n\n  await page.goto('/')\n\n  // Test loading states appear\n  await expect(page.getByTestId('skeleton-loader')).toBeVisible()\n})\n```\n\n### Offline Mode\n\nUse `context.setOffline(true/false)` to simulate network connectivity changes.\n\n> **For comprehensive offline testing patterns:**\n>\n> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)\n> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)\n\n### Network Throttling Fixture\n\n```typescript\n// fixtures/network.fixture.ts\ntype NetworkCondition = 'slow3g' | 'fast3g' | 'offline'\n\nconst conditions = {\n  slow3g: {downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000},\n  fast3g: {downloadThroughput: 180000, uploadThroughput: 75000, latency: 150},\n}\n\ntype NetworkFixtures = {\n  setNetworkCondition: (condition: NetworkCondition) => Promise<void>\n}\n\nexport const test = base.extend<NetworkFixtures>({\n  setNetworkCondition: async ({page, context}, use) => {\n    const client = await context.newCDPSession(page)\n\n    await use(async (condition) => {\n      if (condition === 'offline') {\n        await context.setOffline(true)\n      } else {\n        await client.send('Network.emulateNetworkConditions', {\n          offline: false,\n          ...conditions[condition],\n        })\n      }\n    })\n\n    // Reset\n    await context.setOffline(false)\n  },\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                         |\n| ------------------------ | ------------------------------ | -------------------------------- |\n| Mocking all requests     | Tests don't reflect reality    | Mock only what's necessary       |\n| No cleanup of routes     | Routes persist across tests    | Use fixtures with cleanup        |\n| Ignoring request method  | Mock applies to wrong requests | Check `route.request().method()` |\n| Hardcoded mock responses | Brittle, hard to maintain      | Use factories for mock data      |\n\n## Related References\n\n- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n","isInternal":false,"tokens":2623,"sizeBytes":10897},{"name":"third-party.md","path":".agents/skills/playwright-best-practices/advanced/third-party.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/advanced/third-party.md","title":"Advanced Skill","category":"anthropic-skill","format":"markdown","content":"# Third-Party Service Mocking\n\n## Table of Contents\n\n1. [OAuth/SSO Mocking](#oauthsso-mocking)\n2. [Payment Gateway Mocking](#payment-gateway-mocking)\n3. [Email Verification](#email-verification)\n4. [SMS Verification](#sms-verification)\n5. [Analytics & Tracking](#analytics--tracking)\n\n## OAuth/SSO Mocking\n\n### Mock Google OAuth\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  // Mock the OAuth callback\n  await page.route('**/auth/google/callback**', (route) => {\n    const url = new URL(route.request().url())\n    // Simulate successful OAuth by redirecting with token\n    route.fulfill({\n      status: 302,\n      headers: {\n        Location: '/dashboard?token=mock-jwt-token',\n      },\n    })\n  })\n\n  // Mock the token verification endpoint\n  await page.route('**/api/auth/verify', (route) =>\n    route.fulfill({\n      json: {\n        valid: true,\n        user: {\n          id: '123',\n          email: 'test@gmail.com',\n          name: 'Test User',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n```typescript\n// fixtures/oauth.fixture.ts\ntype OAuthProvider = 'google' | 'github' | 'microsoft'\n\ntype OAuthUser = {\n  id: string\n  email: string\n  name: string\n  avatar?: string\n}\n\ntype OAuthFixtures = {\n  mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>\n}\n\nexport const test = base.extend<OAuthFixtures>({\n  mockOAuth: async ({page}, use) => {\n    await use(async (provider, user) => {\n      // Mock callback redirect\n      await page.route(`**/auth/${provider}/callback**`, (route) =>\n        route.fulfill({\n          status: 302,\n          headers: {Location: `/auth/success?provider=${provider}`},\n        }),\n      )\n\n      // Mock session/user endpoint\n      await page.route('**/api/auth/session', (route) =>\n        route.fulfill({\n          json: {user, provider, authenticated: true},\n        }),\n      )\n\n      // Mock user info endpoint\n      await page.route('**/api/me', (route) => route.fulfill({json: user}))\n    })\n  },\n})\n\n// Usage\ntest('login with GitHub', async ({page, mockOAuth}) => {\n  await mockOAuth('github', {\n    id: 'gh-123',\n    email: 'dev@github.com',\n    name: 'GitHub User',\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with GitHub'}).click()\n\n  await expect(page.getByText('Welcome, GitHub User')).toBeVisible()\n})\n```\n\n### Mock SAML SSO\n\n```typescript\ntest('SAML SSO login', async ({page}) => {\n  // Mock SAML assertion consumer service\n  await page.route('**/saml/acs', async (route) => {\n    route.fulfill({\n      status: 302,\n      headers: {\n        'Location': '/dashboard',\n        'Set-Cookie': 'session=mock-saml-session; Path=/; HttpOnly',\n      },\n    })\n  })\n\n  // Mock session validation\n  await page.route('**/api/session', (route) =>\n    route.fulfill({\n      json: {\n        user: {email: 'user@company.com', name: 'SSO User'},\n        provider: 'saml',\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'SSO Login'}).click()\n\n  await expect(page).toHaveURL('/dashboard')\n})\n```\n\n## Payment Gateway Mocking\n\n### Mock Stripe\n\n```typescript\ntest('Stripe checkout', async ({page}) => {\n  // Mock Stripe.js\n  await page.addInitScript(() => {\n    ;(window as any).Stripe = () => ({\n      elements: () => ({\n        create: () => ({\n          mount: () => {},\n          on: () => {},\n          destroy: () => {},\n        }),\n      }),\n      confirmCardPayment: async () => ({\n        paymentIntent: {status: 'succeeded', id: 'pi_mock_123'},\n      }),\n      createPaymentMethod: async () => ({\n        paymentMethod: {id: 'pm_mock_123'},\n      }),\n    })\n  })\n\n  // Mock backend payment endpoint\n  await page.route('**/api/create-payment-intent', (route) =>\n    route.fulfill({\n      json: {clientSecret: 'pi_mock_123_secret_mock'},\n    }),\n  )\n\n  await page.route('**/api/confirm-payment', (route) =>\n    route.fulfill({\n      json: {success: true, orderId: 'order-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay $99.99'}).click()\n\n  await expect(page.getByText('Payment successful')).toBeVisible()\n})\n```\n\n### Mock PayPal\n\n```typescript\ntest('PayPal checkout', async ({page}) => {\n  // Mock PayPal SDK\n  await page.addInitScript(() => {\n    ;(window as any).paypal = {\n      Buttons: () => ({\n        render: () => Promise.resolve(),\n        isEligible: () => true,\n      }),\n      FUNDING: {PAYPAL: 'paypal', CARD: 'card'},\n    }\n  })\n\n  // Mock PayPal order creation\n  await page.route('**/api/paypal/create-order', (route) =>\n    route.fulfill({\n      json: {orderId: 'PAYPAL-ORDER-123'},\n    }),\n  )\n\n  // Mock PayPal capture\n  await page.route('**/api/paypal/capture', (route) =>\n    route.fulfill({\n      json: {success: true, transactionId: 'TXN-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n\n  // Simulate PayPal approval callback\n  await page.evaluate(() => {\n    ;(window as any).onPayPalApprove?.({orderID: 'PAYPAL-ORDER-123'})\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Payment Fixture\n\n```typescript\n// fixtures/payment.fixture.ts\ntype PaymentFixtures = {\n  mockStripe: (options?: {failPayment?: boolean}) => Promise<void>\n}\n\nexport const test = base.extend<PaymentFixtures>({\n  mockStripe: async ({page}, use) => {\n    await use(async (options = {}) => {\n      await page.addInitScript(\n        ([shouldFail]) => {\n          ;(window as any).Stripe = () => ({\n            elements: () => ({\n              create: () => ({\n                mount: () => {},\n                on: (event: string, handler: Function) => {\n                  if (event === 'ready') setTimeout(handler, 100)\n                },\n                destroy: () => {},\n              }),\n            }),\n            confirmCardPayment: async () => {\n              if (shouldFail) {\n                return {error: {message: 'Card declined'}}\n              }\n              return {paymentIntent: {status: 'succeeded'}}\n            },\n          })\n        },\n        [options.failPayment],\n      )\n    })\n  },\n})\n\n// Usage\ntest('handles declined card', async ({page, mockStripe}) => {\n  await mockStripe({failPayment: true})\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay'}).click()\n\n  await expect(page.getByText('Card declined')).toBeVisible()\n})\n```\n\n## Email Verification\n\n### Mock Email API\n\n```typescript\ntest('email verification flow', async ({page, request}) => {\n  let verificationToken: string\n\n  // Capture the verification email\n  await page.route('**/api/send-verification', async (route) => {\n    const body = route.request().postDataJSON()\n    verificationToken = `mock-token-${Date.now()}`\n\n    // Don't actually send email, just store token\n    route.fulfill({\n      json: {sent: true, messageId: 'msg-123'},\n    })\n  })\n\n  // Mock token verification\n  await page.route('**/api/verify-email**', (route) => {\n    const url = new URL(route.request().url())\n    const token = url.searchParams.get('token')\n\n    if (token === verificationToken) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid token'}})\n    }\n  })\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  await expect(page.getByText('Check your email')).toBeVisible()\n\n  // Simulate clicking email link\n  await page.goto(`/verify?token=${verificationToken}`)\n\n  await expect(page.getByText('Email verified')).toBeVisible()\n})\n```\n\n### Use Mailinator/Temp Mail\n\n```typescript\n// fixtures/email.fixture.ts\ntype EmailFixtures = {\n  getVerificationEmail: (inbox: string) => Promise<{link: string}>\n}\n\nexport const test = base.extend<EmailFixtures>({\n  getVerificationEmail: async ({request}, use) => {\n    await use(async (inbox) => {\n      // Poll Mailinator API for new email\n      const response = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const messages = await response.json()\n      const latest = messages.msgs[0]\n\n      // Get full message\n      const msgResponse = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const message = await msgResponse.json()\n\n      // Extract verification link from HTML\n      const linkMatch = message.parts[0].body.match(/href=\"([^\"]*verify[^\"]*)\"/)\n      return {link: linkMatch?.[1] || ''}\n    })\n  },\n})\n```\n\n## SMS Verification\n\n### Mock SMS API\n\n```typescript\ntest('SMS verification', async ({page}) => {\n  let smsCode: string\n\n  // Capture SMS send\n  await page.route('**/api/send-sms', (route) => {\n    smsCode = Math.random().toString().slice(2, 8) // 6-digit code\n\n    route.fulfill({\n      json: {sent: true, messageId: 'sms-123'},\n    })\n  })\n\n  // Mock code verification\n  await page.route('**/api/verify-sms', (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.code === smsCode) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid code'}})\n    }\n  })\n\n  await page.goto('/verify-phone')\n  await page.getByLabel('Phone').fill('+1234567890')\n  await page.getByRole('button', {name: 'Send Code'}).click()\n\n  // Enter the code\n  await page.getByLabel('Verification Code').fill(smsCode)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await expect(page.getByText('Phone verified')).toBeVisible()\n})\n```\n\n## Analytics & Tracking\n\n### Block Analytics in Tests\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Block all analytics/tracking\n  await page.route(\n    /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,\n    (route) => route.abort(),\n  )\n})\n```\n\n### Mock Analytics for Verification\n\n```typescript\ntest('tracks purchase event', async ({page}) => {\n  const analyticsEvents: any[] = []\n\n  // Capture analytics calls\n  await page.route('**/api/analytics/**', (route) => {\n    analyticsEvents.push(route.request().postDataJSON())\n    route.fulfill({status: 200})\n  })\n\n  // Mock analytics SDK\n  await page.addInitScript(() => {\n    ;(window as any).analytics = {\n      track: (event: string, props: any) => {\n        fetch('/api/analytics/track', {\n          method: 'POST',\n          body: JSON.stringify({event, props}),\n        })\n      },\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n\n  // Verify analytics event was sent\n  expect(analyticsEvents).toContainEqual(\n    expect.objectContaining({\n      event: 'Purchase Completed',\n      props: expect.objectContaining({amount: expect.any(Number)}),\n    }),\n  )\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern              | Problem                        | Solution                |\n| ------------------------- | ------------------------------ | ----------------------- |\n| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints    |\n| Real payment processing   | Charges real money, slow       | Use test mode or mock   |\n| Waiting for real emails   | Very slow, unreliable          | Mock email API          |\n| Not mocking analytics     | Pollutes analytics data        | Block or mock analytics |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n","isInternal":false,"tokens":2955,"sizeBytes":11923},{"name":"pom-vs-fixtures.md","path":".agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md","title":"Architecture Skill","category":"anthropic-skill","format":"markdown","content":"# Organizing Reusable Test Code\n\n## Table of Contents\n\n1. [Pattern Comparison](#pattern-comparison)\n2. [Selection Flowchart](#selection-flowchart)\n3. [Page Objects](#page-objects)\n4. [Custom Fixtures](#custom-fixtures)\n5. [Helper Functions](#helper-functions)\n6. [Combined Project Structure](#combined-project-structure)\n7. [Anti-Patterns](#anti-patterns)\n\nUse all three patterns together. Most projects benefit from a hybrid approach:\n\n- **Page objects** for UI interaction (pages/components with 5+ interactions)\n- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)\n- **Helper functions** for stateless utilities (generate data, format values, simple waits)\n\nIf only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.\n\n## Pattern Comparison\n\n| Aspect            | Page Objects                            | Custom Fixtures                            | Helper Functions                  |\n| ----------------- | --------------------------------------- | ------------------------------------------ | --------------------------------- |\n| **Purpose**       | Encapsulate UI interactions             | Provide resources with setup/teardown      | Stateless utilities               |\n| **Lifecycle**     | Manual (constructor/methods)            | Built-in (`use()` with automatic teardown) | None                              |\n| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures                   | Call other functions              |\n| **Best for**      | Pages with many reused interactions     | Resources needing setup AND teardown       | Simple logic with no side effects |\n\n## Selection Flowchart\n\n```text\nWhat kind of reusable code?\n|\n+-- Interacts with browser page/component?\n|   |\n|   +-- Has 5+ interactions (fill, click, navigate, assert)?\n|   |   +-- YES: Used in 3+ test files?\n|   |   |   +-- YES --> PAGE OBJECT\n|   |   |   +-- NO --> Inline or small helper\n|   |   +-- NO --> HELPER FUNCTION\n|   |\n|   +-- Needs setup before AND cleanup after test?\n|       +-- YES --> CUSTOM FIXTURE\n|       +-- NO --> PAGE OBJECT method or HELPER\n|\n+-- Manages resource with lifecycle (create/destroy)?\n|   +-- Examples: auth state, DB connection, API client, test user\n|   +-- YES --> CUSTOM FIXTURE (always)\n|\n+-- Stateless utility? (no browser, no side effects)\n|   +-- Examples: random email, format date, build URL, parse response\n|   +-- YES --> HELPER FUNCTION\n|\n+-- Not sure?\n    +-- Start with HELPER FUNCTION\n    +-- Promote to PAGE OBJECT when interactions grow\n    +-- Promote to FIXTURE when lifecycle needed\n```\n\n## Page Objects\n\nBest for pages/components with 5+ interactions appearing in 3+ test files.\n\n```typescript\n// page-objects/booking.page.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class BookingPage {\n  readonly page: Page\n  readonly dateField: Locator\n  readonly guestCount: Locator\n  readonly roomType: Locator\n  readonly reserveBtn: Locator\n  readonly totalPrice: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.dateField = page.getByLabel('Check-in date')\n    this.guestCount = page.getByLabel('Guests')\n    this.roomType = page.getByLabel('Room type')\n    this.reserveBtn = page.getByRole('button', {name: 'Reserve'})\n    this.totalPrice = page.getByTestId('total-price')\n  }\n\n  async goto() {\n    await this.page.goto('/booking')\n  }\n\n  async fillDetails(opts: {date: string; guests: number; room: string}) {\n    await this.dateField.fill(opts.date)\n    await this.guestCount.fill(String(opts.guests))\n    await this.roomType.selectOption(opts.room)\n  }\n\n  async reserve() {\n    await this.reserveBtn.click()\n    await this.page.waitForURL('**/confirmation')\n  }\n\n  async expectPrice(amount: string) {\n    await expect(this.totalPrice).toHaveText(amount)\n  }\n}\n```\n\n```typescript\n// tests/booking/reservation.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {BookingPage} from '../page-objects/booking.page'\n\ntest('complete reservation with standard room', async ({page}) => {\n  const booking = new BookingPage(page)\n  await booking.goto()\n  await booking.fillDetails({date: '2026-03-15', guests: 2, room: 'standard'})\n  await booking.reserve()\n  await expect(page.getByText('Reservation confirmed')).toBeVisible()\n})\n```\n\n**Page object principles:**\n\n- One class per logical page/component, not per URL\n- Constructor takes `Page`\n- Locators as `readonly` properties in constructor\n- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks\n- Navigation methods (`goto`) belong on the page object\n\n## Custom Fixtures\n\nBest for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.\n\n```typescript\n// fixtures/base.fixture.ts\nimport {test as base, expect} from '@playwright/test'\n\nimport {generateMember} from '../helpers/data'\nimport {BookingPage} from '../page-objects/booking.page'\n\ntype Fixtures = {\n  bookingPage: BookingPage\n  member: {email: string; password: string; id: string}\n  loggedInPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<Fixtures>({\n  bookingPage: async ({page}, use) => {\n    await use(new BookingPage(page))\n  },\n\n  member: async ({request}, use) => {\n    const data = generateMember()\n    const res = await request.post('/api/test/members', {data})\n    const member = await res.json()\n    await use(member)\n    await request.delete(`/api/test/members/${member.id}`)\n  },\n\n  loggedInPage: async ({page, member}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(member.email)\n    await page.getByLabel('Password').fill(member.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await expect(page).toHaveURL('/dashboard')\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/dashboard/overview.spec.ts\nimport {test, expect} from '../../fixtures/base.fixture'\n\ntest('member sees dashboard widgets', async ({loggedInPage}) => {\n  await expect(loggedInPage.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible()\n})\n\ntest('new member sees welcome prompt', async ({loggedInPage, member}) => {\n  await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible()\n})\n```\n\n**Fixture principles:**\n\n- Use `test.extend()` — never module-level variables\n- `use()` callback separates setup from teardown\n- Teardown runs even if test fails\n- Fixtures compose: one can depend on another\n- Fixtures are lazy: created only when requested\n- Wrap page objects in fixtures for lifecycle management\n\n## Helper Functions\n\nBest for stateless utilities — generating test data, formatting values, building URLs, parsing responses.\n\n```typescript\n// helpers/data.ts\nimport {randomUUID} from 'node:crypto'\n\nexport function generateEmail(prefix = 'user'): string {\n  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`\n}\n\nexport function generateMember(overrides: Partial<Member> = {}): Member {\n  return {\n    email: generateEmail(),\n    password: 'SecurePass456!',\n    name: 'Test Member',\n    ...overrides,\n  }\n}\n\ninterface Member {\n  email: string\n  password: string\n  name: string\n}\n\nexport function formatPrice(cents: number): string {\n  return `$${(cents / 100).toFixed(2)}`\n}\n```\n\n```typescript\n// helpers/assertions.ts\nimport {type Page, expect} from '@playwright/test'\n\nexport async function expectNotification(page: Page, message: string): Promise<void> {\n  const notification = page.getByRole('alert').filter({hasText: message})\n  await expect(notification).toBeVisible()\n  await expect(notification).toBeHidden({timeout: 10000})\n}\n```\n\n```typescript\n// tests/settings/account.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {expectNotification} from '../../helpers/assertions'\nimport {generateEmail} from '../../helpers/data'\n\ntest('update account email', async ({page}) => {\n  const newEmail = generateEmail('updated')\n  await page.goto('/settings/account')\n  await page.getByLabel('Email').fill(newEmail)\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expectNotification(page, 'Account updated')\n  await expect(page.getByLabel('Email')).toHaveValue(newEmail)\n})\n```\n\n**Helper principles:**\n\n- Pure functions with no side effects\n- No browser state — take `page` as parameter if needed\n- Promote to fixture if setup/teardown needed\n- Promote to page object if many page interactions grow\n- Keep small and focused\n\n## Combined Project Structure\n\n```text\ntests/\n+-- fixtures/\n|   +-- auth.fixture.ts\n|   +-- db.fixture.ts\n|   +-- base.fixture.ts\n+-- page-objects/\n|   +-- login.page.ts\n|   +-- booking.page.ts\n|   +-- components/\n|       +-- data-table.component.ts\n+-- helpers/\n|   +-- data.ts\n|   +-- assertions.ts\n+-- e2e/\n|   +-- auth/\n|   |   +-- login.spec.ts\n|   +-- booking/\n|       +-- reservation.spec.ts\nplaywright.config.ts\n```\n\n**Layer responsibilities:**\n\n| Layer            | Pattern         | Responsibility                                      |\n| ---------------- | --------------- | --------------------------------------------------- |\n| **Test file**    | `test()`        | Describes behavior, orchestrates layers             |\n| **Fixtures**     | `test.extend()` | Resource lifecycle — setup, provide, teardown       |\n| **Page objects** | Classes         | UI interaction — navigation, actions, locators      |\n| **Helpers**      | Functions       | Utilities — data generation, formatting, assertions |\n\n## Anti-Patterns\n\n### Page object managing resources\n\n```typescript\n// BAD: page object handling API calls and database\nclass LoginPage {\n  async createUser() {\n    /* API call */\n  }\n  async deleteUser() {\n    /* API call */\n  }\n  async signIn(email: string, password: string) {\n    /* UI */\n  }\n}\n```\n\nResource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.\n\n### Locator-only page objects\n\n```typescript\n// BAD: no methods, just locators\nclass LoginPage {\n  emailInput = this.page.getByLabel('Email')\n  passwordInput = this.page.getByLabel('Password')\n  submitBtn = this.page.getByRole('button', {name: 'Sign in'})\n  constructor(private page: Page) {}\n}\n```\n\nAdd intent-revealing methods or skip the page object entirely.\n\n### Monolithic fixtures\n\n```typescript\n// BAD: one fixture doing everything\ntest.extend({\n  everything: async ({page, request}, use) => {\n    const user = await createUser(request)\n    const products = await seedProducts(request, 50)\n    await setupPayment(request, user.id)\n    await page.goto('/dashboard')\n    await use({user, products, page})\n    // massive teardown...\n  },\n})\n```\n\nBreak into small, composable fixtures. Each fixture does one thing.\n\n### Helpers with side effects\n\n```typescript\n// BAD: module-level state\nlet createdUserId: string\n\nexport async function createTestUser(request: APIRequestContext) {\n  const res = await request.post('/api/users', {data: {email: 'test@example.com'}})\n  const user = await res.json()\n  createdUserId = user.id // shared across tests!\n  return user\n}\n```\n\nModule-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.\n\n### Over-abstracting simple operations\n\n```typescript\n// BAD: helper for one-liner\nexport async function clickButton(page: Page, name: string) {\n  await page.getByRole('button', {name}).click()\n}\n```\n\nOnly abstract when there is real duplication (3+ usages) or complexity (5+ interactions).\n","isInternal":false,"tokens":2696,"sizeBytes":11649},{"name":"test-architecture.md","path":".agents/skills/playwright-best-practices/architecture/test-architecture.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/architecture/test-architecture.md","title":"Architecture Skill","category":"anthropic-skill","format":"markdown","content":"# Choosing Test Types: E2E, Component, or API\n\n## Table of Contents\n\n1. [Decision Matrix](#decision-matrix)\n2. [API Tests](#api-tests)\n3. [Component Tests](#component-tests)\n4. [E2E Tests](#e2e-tests)\n5. [Layering Test Types](#layering-test-types)\n6. [Common Mistakes](#common-mistakes)\n7. [Related](#related)\n\n> **When to use**: Deciding which test type to write for a feature. Ask: \"What's the cheapest test that gives confidence this works?\"\n\n## Decision Matrix\n\n| Scenario                    | Recommended Type | Rationale                                     |\n| --------------------------- | ---------------- | --------------------------------------------- |\n| Login / auth flow           | E2E              | Cross-page, cookies, redirects, session state |\n| Form submission             | Component        | Isolated validation logic, error states       |\n| CRUD operations             | API              | Data integrity matters more than UI           |\n| Search with results UI      | Component + API  | API for query logic; component for rendering  |\n| Cross-page navigation       | E2E              | Routing, history, deep linking                |\n| API error handling          | API              | Status codes, error shapes, edge cases        |\n| UI error feedback           | Component        | Toast, banner, inline error rendering         |\n| Accessibility               | Component        | ARIA roles, keyboard nav per-component        |\n| Responsive layout           | Component        | Viewport-specific rendering without full app  |\n| API contract validation     | API              | Response shapes, headers, auth                |\n| WebSocket/real-time         | E2E              | Requires full browser environment             |\n| Payment / checkout          | E2E              | Multi-step, third-party iframes               |\n| Onboarding wizard           | E2E              | Multi-step, state persists across pages       |\n| Widget behavior             | Component        | Toggle, accordion, date picker, modal         |\n| Permissions / authorization | API              | Role-based access is backend logic            |\n\n## API Tests\n\n**Ideal for**:\n\n- CRUD operations (create, read, update, delete)\n- Input validation and error responses (400, 422)\n- Permission and authorization checks\n- Data integrity and business rules\n- API contract verification\n- Edge cases expensive to reproduce through UI\n- Test data setup/teardown for E2E tests\n\n**Avoid for**:\n\n- Testing how errors display to users\n- Browser-specific behavior (cookies, redirects)\n- Visual layout or responsive design\n- Flows requiring JavaScript execution or DOM interaction\n- Third-party iframe interactions\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Products API', () => {\n  let token: string\n\n  test.beforeAll(async ({request}) => {\n    const res = await request.post('/api/auth/token', {\n      data: {email: 'manager@shop.io', password: 'mgr-secret'},\n    })\n    token = (await res.json()).accessToken\n  })\n\n  test('creates product with valid payload', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Widget Pro', sku: 'WGT-100', price: 29.99},\n    })\n\n    expect(res.status()).toBe(201)\n    const product = await res.json()\n    expect(product).toMatchObject({name: 'Widget Pro', sku: 'WGT-100'})\n    expect(product).toHaveProperty('id')\n  })\n\n  test('rejects duplicate SKU with 409', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Duplicate', sku: 'WGT-100', price: 19.99},\n    })\n\n    expect(res.status()).toBe(409)\n    expect((await res.json()).message).toContain('already exists')\n  })\n\n  test('returns 422 for missing required fields', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Incomplete'},\n    })\n\n    expect(res.status()).toBe(422)\n    const err = await res.json()\n    expect(err.errors).toContainEqual(expect.objectContaining({field: 'sku'}))\n  })\n\n  test('staff role cannot delete products', async ({request}) => {\n    const staffLogin = await request.post('/api/auth/token', {\n      data: {email: 'staff@shop.io', password: 'staff-pass'},\n    })\n    const staffToken = (await staffLogin.json()).accessToken\n\n    const res = await request.delete('/api/products/123', {\n      headers: {Authorization: `Bearer ${staffToken}`},\n    })\n\n    expect(res.status()).toBe(403)\n  })\n\n  test('lists products with pagination', async ({request}) => {\n    const res = await request.get('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      params: {page: '1', limit: '20'},\n    })\n\n    expect(res.status()).toBe(200)\n    const body = await res.json()\n    expect(body.items).toBeInstanceOf(Array)\n    expect(body.items.length).toBeLessThanOrEqual(20)\n    expect(body).toHaveProperty('totalCount')\n  })\n})\n```\n\n## Component Tests\n\n**Ideal for**:\n\n- Form validation (required fields, format rules, error messages)\n- Interactive widgets (modals, dropdowns, accordions, date pickers)\n- Conditional rendering (show/hide, loading states, empty states)\n- Accessibility per-component (ARIA attributes, keyboard navigation)\n- Responsive layout at different viewports\n- Visual states (hover, focus, disabled, selected)\n\n**Avoid for**:\n\n- Testing routing or navigation between pages\n- Flows requiring real cookies, sessions, or server-side state\n- Data persistence or API contract validation\n- Third-party iframe interactions\n- Anything requiring multiple pages or browser contexts\n\n```typescript\nimport { test, expect } from \"@playwright/experimental-ct-react\";\nimport { ContactForm } from \"../src/components/ContactForm\";\n\ntest.describe(\"ContactForm component\", () => {\n  test(\"displays validation errors on empty submit\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Name is required\")).toBeVisible();\n    await expect(component.getByText(\"Email is required\")).toBeVisible();\n  });\n\n  test(\"rejects malformed email\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"invalid-email\");\n    await component.getByLabel(\"Message\").fill(\"Hello\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Enter a valid email\")).toBeVisible();\n  });\n\n  test(\"invokes onSubmit with form data\", async ({ mount }) => {\n    const submissions: Array<{ name: string; email: string; message: string }> =\n      [];\n    const component = await mount(\n      <ContactForm onSubmit={(data) => submissions.push(data)} />\n    );\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"alex@company.org\");\n    await component.getByLabel(\"Message\").fill(\"Inquiry about pricing\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    expect(submissions).toHaveLength(1);\n    expect(submissions[0]).toEqual({\n      name: \"Alex\",\n      email: \"alex@company.org\",\n      message: \"Inquiry about pricing\",\n    });\n  });\n\n  test(\"disables button during submission\", async ({ mount }) => {\n    const component = await mount(\n      <ContactForm onSubmit={() => {}} submitting={true} />\n    );\n\n    await expect(\n      component.getByRole(\"button\", { name: \"Sending...\" })\n    ).toBeDisabled();\n  });\n\n  test(\"associates labels with inputs for accessibility\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Name\" })\n    ).toBeVisible();\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Email\" })\n    ).toBeVisible();\n  });\n});\n```\n\n## E2E Tests\n\n**Ideal for**:\n\n- Critical user flows that generate revenue (checkout, signup)\n- Authentication flows (login, SSO, MFA, password reset)\n- Multi-page workflows where state carries across navigation\n- Flows involving third-party iframes (payment widgets)\n- Smoke tests validating the entire stack\n- Real-time collaboration requiring multiple browser contexts\n\n**Avoid for**:\n\n- Testing every form validation permutation\n- CRUD operations where UI is a thin wrapper\n- Verifying individual component states\n- Testing API response shapes or error codes\n- Responsive layout at every breakpoint\n- Edge cases that only affect the backend\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('subscription flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.request.post('/api/test/seed-account', {\n      data: {plan: 'free', email: 'subscriber@demo.io'},\n    })\n    await page.goto('/account/upgrade')\n  })\n\n  test('upgrades to premium plan', async ({page}) => {\n    await test.step('select plan', async () => {\n      await expect(page.getByRole('heading', {name: 'Choose Your Plan'})).toBeVisible()\n      await page.getByRole('button', {name: 'Select Premium'}).click()\n    })\n\n    await test.step('enter billing details', async () => {\n      await page.getByLabel('Cardholder name').fill('Sam Johnson')\n      await page.getByLabel('Billing address').fill('456 Oak Ave')\n      await page.getByLabel('City').fill('Seattle')\n      await page.getByRole('combobox', {name: 'State'}).selectOption('WA')\n      await page.getByLabel('Postal code').fill('98101')\n      await page.getByRole('button', {name: 'Continue'}).click()\n    })\n\n    await test.step('complete payment', async () => {\n      const paymentFrame = page.frameLocator('iframe[title=\"Secure Payment\"]')\n      await paymentFrame.getByLabel('Card number').fill('5555555555554444')\n      await paymentFrame.getByLabel('Expiry').fill('09/29')\n      await paymentFrame.getByLabel('CVV').fill('456')\n      await page.getByRole('button', {name: 'Subscribe now'}).click()\n    })\n\n    await test.step('verify success', async () => {\n      await page.waitForURL('**/account/subscription/success**')\n      await expect(page.getByRole('heading', {name: 'Welcome to Premium'})).toBeVisible()\n      await expect(page.getByText(/Subscription #\\d+/)).toBeVisible()\n    })\n  })\n})\n```\n\n## Layering Test Types\n\nEffective test suites combine all three types. Example for an \"inventory management\" feature:\n\n### API Layer (60% of tests)\n\nCover every backend logic permutation. Cheap to run and maintain.\n\n```\ntests/api/inventory.spec.ts\n  - creates item with valid data (201)\n  - rejects duplicate SKU (409)\n  - rejects invalid quantity format (422)\n  - rejects missing required fields (422)\n  - warehouse-staff cannot delete items (403)\n  - unauthenticated request returns 401\n  - lists items with pagination\n  - filters items by category\n  - updates item stock level\n  - archives an item\n  - prevents archiving items with pending orders\n```\n\n### Component Layer (30% of tests)\n\nCover every visual state and interaction.\n\n```\ntests/components/InventoryForm.spec.tsx\n  - shows validation errors on empty submit\n  - shows inline error for invalid SKU format\n  - disables submit while saving\n  - calls onSubmit with form data\n  - resets form after successful save\n\ntests/components/InventoryTable.spec.tsx\n  - renders item rows from props\n  - shows empty state when no items\n  - handles archive confirmation modal\n  - sorts by column header click\n  - shows stock level badges with correct colors\n```\n\n### E2E Layer (10% of tests)\n\nCover only critical paths proving full stack works.\n\n```\ntests/e2e/inventory.spec.ts\n  - manager creates item and sees it in list\n  - manager updates item stock level\n  - warehouse-staff cannot access admin settings\n```\n\n### Execution Profile\n\nFor this feature:\n\n- **11 API tests** — ~2 seconds total, no browser\n- **10 component tests** — ~5 seconds total, real browser but no server\n- **3 E2E tests** — ~15 seconds total, full stack\n\nTotal: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).\n\n## Common Mistakes\n\n| Anti-Pattern                              | Problem                                                  | Better Approach                                                |\n| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |\n| E2E for every validation rule             | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display  |\n| No API tests, all E2E                     | Slow suite, flaky from UI timing, hard to diagnose       | API tests for data/logic, E2E for critical paths only          |\n| Component tests mocking everything        | Tests pass but app broken because mocks drift            | Mock only external boundaries; API tests verify real contracts |\n| Same assertion in API, component, AND E2E | Triple maintenance cost                                  | Each layer tests what it uniquely verifies                     |\n| E2E creating test data via UI             | 2-minute test where 90 seconds is setup                  | Seed via API in `beforeEach`, test actual flow                 |\n| Testing third-party behavior              | Testing that Stripe validates cards (Stripe's job)       | Mock Stripe; trust their contract                              |\n| Skipping API layer                        | Can't tell if bug is frontend or backend                 | API tests isolate backend; component tests isolate frontend    |\n| One giant E2E for entire feature          | 5-minute test failing somewhere with no clear cause      | Focused E2E per critical path; use `test.step()`               |\n\n## Related\n\n- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming\n- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing\n- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests\n- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`\n- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services\n- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic\n","isInternal":false,"tokens":3232,"sizeBytes":14676},{"name":"when-to-mock.md","path":".agents/skills/playwright-best-practices/architecture/when-to-mock.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/architecture/when-to-mock.md","title":"Architecture Skill","category":"anthropic-skill","format":"markdown","content":"# Mocking Strategy: Real vs Mock Services\n\n## Table of Contents\n\n1. [Core Principle](#core-principle)\n2. [Decision Matrix](#decision-matrix)\n3. [Decision Flowchart](#decision-flowchart)\n4. [Mocking Techniques](#mocking-techniques)\n5. [Real Service Strategies](#real-service-strategies)\n6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)\n7. [Validating Mock Accuracy](#validating-mock-accuracy)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.\n\n## Core Principle\n\n**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.\n\n## Decision Matrix\n\n| Scenario                          | Mock?                    | Strategy                                        |\n| --------------------------------- | ------------------------ | ----------------------------------------------- |\n| Your own REST/GraphQL API         | Never                    | Hit real API against staging or local dev       |\n| Your database (through your API)  | Never                    | Seed via API or fixtures                        |\n| Authentication (your auth system) | Mostly no                | Use `storageState` to skip login in most tests  |\n| Stripe / payment gateway          | Always                   | `route.fulfill()` with expected responses       |\n| SendGrid / email service          | Always                   | Mock the API call, verify request payload       |\n| OAuth providers (Google, GitHub)  | Always                   | Mock token exchange, test your callback handler |\n| Analytics (Segment, Mixpanel)     | Always                   | `route.abort()` or `route.fulfill()`            |\n| Maps / geocoding APIs             | Always                   | Mock with static responses                      |\n| Feature flags (LaunchDarkly)      | Usually                  | Mock to force specific flag states              |\n| CDN / static assets               | Never                    | Let them load normally                          |\n| Flaky external dependency         | CI: mock, local: real    | Conditional mocking based on environment        |\n| Slow external dependency          | Dev: mock, nightly: real | Separate test projects in config                |\n\n## Decision Flowchart\n\n```text\nIs this service part of YOUR codebase?\n├── YES → Do NOT mock. Test the real integration.\n│   ├── Is it slow? → Optimize the service, not the test.\n│   └── Is it flaky? → Fix the service. Flaky infra is a bug.\n└── NO → It's a third-party service.\n    ├── Is it paid per call? → ALWAYS mock.\n    ├── Is it rate-limited? → ALWAYS mock.\n    ├── Is it slow or unreliable? → ALWAYS mock.\n    └── Is it a complex multi-step flow? → Mock with HAR recording.\n```\n\n## Mocking Techniques\n\n### Blocking Unwanted Requests\n\nBlock third-party scripts that slow tests and add no coverage:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {\n    route.abort()\n  })\n})\n\ntest('dashboard renders without tracking scripts', async ({page}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n})\n```\n\n### Full Mock (route.fulfill)\n\nCompletely replace a third-party API response:\n\n```typescript\ntest('order flow with mocked payment service', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        transactionId: 'txn_mock_abc',\n        status: 'completed',\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n\ntest('display error on payment decline', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 402,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        error: {code: 'insufficient_funds', message: 'Card declined.'},\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByRole('alert')).toContainText('Card declined')\n})\n```\n\n### Partial Mock (Modify Responses)\n\nLet the real API call happen but tweak the response:\n\n```typescript\ntest('display low inventory warning', async ({page}) => {\n  await page.route('**/api/inventory/*', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.quantity = 1\n    data.lowStock = true\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/products/widget-pro')\n  await expect(page.getByText('Only 1 remaining')).toBeVisible()\n})\n\ntest('inject test notification into real response', async ({page}) => {\n  await page.route('**/api/alerts', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.items.push({\n      id: 'test-alert',\n      text: 'Report generated',\n      category: 'info',\n    })\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/home')\n  await expect(page.getByText('Report generated')).toBeVisible()\n})\n```\n\n### Record and Replay (HAR Files)\n\nFor complex API sequences (OAuth flows, multi-step wizards):\n\n**Recording:**\n\n```typescript\ntest('capture API traffic for admin panel', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: true,\n  })\n\n  await page.goto('/admin')\n  await page.getByRole('tab', {name: 'Reports'}).click()\n  await page.getByRole('tab', {name: 'Settings'}).click()\n})\n```\n\n**Replaying:**\n\n```typescript\ntest('admin panel loads with recorded data', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: false,\n  })\n\n  await page.goto('/admin')\n  await expect(page.getByRole('heading', {name: 'Reports'})).toBeVisible()\n})\n```\n\n**HAR maintenance:**\n\n- Record against a known-good staging environment\n- Commit `.har` files to version control\n- Re-record when APIs change\n- Scope HAR to specific URL patterns\n\n## Real Service Strategies\n\n### Local Dev Server\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 30_000,\n  },\n  use: {\n    baseURL: 'http://localhost:3000',\n  },\n})\n```\n\n### Staging Environment\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    baseURL: process.env.CI ? 'https://staging.example.com' : 'http://localhost:3000',\n  },\n})\n```\n\n### Test Containers\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'docker compose -f docker-compose.test.yml up --wait',\n    url: 'http://localhost:3000/health',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n  globalTeardown: './tests/global-teardown.ts',\n})\n```\n\n```typescript\n// tests/global-teardown.ts\nimport {execSync} from 'child_process'\n\nexport default function globalTeardown() {\n  if (process.env.CI) {\n    execSync('docker compose -f docker-compose.test.yml down -v')\n  }\n}\n```\n\n## Hybrid Approach: Fixture-Based Mock Control\n\nCreate fixtures that let individual tests opt into mocking specific services:\n\n```typescript\n// tests/fixtures/service-mocks.ts\nimport {test as base} from '@playwright/test'\n\ntype MockConfig = {\n  mockPayments: boolean\n  mockNotifications: boolean\n  mockAnalytics: boolean\n}\n\nexport const test = base.extend<MockConfig>({\n  mockPayments: [true, {option: true}],\n  mockNotifications: [true, {option: true}],\n  mockAnalytics: [true, {option: true}],\n\n  page: async ({page, mockPayments, mockNotifications, mockAnalytics}, use) => {\n    if (mockPayments) {\n      await page.route('**/api/billing/**', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({status: 'paid', id: 'inv_mock_789'}),\n        })\n      })\n    }\n\n    if (mockNotifications) {\n      await page.route('**/api/notify', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({delivered: true}),\n        })\n      })\n    }\n\n    if (mockAnalytics) {\n      await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {\n        route.abort()\n      })\n    }\n\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/billing.spec.ts\nimport {test, expect} from './fixtures/service-mocks'\n\ntest('subscription renewal sends notification', async ({page}) => {\n  await page.goto('/account/billing')\n  await page.getByRole('button', {name: 'Renew Now'}).click()\n  await expect(page.getByText('Subscription renewed')).toBeVisible()\n})\n\ntest.describe('integration suite', () => {\n  test.use({mockPayments: false})\n\n  test('real billing flow against test gateway', async ({page}) => {\n    await page.goto('/account/billing')\n    await page.getByRole('button', {name: 'Renew Now'}).click()\n    await expect(page.getByText('Subscription renewed')).toBeVisible()\n  })\n})\n```\n\n### Environment-Based Test Projects\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'ci-fast',\n      testMatch: '**/*.spec.ts',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'nightly-full',\n      testMatch: '**/*.integration.spec.ts',\n      use: {baseURL: 'https://staging.example.com'},\n      timeout: 120_000,\n    },\n  ],\n})\n```\n\n## Validating Mock Accuracy\n\nGuard against mock drift from real APIs:\n\n```typescript\ntest.describe('contract validation', () => {\n  test('billing mock matches real API shape', async ({request}) => {\n    const realResponse = await request.post('/api/billing/charge', {\n      data: {amount: 5000, currency: 'usd'},\n    })\n    const realBody = await realResponse.json()\n\n    const mockBody = {\n      status: 'paid',\n      id: 'inv_mock_789',\n    }\n\n    expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())\n\n    for (const key of Object.keys(mockBody)) {\n      expect(typeof mockBody[key]).toBe(typeof realBody[key])\n    }\n  })\n})\n```\n\n## Anti-Patterns\n\n| Don't Do This                             | Problem                                                       | Do This Instead                                               |\n| ----------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |\n| Mock your own API                         | Tests pass, app breaks. Zero integration coverage.            | Hit your real API. Mock only third-party services.            |\n| Mock everything for speed                 | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries.                                |\n| Never mock anything                       | Tests are slow, flaky, fail when third parties have outages.  | Mock third-party services.                                    |\n| Use outdated mocks                        | Mock returns different shape than real API.                   | Run contract validation tests. Re-record HAR files regularly. |\n| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation.                          | Use `page.route()` which intercepts at network layer.         |\n| Copy-paste mocks across files             | One API change requires updating many files.                  | Centralize mocks in fixtures.                                 |\n| Block all network and whitelist           | Extremely brittle. Every new endpoint requires update.        | Allow all by default. Selectively mock third-party services.  |\n","isInternal":false,"tokens":2757,"sizeBytes":12344},{"name":"browser-apis.md","path":".agents/skills/playwright-best-practices/browser-apis/browser-apis.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/browser-apis/browser-apis.md","title":"Browser-apis Skill","category":"anthropic-skill","format":"markdown","content":"# Browser APIs: Geolocation, Permissions & More\n\n## Table of Contents\n\n1. [Geolocation](#geolocation)\n2. [Permissions](#permissions)\n3. [Clipboard](#clipboard)\n4. [Notifications](#notifications)\n5. [Camera & Microphone](#camera--microphone)\n\n## Geolocation\n\n### Mock Location\n\n```typescript\ntest('shows nearby stores', async ({context}) => {\n  // Grant permission and set location\n  await context.grantPermissions(['geolocation'])\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194}) // San Francisco\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('San Francisco')).toBeVisible()\n})\n```\n\n### Geolocation Fixture\n\n```typescript\n// fixtures/geolocation.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype Coordinates = {latitude: number; longitude: number; accuracy?: number}\n\ntype GeoFixtures = {\n  setLocation: (coords: Coordinates) => Promise<void>\n}\n\nexport const test = base.extend<GeoFixtures>({\n  setLocation: async ({context}, use) => {\n    await context.grantPermissions(['geolocation'])\n\n    await use(async (coords) => {\n      await context.setGeolocation({\n        latitude: coords.latitude,\n        longitude: coords.longitude,\n        accuracy: coords.accuracy ?? 100,\n      })\n    })\n  },\n})\n\n// Usage\ntest('delivery zone check', async ({page, setLocation}) => {\n  await setLocation({latitude: 40.7128, longitude: -74.006}) // NYC\n\n  await page.goto('/delivery')\n\n  await expect(page.getByText('Delivery available')).toBeVisible()\n})\n```\n\n### Test Location Changes\n\n```typescript\ntest('tracks location updates', async ({context}) => {\n  await context.grantPermissions(['geolocation'])\n\n  const page = await context.newPage()\n  await page.goto('/tracking')\n\n  // Initial location\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194})\n  await page.getByRole('button', {name: 'Start Tracking'}).click()\n\n  await expect(page.getByTestId('location')).toContainText('37.7749')\n\n  // Move to new location\n  await context.setGeolocation({latitude: 37.8044, longitude: -122.2712})\n\n  // Trigger location update\n  await page.evaluate(() => {\n    navigator.geolocation.getCurrentPosition(() => {})\n  })\n\n  await expect(page.getByTestId('location')).toContainText('37.8044')\n})\n```\n\n### Test Geolocation Denial\n\n```typescript\ntest('handles location denied', async ({browser}) => {\n  // Create context without geolocation permission\n  const context = await browser.newContext({\n    permissions: [], // No permissions\n  })\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('Location access denied')).toBeVisible()\n  await expect(page.getByLabel('Enter ZIP code')).toBeVisible()\n\n  await context.close()\n})\n```\n\n## Permissions\n\n### Grant Permissions\n\n```typescript\ntest('notifications with permission', async ({context}) => {\n  await context.grantPermissions(['notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/alerts')\n\n  // Notification API should work\n  const permission = await page.evaluate(() => Notification.permission)\n  expect(permission).toBe('granted')\n})\n```\n\n### Test Permission Denied\n\n```typescript\ntest('handles notification permission denied', async ({browser}) => {\n  const context = await browser.newContext({\n    permissions: [], // Deny all\n  })\n\n  const page = await context.newPage()\n  await page.goto('/notifications')\n\n  await page.getByRole('button', {name: 'Enable Notifications'}).click()\n\n  await expect(page.getByText('Please enable notifications')).toBeVisible()\n\n  await context.close()\n})\n```\n\n### Multiple Permissions\n\n```typescript\ntest('video call with permissions', async ({context}) => {\n  await context.grantPermissions(['camera', 'microphone', 'notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/video-call')\n\n  // All permissions should be granted\n  const permissions = await page.evaluate(async () => ({\n    camera: await navigator.permissions.query({\n      name: 'camera' as PermissionName,\n    }),\n    microphone: await navigator.permissions.query({\n      name: 'microphone' as PermissionName,\n    }),\n  }))\n\n  expect(permissions.camera.state).toBe('granted')\n  expect(permissions.microphone.state).toBe('granted')\n})\n```\n\n## Clipboard\n\n### Test Copy to Clipboard\n\n```typescript\ntest('copy button works', async ({page, context}) => {\n  // Grant clipboard permissions\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/share')\n\n  await page.getByRole('button', {name: 'Copy Link'}).click()\n\n  // Read clipboard content\n  const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())\n\n  expect(clipboardContent).toContain('https://example.com/share/')\n})\n```\n\n### Test Paste from Clipboard\n\n```typescript\ntest('paste from clipboard', async ({page, context}) => {\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/editor')\n\n  // Write to clipboard\n  await page.evaluate(() => navigator.clipboard.writeText('Pasted content'))\n\n  // Trigger paste\n  await page.getByLabel('Content').focus()\n  await page.keyboard.press('Control+V')\n\n  await expect(page.getByLabel('Content')).toHaveValue('Pasted content')\n})\n```\n\n### Clipboard Fixture\n\n```typescript\n// fixtures/clipboard.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClipboardFixtures = {\n  clipboard: {\n    write: (text: string) => Promise<void>\n    read: () => Promise<string>\n  }\n}\n\nexport const test = base.extend<ClipboardFixtures>({\n  clipboard: async ({page, context}, use) => {\n    await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n    await use({\n      write: async (text) => {\n        await page.evaluate((t) => navigator.clipboard.writeText(t), text)\n      },\n      read: async () => {\n        return page.evaluate(() => navigator.clipboard.readText())\n      },\n    })\n  },\n})\n```\n\n## Notifications\n\n### Mock Notification API\n\n```typescript\ntest('shows browser notification', async ({page}) => {\n  const notifications: any[] = []\n\n  // Mock Notification constructor\n  await page.addInitScript(() => {\n    ;(window as any).__notifications = []\n    ;(window as any).Notification = class {\n      constructor(title: string, options?: NotificationOptions) {\n        ;(window as any).__notifications.push({title, ...options})\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/alerts')\n  await page.getByRole('button', {name: 'Notify Me'}).click()\n\n  // Check notification was created\n  const created = await page.evaluate(() => (window as any).__notifications)\n  expect(created).toHaveLength(1)\n  expect(created[0].title).toBe('New Alert')\n})\n```\n\n### Test Notification Click\n\n```typescript\ntest('notification click handler', async ({page}) => {\n  await page.addInitScript(() => {\n    ;(window as any).Notification = class {\n      onclick: (() => void) | null = null\n      constructor(title: string) {\n        // Simulate click after creation\n        setTimeout(() => this.onclick?.(), 100)\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/messages')\n  await page.evaluate(() => {\n    new Notification('New Message')\n  })\n\n  // Should navigate to messages when notification clicked\n  await expect(page).toHaveURL(/\\/messages/)\n})\n```\n\n## Camera & Microphone\n\n### Mock Media Devices\n\n```typescript\ntest('video preview works', async ({page, context}) => {\n  await context.grantPermissions(['camera'])\n\n  // Mock getUserMedia\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      canvas.width = 640\n      canvas.height = 480\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/video-settings')\n  await page.getByRole('button', {name: 'Start Camera'}).click()\n\n  await expect(page.getByTestId('video-preview')).toBeVisible()\n})\n```\n\n### Test Media Device Selection\n\n```typescript\ntest('switch camera', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.enumerateDevices = async () =>\n      [\n        {\n          deviceId: 'cam1',\n          kind: 'videoinput',\n          label: 'Front Camera',\n          groupId: '1',\n        },\n        {\n          deviceId: 'cam2',\n          kind: 'videoinput',\n          label: 'Back Camera',\n          groupId: '2',\n        },\n      ] as MediaDeviceInfo[]\n\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/camera')\n\n  // Should show camera options\n  await expect(page.getByRole('combobox', {name: 'Camera'})).toBeVisible()\n  await expect(page.getByText('Front Camera')).toBeVisible()\n  await expect(page.getByText('Back Camera')).toBeVisible()\n})\n```\n\n### Test Media Errors\n\n```typescript\ntest('handles camera access error', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      throw new DOMException('Permission denied', 'NotAllowedError')\n    }\n  })\n\n  await page.goto('/video-call')\n  await page.getByRole('button', {name: 'Join Call'}).click()\n\n  await expect(page.getByText('Camera access denied')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Join Audio Only'})).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                           | Solution                            |\n| ----------------------------- | --------------------------------- | ----------------------------------- |\n| Not granting permissions      | Tests fail with permission errors | Use `context.grantPermissions()`    |\n| Testing real geolocation      | Flaky, environment-dependent      | Mock with `setGeolocation()`        |\n| Not testing permission denial | Misses error handling             | Test both granted and denied states |\n| Using real camera/mic         | CI has no devices                 | Mock `getUserMedia`                 |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures\n- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation\n","isInternal":false,"tokens":2343,"sizeBytes":10502},{"name":"iframes.md","path":".agents/skills/playwright-best-practices/browser-apis/iframes.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/browser-apis/iframes.md","title":"Browser-apis Skill","category":"anthropic-skill","format":"markdown","content":"# iFrame Testing\n\n## Table of Contents\n\n1. [Basic iFrame Access](#basic-iframe-access)\n2. [Cross-Origin iFrames](#cross-origin-iframes)\n3. [Nested iFrames](#nested-iframes)\n4. [Dynamic iFrames](#dynamic-iframes)\n5. [iFrame Navigation](#iframe-navigation)\n6. [Common Patterns](#common-patterns)\n\n## Basic iFrame Access\n\n### Using frameLocator\n\n```typescript\n// Access iframe by selector\nconst frame = page.frameLocator('iframe#payment')\nawait frame.getByRole('button', {name: 'Pay'}).click()\n\n// Access by name attribute\nconst namedFrame = page.frameLocator('iframe[name=\"checkout\"]')\nawait namedFrame.getByLabel('Card number').fill('4242424242424242')\n\n// Access by title\nconst titledFrame = page.frameLocator('iframe[title=\"Payment Form\"]')\n\n// Access by src (partial match)\nconst srcFrame = page.frameLocator('iframe[src*=\"stripe.com\"]')\n```\n\n### Frame vs FrameLocator\n\n```typescript\n// frameLocator - for locator-based operations (recommended)\nconst frameLocator = page.frameLocator('#my-iframe')\nawait frameLocator.getByRole('button').click()\n\n// frame() - for Frame object operations (navigation, evaluation)\nconst frame = page.frame({name: 'my-frame'})\nif (frame) {\n  await frame.goto('https://example.com')\n  const title = await frame.title()\n}\n\n// Get all frames\nconst frames = page.frames()\nfor (const f of frames) {\n  console.log('Frame URL:', f.url())\n}\n```\n\n### Waiting for iFrame Content\n\n```typescript\n// Wait for iframe to load\nconst frame = page.frameLocator('#dynamic-iframe')\n\n// Wait for element inside iframe\nawait expect(frame.getByRole('heading')).toBeVisible({timeout: 10000})\n\n// Wait for iframe src to change\nawait page.waitForFunction(() => {\n  const iframe = document.querySelector('iframe#my-frame') as HTMLIFrameElement\n  return iframe?.src.includes('loaded')\n})\n```\n\n## Cross-Origin iFrames\n\n### Accessing Cross-Origin Content\n\n```typescript\n// Cross-origin iframes work seamlessly with frameLocator\nconst thirdPartyFrame = page.frameLocator('iframe[src*=\"third-party.com\"]')\n\n// Interact with elements inside cross-origin iframe\nawait thirdPartyFrame.getByRole('textbox').fill('test@example.com')\nawait thirdPartyFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Wait for cross-origin iframe to be ready\nawait expect(thirdPartyFrame.locator('body')).toBeVisible()\n```\n\n### Payment Provider iFrames (Stripe, PayPal)\n\n```typescript\ntest('Stripe payment iframe', async ({page}) => {\n  await page.goto('/checkout')\n\n  // Stripe uses multiple iframes for each field\n  const cardFrame = page.frameLocator('iframe[name*=\"__privateStripeFrame\"]').first()\n\n  // Wait for Stripe to initialize\n  await expect(cardFrame.locator('[placeholder=\"Card number\"]')).toBeVisible({\n    timeout: 15000,\n  })\n\n  // Fill card details\n  await cardFrame.locator('[placeholder=\"Card number\"]').fill('4242424242424242')\n  await cardFrame.locator('[placeholder=\"MM / YY\"]').fill('12/30')\n  await cardFrame.locator('[placeholder=\"CVC\"]').fill('123')\n})\n```\n\n### Handling OAuth in iFrames\n\n```typescript\ntest('OAuth iframe flow', async ({page}) => {\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // If OAuth opens in iframe instead of popup\n  const oauthFrame = page.frameLocator('iframe[src*=\"accounts.google.com\"]')\n\n  // Wait for OAuth form\n  await expect(oauthFrame.getByLabel('Email')).toBeVisible({timeout: 10000})\n  await oauthFrame.getByLabel('Email').fill('test@gmail.com')\n})\n```\n\n## Nested iFrames\n\n### Accessing Nested Frames\n\n```typescript\n// Parent iframe contains child iframe\nconst parentFrame = page.frameLocator('#outer-frame')\nconst childFrame = parentFrame.frameLocator('#inner-frame')\n\n// Interact with deeply nested content\nawait childFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Multiple levels of nesting\nconst level1 = page.frameLocator('#level1')\nconst level2 = level1.frameLocator('#level2')\nconst level3 = level2.frameLocator('#level3')\nawait level3.getByText('Deep content').click()\n```\n\n### Finding Elements Across Frame Hierarchy\n\n```typescript\n// Helper to search all frames for an element\nasync function findInAnyFrame(page: Page, selector: string): Promise<Locator | null> {\n  // Check main page first\n  const mainCount = await page.locator(selector).count()\n  if (mainCount > 0) return page.locator(selector)\n\n  // Check all frames\n  for (const frame of page.frames()) {\n    const count = await frame.locator(selector).count()\n    if (count > 0) {\n      return frame.locator(selector)\n    }\n  }\n  return null\n}\n\ntest('find element in any frame', async ({page}) => {\n  await page.goto('/complex-page')\n  const element = await findInAnyFrame(page, '[data-testid=\"submit-btn\"]')\n  if (element) await element.click()\n})\n```\n\n## Dynamic iFrames\n\n### iFrames Created at Runtime\n\n```typescript\ntest('handle dynamically created iframe', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Click button that creates iframe\n  await page.getByRole('button', {name: 'Open Widget'}).click()\n\n  // Wait for iframe to appear in DOM\n  await page.waitForSelector('iframe#widget-frame')\n\n  // Now access the frame\n  const widgetFrame = page.frameLocator('#widget-frame')\n  await expect(widgetFrame.getByText('Widget Loaded')).toBeVisible()\n})\n```\n\n### iFrames with Changing src\n\n```typescript\ntest('iframe src changes', async ({page}) => {\n  await page.goto('/multi-step')\n\n  const frame = page.frameLocator('#step-frame')\n\n  // Step 1\n  await expect(frame.getByText('Step 1')).toBeVisible()\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for iframe to reload with new content\n  await expect(frame.getByText('Step 2')).toBeVisible({timeout: 10000})\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Step 3\n  await expect(frame.getByText('Step 3')).toBeVisible({timeout: 10000})\n})\n```\n\n### Lazy-Loaded iFrames\n\n```typescript\ntest('lazy loaded iframe', async ({page}) => {\n  await page.goto('/page-with-lazy-iframe')\n\n  // Scroll to trigger lazy load\n  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))\n\n  // Wait for iframe to load\n  const lazyFrame = page.frameLocator('#lazy-iframe')\n  await expect(lazyFrame.locator('body')).not.toBeEmpty({timeout: 15000})\n\n  // Interact with content\n  await lazyFrame.getByRole('button').click()\n})\n```\n\n## iFrame Navigation\n\n### Navigating Within iFrame\n\n```typescript\ntest('iframe internal navigation', async ({page}) => {\n  await page.goto('/app')\n\n  // Get frame object for navigation control\n  const frame = page.frame({name: 'content-frame'})\n  if (!frame) throw new Error('Frame not found')\n\n  // Navigate within iframe\n  await frame.goto('https://embedded-app.com/page2')\n\n  // Wait for navigation\n  await frame.waitForURL('**/page2')\n\n  // Verify content\n  await expect(frame.getByRole('heading')).toHaveText('Page 2')\n})\n```\n\n### Handling Frame Navigation Events\n\n```typescript\ntest('track iframe navigation', async ({page}) => {\n  const navigations: string[] = []\n\n  // Listen to frame navigation\n  page.on('framenavigated', (frame) => {\n    if (frame.parentFrame()) {\n      // This is an iframe navigation\n      navigations.push(frame.url())\n    }\n  })\n\n  await page.goto('/with-iframe')\n  await page.frameLocator('#nav-frame').getByRole('link', {name: 'Page 2'}).click()\n\n  // Verify navigation occurred\n  expect(navigations.some((url) => url.includes('page2'))).toBe(true)\n})\n```\n\n## Common Patterns\n\n### iFrame Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base, FrameLocator} from '@playwright/test'\n\nexport const test = base.extend<{paymentFrame: FrameLocator}>({\n  paymentFrame: async ({page}, use) => {\n    await page.goto('/checkout')\n\n    // Wait for payment iframe to be ready\n    const frame = page.frameLocator('iframe[src*=\"payment\"]')\n    await expect(frame.locator('body')).toBeVisible({timeout: 15000})\n\n    await use(frame)\n  },\n})\n\n// test file\ntest('complete payment', async ({paymentFrame}) => {\n  await paymentFrame.getByLabel('Card').fill('4242424242424242')\n  await paymentFrame.getByRole('button', {name: 'Pay'}).click()\n})\n```\n\n### Debugging iFrame Issues\n\n```typescript\ntest('debug iframe content', async ({page}) => {\n  await page.goto('/page-with-iframes')\n\n  // List all frames\n  console.log('All frames:')\n  for (const frame of page.frames()) {\n    console.log(`  - ${frame.name() || '(unnamed)'}: ${frame.url()}`)\n  }\n\n  // Screenshot specific iframe content\n  const frame = page.frame({name: 'target-frame'})\n  if (frame) {\n    const body = frame.locator('body')\n    await body.screenshot({path: 'iframe-content.png'})\n  }\n\n  // Get iframe HTML for debugging\n  const frameContent = page.frameLocator('#my-frame')\n  const html = await frameContent.locator('body').innerHTML()\n  console.log('iFrame HTML:', html.substring(0, 500))\n})\n```\n\n### Handling iFrame Load Failures\n\n```typescript\ntest('handle iframe load failure', async ({page}) => {\n  await page.goto('/page-with-unreliable-iframe')\n\n  const frame = page.frameLocator('#unreliable-frame')\n\n  try {\n    // Try to interact with iframe content\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 5000})\n    await frame.getByRole('button').click()\n  } catch (error) {\n    // Fallback: refresh iframe\n    await page.evaluate(() => {\n      const iframe = document.querySelector('#unreliable-frame') as HTMLIFrameElement\n      if (iframe) iframe.src = iframe.src\n    })\n\n    // Retry\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 10000})\n    await frame.getByRole('button').click()\n  }\n})\n```\n\n### Mocking iFrame Content\n\n```typescript\ntest('mock iframe response', async ({page}) => {\n  // Intercept iframe src request\n  await page.route('**/embedded-widget**', (route) => {\n    route.fulfill({\n      contentType: 'text/html',\n      body: `\n        <!DOCTYPE html>\n        <html>\n          <body>\n            <h1>Mocked Widget</h1>\n            <button>Mocked Button</button>\n          </body>\n        </html>\n      `,\n    })\n  })\n\n  await page.goto('/page-with-widget')\n\n  const frame = page.frameLocator('#widget-frame')\n  await expect(frame.getByRole('heading')).toHaveText('Mocked Widget')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                           | Solution                                           |\n| ------------------------------------- | --------------------------------- | -------------------------------------------------- |\n| Using `page.frame()` for interactions | Less reliable than frameLocator   | Use `page.frameLocator()` for element interactions |\n| Hardcoding iframe index               | Fragile if DOM order changes      | Use name, id, or src attribute selectors           |\n| Not waiting for iframe load           | Race conditions                   | Wait for element inside iframe to be visible       |\n| Assuming same-origin                  | Cross-origin has different timing | Always wait for iframe content explicitly          |\n| Ignoring nested iframes               | Element not found                 | Chain frameLocator calls for nested frames         |\n\n## Related References\n\n- **Locators**: See [locators.md](../core/locators.md) for selector strategies\n- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues\n","isInternal":false,"tokens":2698,"sizeBytes":11383},{"name":"service-workers.md","path":".agents/skills/playwright-best-practices/browser-apis/service-workers.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/browser-apis/service-workers.md","title":"Browser-apis Skill","category":"anthropic-skill","format":"markdown","content":"# Service Worker Testing\n\n## Table of Contents\n\n1. [Service Worker Basics](#service-worker-basics)\n2. [Registration & Lifecycle](#registration--lifecycle)\n3. [Cache Testing](#cache-testing)\n4. [Offline Testing](#offline-testing)\n5. [Push Notifications](#push-notifications)\n6. [Background Sync](#background-sync)\n\n## Service Worker Basics\n\n### Waiting for Service Worker Registration\n\n```typescript\ntest('service worker registers', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to register\n  const swRegistered = await page.evaluate(async () => {\n    if (!('serviceWorker' in navigator)) return false\n\n    const registration = await navigator.serviceWorker.ready\n    return !!registration.active\n  })\n\n  expect(swRegistered).toBe(true)\n})\n```\n\n### Getting Service Worker State\n\n```typescript\ntest('check SW state', async ({page}) => {\n  await page.goto('/')\n\n  const swState = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.getRegistration()\n    if (!registration) return null\n\n    return {\n      installing: !!registration.installing,\n      waiting: !!registration.waiting,\n      active: !!registration.active,\n      scope: registration.scope,\n    }\n  })\n\n  expect(swState?.active).toBe(true)\n  expect(swState?.scope).toContain(page.url())\n})\n```\n\n### Service Worker Context\n\n```typescript\ntest('access service worker', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Get all service workers in context\n  const workers = context.serviceWorkers()\n\n  // Wait for service worker if not yet available\n  if (workers.length === 0) {\n    await context.waitForEvent('serviceworker')\n  }\n\n  const sw = context.serviceWorkers()[0]\n  expect(sw.url()).toContain('sw.js')\n})\n```\n\n## Registration & Lifecycle\n\n### Testing SW Update Flow\n\n```typescript\ntest('service worker updates', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Check for update\n  const hasUpdate = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.ready\n    await registration.update()\n\n    return new Promise<boolean>((resolve) => {\n      if (registration.waiting) {\n        resolve(true)\n      } else {\n        registration.addEventListener('updatefound', () => {\n          resolve(true)\n        })\n        // Timeout if no update\n        setTimeout(() => resolve(false), 5000)\n      }\n    })\n  })\n\n  // If update found, test skip waiting flow\n  if (hasUpdate) {\n    await page.evaluate(async () => {\n      const registration = await navigator.serviceWorker.ready\n      registration.waiting?.postMessage({type: 'SKIP_WAITING'})\n    })\n\n    // Wait for controller change\n    await page.evaluate(() => {\n      return new Promise<void>((resolve) => {\n        navigator.serviceWorker.addEventListener('controllerchange', () => {\n          resolve()\n        })\n      })\n    })\n  }\n})\n```\n\n### Testing SW Installation\n\n```typescript\ntest('verify SW install event', async ({context, page}) => {\n  // Listen for service worker before navigating\n  const swPromise = context.waitForEvent('serviceworker')\n\n  await page.goto('/pwa-app')\n\n  const sw = await swPromise\n\n  // Evaluate in SW context\n  const swVersion = await sw.evaluate(() => {\n    // Access SW globals\n    return (self as any).SW_VERSION || 'unknown'\n  })\n\n  expect(swVersion).toBe('1.0.0')\n})\n```\n\n### Unregistering Service Workers\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.goto('/')\n\n  // Unregister all service workers for clean state\n  await page.evaluate(async () => {\n    const registrations = await navigator.serviceWorker.getRegistrations()\n    await Promise.all(registrations.map((r) => r.unregister()))\n  })\n\n  // Clear caches\n  await page.evaluate(async () => {\n    const cacheNames = await caches.keys()\n    await Promise.all(cacheNames.map((name) => caches.delete(name)))\n  })\n})\n```\n\n## Cache Testing\n\n### Verifying Cached Resources\n\n```typescript\ntest('assets are cached', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to cache assets\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n\n  // Check cache contents\n  const cachedUrls = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const requests = await cache.keys()\n    return requests.map((r) => r.url)\n  })\n\n  expect(cachedUrls).toContain(expect.stringContaining('/styles.css'))\n  expect(cachedUrls).toContain(expect.stringContaining('/app.js'))\n})\n```\n\n### Testing Cache Strategies\n\n```typescript\ntest('cache-first strategy', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for initial cache\n  await page.waitForFunction(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.length > 0\n  })\n\n  // Block network for cached resources\n  await page.route('**/styles.css', (route) => route.abort())\n\n  // Reload - should work from cache\n  await page.reload()\n\n  // Verify page still styled (CSS loaded from cache)\n  const hasStyles = await page.evaluate(() => {\n    const body = document.body\n    const styles = window.getComputedStyle(body)\n    return styles.fontFamily !== '' // Has custom font from CSS\n  })\n\n  expect(hasStyles).toBe(true)\n})\n```\n\n### Testing Cache Updates\n\n```typescript\ntest('cache updates on new version', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Get initial cache\n  const initialCacheKeys = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.map((r) => r.url)\n  })\n\n  // Simulate app update by mocking SW response\n  await page.route('**/sw.js', (route) => {\n    route.fulfill({\n      contentType: 'application/javascript',\n      body: `\n        const VERSION = 'v2';\n        self.addEventListener('install', (e) => {\n          e.waitUntil(caches.open('app-cache-v2'));\n          self.skipWaiting();\n        });\n      `,\n    })\n  })\n\n  // Trigger update\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.update()\n  })\n\n  // Verify new cache exists\n  await page.waitForFunction(async () => {\n    return await caches.has('app-cache-v2')\n  })\n})\n```\n\n## Offline Testing\n\nThis section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).\n\n### Simulating Offline Mode\n\n```typescript\ntest('app works offline', async ({page, context}) => {\n  await page.goto('/pwa-app')\n\n  // Ensure SW is active and content cached\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n  await page.waitForTimeout(1000) // Allow caching to complete\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to cached page\n  await page.reload()\n\n  // Verify content loads\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  // Verify offline indicator\n  await expect(page.locator('.offline-badge')).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n  await expect(page.locator('.offline-badge')).not.toBeVisible()\n})\n```\n\n### Testing Offline Fallback\n\n```typescript\ntest('shows offline page for uncached routes', async ({page, context}) => {\n  await page.goto('/pwa-app')\n  await page.evaluate(() => navigator.serviceWorker.ready)\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to uncached page\n  await page.goto('/uncached-page')\n\n  // Should show offline fallback\n  await expect(page.getByText('You are offline')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Testing Offline Form Submission\n\n```typescript\ntest('queues form submission offline', async ({page, context}) => {\n  await page.goto('/pwa-app/form')\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Submit form\n  await page.getByLabel('Message').fill('Offline message')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Should show queued status\n  await expect(page.getByText('Queued for sync')).toBeVisible()\n\n  // Go online\n  await context.setOffline(false)\n\n  // Trigger sync (or wait for automatic)\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    // Manually trigger sync for testing\n    await (reg as any).sync?.register('form-sync')\n  })\n\n  // Verify submission completed\n  await expect(page.getByText('Message sent')).toBeVisible({timeout: 10000})\n})\n```\n\n## Push Notifications\n\n### Mocking Push Subscription\n\n```typescript\ntest('handles push subscription', async ({page, context}) => {\n  // Grant notification permission\n  await context.grantPermissions(['notifications'])\n\n  await page.goto('/pwa-app')\n\n  // Subscribe to push\n  const subscription = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    const sub = await reg.pushManager.subscribe({\n      userVisibleOnly: true,\n      applicationServerKey: 'test-key',\n    })\n    return sub.toJSON()\n  })\n\n  expect(subscription.endpoint).toBeDefined()\n})\n```\n\n### Testing Push Message Handling\n\n```typescript\ntest('handles push notification', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Wait for SW\n  const swPromise = context.waitForEvent('serviceworker')\n  const sw = await swPromise\n\n  // Simulate push message to service worker\n  await sw.evaluate(async () => {\n    // Dispatch push event\n    const pushEvent = new PushEvent('push', {\n      data: new PushMessageData(JSON.stringify({title: 'Test', body: 'Push message'})),\n    })\n    self.dispatchEvent(pushEvent)\n  })\n\n  // Note: Actual notification display testing is limited in Playwright\n  // Focus on verifying the SW handles the push correctly\n})\n```\n\n### Testing Notification Click\n\n```typescript\ntest('notification click opens page', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Store notification URL target\n  let notificationUrl = ''\n\n  // Listen for new pages (notification click opens new page)\n  context.on('page', (newPage) => {\n    notificationUrl = newPage.url()\n  })\n\n  // Trigger notification via SW\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.showNotification('Test', {\n      body: 'Click me',\n      data: {url: '/notification-target'},\n    })\n  })\n\n  // Simulate clicking notification (via SW)\n  const sw = context.serviceWorkers()[0]\n  await sw.evaluate(() => {\n    self.dispatchEvent(\n      new NotificationEvent('notificationclick', {\n        notification: {data: {url: '/notification-target'}} as any,\n      }),\n    )\n  })\n\n  // Verify navigation occurred\n  await page.waitForTimeout(1000)\n  // Check if new page opened or current page navigated\n})\n```\n\n## Background Sync\n\n### Testing Background Sync Registration\n\n```typescript\ntest('registers background sync', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Register sync\n  const syncRegistered = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    if (!('sync' in reg)) return false\n\n    await (reg as any).sync.register('my-sync')\n    return true\n  })\n\n  expect(syncRegistered).toBe(true)\n})\n```\n\n### Testing Sync Event\n\n```typescript\ntest('sync event fires when online', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Queue data while offline\n  await context.setOffline(true)\n\n  await page.evaluate(async () => {\n    // Store data in IndexedDB for sync\n    const db = await openDB()\n    await db.put('sync-queue', {id: 1, data: 'test'})\n\n    // Register sync\n    const reg = await navigator.serviceWorker.ready\n    await (reg as any).sync.register('data-sync')\n  })\n\n  // Track sync completion\n  await page.evaluate(() => {\n    window.syncCompleted = false\n    navigator.serviceWorker.addEventListener('message', (e) => {\n      if (e.data.type === 'SYNC_COMPLETE') {\n        window.syncCompleted = true\n      }\n    })\n  })\n\n  // Go online\n  await context.setOffline(false)\n\n  // Wait for sync to complete\n  await page.waitForFunction(() => window.syncCompleted, {timeout: 10000})\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                 | Solution                                     |\n| ------------------------------ | ----------------------- | -------------------------------------------- |\n| Not clearing SW between tests  | Tests affect each other | Unregister SW in beforeEach                  |\n| Not waiting for SW ready       | Race conditions         | Always await `navigator.serviceWorker.ready` |\n| Testing in isolation only      | Misses real SW behavior | Test with actual caching                     |\n| Hardcoded timeouts for caching | Flaky tests             | Wait for cache to populate                   |\n| Ignoring SW update cycle       | Missing update bugs     | Test install, activate, update flows         |\n\n## Related References\n\n- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns\n- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception\n- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns\n","isInternal":false,"tokens":3052,"sizeBytes":13571},{"name":"websockets.md","path":".agents/skills/playwright-best-practices/browser-apis/websockets.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/browser-apis/websockets.md","title":"Browser-apis Skill","category":"anthropic-skill","format":"markdown","content":"# WebSocket & Real-Time Testing\n\n## Table of Contents\n\n1. [WebSocket Basics](#websocket-basics)\n2. [Mocking WebSocket Messages](#mocking-websocket-messages)\n3. [Testing Real-Time Features](#testing-real-time-features)\n4. [Server-Sent Events](#server-sent-events)\n5. [Reconnection Testing](#reconnection-testing)\n\n## WebSocket Basics\n\n### Wait for WebSocket Connection\n\n```typescript\ntest('chat connects via websocket', async ({page}) => {\n  // Listen for WebSocket connection\n  const wsPromise = page.waitForEvent('websocket')\n\n  await page.goto('/chat')\n\n  const ws = await wsPromise\n  expect(ws.url()).toContain('/ws/chat')\n\n  // Wait for connection to be established\n  await ws.waitForEvent('framesent')\n})\n```\n\n### Monitor WebSocket Messages\n\n```typescript\ntest('receives real-time updates', async ({page}) => {\n  const messages: string[] = []\n\n  // Set up listener before navigation\n  page.on('websocket', (ws) => {\n    ws.on('framereceived', (frame) => {\n      messages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Wait for some messages\n  await expect.poll(() => messages.length).toBeGreaterThan(0)\n\n  // Verify message format\n  const data = JSON.parse(messages[0])\n  expect(data).toHaveProperty('type')\n})\n```\n\n### Capture Sent Messages\n\n```typescript\ntest('sends correct message format', async ({page}) => {\n  const sentMessages: string[] = []\n\n  page.on('websocket', (ws) => {\n    ws.on('framesent', (frame) => {\n      sentMessages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/chat')\n  await page.getByLabel('Message').fill('Hello!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  await expect.poll(() => sentMessages.length).toBeGreaterThan(0)\n\n  const sent = JSON.parse(sentMessages[sentMessages.length - 1])\n  expect(sent).toEqual({\n    type: 'message',\n    content: 'Hello!',\n  })\n})\n```\n\n## Mocking WebSocket Messages\n\n### Inject Messages via Page Evaluate\n\n```typescript\ntest('displays incoming chat message', async ({page}) => {\n  await page.goto('/chat')\n\n  // Wait for WebSocket to be ready\n  await page.waitForFunction(() => (window as any).chatSocket?.readyState === 1)\n\n  // Simulate incoming message\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'message',\n        from: 'Alice',\n        content: 'Hello there!',\n      }),\n    })\n    ;(window as any).chatSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByText('Alice: Hello there!')).toBeVisible()\n})\n```\n\n### Mock WebSocket with Route Handler\n\n```typescript\ntest('mock websocket entirely', async ({page, context}) => {\n  // Intercept the WebSocket upgrade\n  await context.route('**/ws/**', async (route) => {\n    // For WebSocket routes, we can't fulfill directly\n    // Instead, use page.evaluate to mock the client-side\n  })\n\n  // Alternative: Mock at application level\n  await page.addInitScript(() => {\n    const OriginalWebSocket = window.WebSocket\n    ;(window as any).WebSocket = function (url: string) {\n      const ws = {\n        readyState: 1,\n        send: (data: string) => {\n          console.log('WS Send:', data)\n        },\n        close: () => {},\n        addEventListener: () => {},\n        removeEventListener: () => {},\n      }\n      setTimeout(() => ws.onopen?.(), 100)\n      return ws\n    }\n  })\n\n  await page.goto('/chat')\n})\n```\n\n### WebSocket Mock Fixture\n\n```typescript\n// fixtures/websocket.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype WsMessage = {type: string; [key: string]: any}\n\ntype WebSocketFixtures = {\n  mockWebSocket: {\n    injectMessage: (message: WsMessage) => Promise<void>\n    getSentMessages: () => Promise<WsMessage[]>\n  }\n}\n\nexport const test = base.extend<WebSocketFixtures>({\n  mockWebSocket: async ({page}, use) => {\n    const sentMessages: WsMessage[] = []\n\n    // Capture sent messages\n    await page.addInitScript(() => {\n      ;(window as any).__wsSent = []\n      const OriginalWebSocket = window.WebSocket\n      window.WebSocket = function (url: string) {\n        const ws = new OriginalWebSocket(url)\n        const originalSend = ws.send.bind(ws)\n        ws.send = (data: string) => {\n          ;(window as any).__wsSent.push(JSON.parse(data))\n          originalSend(data)\n        }\n        ;(window as any).__ws = ws\n        return ws\n      } as any\n    })\n\n    await use({\n      injectMessage: async (message) => {\n        await page.evaluate((msg) => {\n          const event = new MessageEvent('message', {\n            data: JSON.stringify(msg),\n          })\n          ;(window as any).__ws?.dispatchEvent(event)\n        }, message)\n      },\n      getSentMessages: async () => {\n        return page.evaluate(() => (window as any).__wsSent || [])\n      },\n    })\n  },\n})\n\n// Usage\ntest('chat with mocked websocket', async ({page, mockWebSocket}) => {\n  await page.goto('/chat')\n\n  // Inject incoming message\n  await mockWebSocket.injectMessage({\n    type: 'message',\n    from: 'Bob',\n    content: 'Hi!',\n  })\n\n  await expect(page.getByText('Bob: Hi!')).toBeVisible()\n\n  // Send a reply\n  await page.getByLabel('Message').fill('Hello Bob!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  const sent = await mockWebSocket.getSentMessages()\n  expect(sent).toContainEqual(expect.objectContaining({content: 'Hello Bob!'}))\n})\n```\n\n## Testing Real-Time Features\n\n### Live Notifications\n\n```typescript\ntest('displays live notification', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Simulate notification via WebSocket\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'notification',\n        title: 'New Order',\n        message: 'Order #123 received',\n      }),\n    })\n    ;(window as any).notificationSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByRole('alert')).toContainText('Order #123 received')\n})\n```\n\n### Live Data Updates\n\n```typescript\ntest('updates stock price in real-time', async ({page}) => {\n  await page.goto('/stocks/AAPL')\n\n  const priceElement = page.getByTestId('stock-price')\n  const initialPrice = await priceElement.textContent()\n\n  // Simulate price update\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'price_update',\n        symbol: 'AAPL',\n        price: 150.25,\n      }),\n    })\n    ;(window as any).stockSocket.dispatchEvent(event)\n  })\n\n  await expect(priceElement).not.toHaveText(initialPrice!)\n  await expect(priceElement).toContainText('150.25')\n})\n```\n\n### Collaborative Editing\n\n```typescript\ntest('shows collaborator cursor', async ({page}) => {\n  await page.goto('/document/123')\n\n  // Simulate another user's cursor position\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'cursor',\n        userId: 'user-456',\n        userName: 'Alice',\n        position: {x: 100, y: 200},\n      }),\n    })\n    ;(window as any).docSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByTestId('cursor-user-456')).toBeVisible()\n  await expect(page.getByText('Alice')).toBeVisible()\n})\n```\n\n## Server-Sent Events\n\n### Test SSE Updates\n\n```typescript\ntest('receives SSE updates', async ({page}) => {\n  // Mock SSE endpoint\n  await page.route('**/api/events', (route) => {\n    route.fulfill({\n      status: 200,\n      headers: {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n        'Connection': 'keep-alive',\n      },\n      body: `data: {\"type\":\"update\",\"value\":42}\\n\\n`,\n    })\n  })\n\n  await page.goto('/live-data')\n\n  await expect(page.getByTestId('value')).toHaveText('42')\n})\n```\n\n### Simulate Multiple SSE Events\n\n```typescript\ntest('handles multiple SSE events', async ({page}) => {\n  await page.route('**/api/events', async (route) => {\n    const encoder = new TextEncoder()\n    const events = [`data: {\"count\":1}\\n\\n`, `data: {\"count\":2}\\n\\n`, `data: {\"count\":3}\\n\\n`]\n\n    route.fulfill({\n      status: 200,\n      headers: {'Content-Type': 'text/event-stream'},\n      body: events.join(''),\n    })\n  })\n\n  await page.goto('/counter')\n\n  // Should receive all events\n  await expect(page.getByTestId('count')).toHaveText('3')\n})\n```\n\n## Reconnection Testing\n\n### Test Connection Loss\n\n```typescript\ntest('handles connection loss gracefully', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate connection close\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  // Should show disconnected state\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n})\n```\n\n### Test Reconnection\n\n```typescript\ntest('reconnects after connection loss', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate disconnect\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n\n  // Simulate reconnection\n  await page.evaluate(() => {\n    const event = new Event('open')\n    ;(window as any).chatSocket = {readyState: 1}\n    ;(window as any).chatSocket.dispatchEvent?.(event)\n  })\n\n  // Force component to re-check connection\n  await page.evaluate(() => {\n    window.dispatchEvent(new Event('online'))\n  })\n\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                       | Solution                           |\n| ------------------------------------- | ----------------------------- | ---------------------------------- |\n| Not waiting for WebSocket ready       | Messages sent too early       | Wait for `readyState === 1`        |\n| Testing against real WebSocket server | Flaky, timing-dependent       | Mock WebSocket messages            |\n| Ignoring connection state             | Tests pass but feature broken | Test connected/disconnected states |\n| No cleanup of listeners               | Memory leaks in tests         | Clean up event listeners           |\n\n## Related References\n\n- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns\n- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users\n","isInternal":false,"tokens":2458,"sizeBytes":10399},{"name":"annotations.md","path":".agents/skills/playwright-best-practices/core/annotations.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/annotations.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Test Annotations & Organization\n\n## Table of Contents\n\n1. [Skip Annotations](#skip-annotations)\n2. [Fixme & Fail Annotations](#fixme--fail-annotations)\n3. [Slow Tests](#slow-tests)\n4. [Test Steps](#test-steps)\n5. [Custom Annotations](#custom-annotations)\n6. [Conditional Annotations](#conditional-annotations)\n\n## Skip Annotations\n\n### Basic Skip\n\n```typescript\n// Skip unconditionally\ntest.skip('feature not implemented', async ({page}) => {\n  // This test won't run\n})\n\n// Skip with reason\ntest('payment flow', async ({page}) => {\n  test.skip(true, 'Payment gateway in maintenance')\n  // Test body won't execute\n})\n```\n\n### Conditional Skip\n\n```typescript\ntest('webkit-specific feature', async ({page, browserName}) => {\n  test.skip(browserName !== 'webkit', 'This feature only works in WebKit')\n\n  await page.goto('/webkit-feature')\n})\n\ntest('production only', async ({page}) => {\n  test.skip(process.env.ENV !== 'production', 'Only runs against production')\n\n  await page.goto('/prod-feature')\n})\n```\n\n### Skip by Platform\n\n```typescript\ntest('windows-specific', async ({page}) => {\n  test.skip(process.platform !== 'win32', 'Windows only')\n})\n\ntest('not on CI', async ({page}) => {\n  test.skip(!!process.env.CI, 'Skipped in CI environment')\n})\n```\n\n### Skip Describe Block\n\n```typescript\ntest.describe('Admin features', () => {\n  test.skip(({browserName}) => browserName === 'firefox', 'Firefox admin bug')\n\n  test('admin dashboard', async ({page}) => {\n    // Skipped in Firefox\n  })\n\n  test('admin settings', async ({page}) => {\n    // Skipped in Firefox\n  })\n})\n```\n\n## Fixme & Fail Annotations\n\n### Fixme - Known Issues\n\n```typescript\n// Mark test as needing fix (skips the test)\ntest.fixme('broken after refactor', async ({page}) => {\n  // Test won't run but is tracked\n})\n\n// Conditional fixme\ntest('flaky on CI', async ({page}) => {\n  test.fixme(!!process.env.CI, 'Investigate CI flakiness - ticket #123')\n\n  await page.goto('/flaky-feature')\n})\n```\n\n### Fail - Expected Failures\n\n```typescript\n// Test is expected to fail (runs but expects failure)\ntest('known bug', async ({page}) => {\n  test.fail()\n\n  await page.goto('/buggy-page')\n  // If this passes, the test fails (bug was fixed!)\n  await expect(page.getByText('Working')).toBeVisible()\n})\n\n// Conditional fail\ntest('fails on webkit', async ({page, browserName}) => {\n  test.fail(browserName === 'webkit', 'WebKit rendering bug #456')\n\n  await page.goto('/render-test')\n  await expect(page.getByTestId('element')).toHaveCSS('width', '100px')\n})\n```\n\n### Difference Between Skip, Fixme, Fail\n\n| Annotation     | Runs? | Use Case                         |\n| -------------- | ----- | -------------------------------- |\n| `test.skip()`  | No    | Feature not applicable           |\n| `test.fixme()` | No    | Known bug, needs investigation   |\n| `test.fail()`  | Yes   | Expected to fail, tracking a bug |\n\n## Slow Tests\n\n### Mark Slow Tests\n\n```typescript\n// Triple the default timeout\ntest('large data import', async ({page}) => {\n  test.slow()\n\n  await page.goto('/import')\n  await page.setInputFiles('#file', 'large-file.csv')\n  await page.getByRole('button', {name: 'Import'}).click()\n\n  await expect(page.getByText('Import complete')).toBeVisible()\n})\n\n// Conditional slow\ntest('video processing', async ({page, browserName}) => {\n  test.slow(browserName === 'webkit', 'WebKit video processing is slow')\n\n  await page.goto('/video-editor')\n})\n```\n\n### Custom Timeout\n\n```typescript\ntest('very long operation', async ({page}) => {\n  // Set specific timeout (in milliseconds)\n  test.setTimeout(120000) // 2 minutes\n\n  await page.goto('/long-operation')\n})\n\n// Timeout for describe block\ntest.describe('Integration tests', () => {\n  test.describe.configure({timeout: 60000})\n\n  test('test 1', async ({page}) => {\n    // Has 60 second timeout\n  })\n})\n```\n\n## Test Steps\n\n### Basic Steps\n\n```typescript\ntest('checkout flow', async ({page}) => {\n  await test.step('Add item to cart', async () => {\n    await page.goto('/products')\n    await page.getByRole('button', {name: 'Add to Cart'}).click()\n  })\n\n  await test.step('Go to checkout', async () => {\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n  })\n\n  await test.step('Fill shipping info', async () => {\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByLabel('City').fill('Test City')\n  })\n\n  await test.step('Complete payment', async () => {\n    await page.getByLabel('Card').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay'}).click()\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Nested Steps\n\n```typescript\ntest('user registration', async ({page}) => {\n  await test.step('Fill registration form', async () => {\n    await page.goto('/register')\n\n    await test.step('Personal info', async () => {\n      await page.getByLabel('Name').fill('John Doe')\n      await page.getByLabel('Email').fill('john@example.com')\n    })\n\n    await test.step('Security', async () => {\n      await page.getByLabel('Password').fill('SecurePass123')\n      await page.getByLabel('Confirm Password').fill('SecurePass123')\n    })\n  })\n\n  await test.step('Submit and verify', async () => {\n    await page.getByRole('button', {name: 'Register'}).click()\n    await expect(page.getByText('Welcome')).toBeVisible()\n  })\n})\n```\n\n### Steps with Return Values\n\n```typescript\ntest('verify order', async ({page}) => {\n  const orderId = await test.step('Create order', async () => {\n    await page.goto('/checkout')\n    await page.getByRole('button', {name: 'Place Order'}).click()\n\n    // Return value from step\n    return await page.getByTestId('order-id').textContent()\n  })\n\n  await test.step('Verify order details', async () => {\n    await page.goto(`/orders/${orderId}`)\n    await expect(page.getByText(`Order #${orderId}`)).toBeVisible()\n  })\n})\n```\n\n### Step in Page Object\n\n```typescript\n// pages/checkout.page.ts\nexport class CheckoutPage {\n  async fillShippingInfo(address: string, city: string) {\n    await test.step('Fill shipping information', async () => {\n      await this.page.getByLabel('Address').fill(address)\n      await this.page.getByLabel('City').fill(city)\n    })\n  }\n\n  async completePayment(cardNumber: string) {\n    await test.step('Complete payment', async () => {\n      await this.page.getByLabel('Card').fill(cardNumber)\n      await this.page.getByRole('button', {name: 'Pay'}).click()\n    })\n  }\n}\n```\n\n## Custom Annotations\n\n### Add Annotations\n\n```typescript\ntest('important feature', async ({page}, testInfo) => {\n  // Add custom annotation\n  testInfo.annotations.push({\n    type: 'priority',\n    description: 'high',\n  })\n\n  testInfo.annotations.push({\n    type: 'ticket',\n    description: 'JIRA-123',\n  })\n\n  await page.goto('/feature')\n})\n```\n\n### Annotation Fixture\n\n```typescript\n// fixtures/annotations.fixture.ts\nimport {test as base, TestInfo} from '@playwright/test'\n\ntype AnnotationFixtures = {\n  annotate: {\n    ticket: (id: string) => void\n    priority: (level: 'low' | 'medium' | 'high') => void\n    owner: (name: string) => void\n  }\n}\n\nexport const test = base.extend<AnnotationFixtures>({\n  annotate: async ({}, use, testInfo) => {\n    await use({\n      ticket: (id) => {\n        testInfo.annotations.push({type: 'ticket', description: id})\n      },\n      priority: (level) => {\n        testInfo.annotations.push({type: 'priority', description: level})\n      },\n      owner: (name) => {\n        testInfo.annotations.push({type: 'owner', description: name})\n      },\n    })\n  },\n})\n\n// Usage\ntest('critical feature', async ({page, annotate}) => {\n  annotate.ticket('JIRA-456')\n  annotate.priority('high')\n  annotate.owner('Alice')\n\n  await page.goto('/critical')\n})\n```\n\n### Read Annotations in Reporter\n\n```typescript\n// reporters/annotation-reporter.ts\nimport {Reporter, TestCase, TestResult} from '@playwright/test/reporter'\n\nclass AnnotationReporter implements Reporter {\n  onTestEnd(test: TestCase, result: TestResult) {\n    const ticket = test.annotations.find((a) => a.type === 'ticket')\n    const priority = test.annotations.find((a) => a.type === 'priority')\n\n    if (ticket) {\n      console.log(`Test linked to: ${ticket.description}`)\n    }\n\n    if (priority?.description === 'high' && result.status === 'failed') {\n      console.log(`HIGH PRIORITY FAILURE: ${test.title}`)\n    }\n  }\n}\n\nexport default AnnotationReporter\n```\n\n## Conditional Annotations\n\n### Annotation Helper\n\n```typescript\n// helpers/test-annotations.ts\nimport {test} from '@playwright/test'\n\nexport function skipInCI(reason = 'Skipped in CI') {\n  test.skip(!!process.env.CI, reason)\n}\n\nexport function skipInBrowser(browser: string, reason: string) {\n  test.beforeEach(({browserName}) => {\n    test.skip(browserName === browser, reason)\n  })\n}\n\nexport function onlyInEnv(env: string) {\n  test.skip(process.env.ENV !== env, `Only runs in ${env}`)\n}\n```\n\n```typescript\n// tests/feature.spec.ts\nimport {skipInCI, onlyInEnv} from '../helpers/test-annotations'\n\ntest('local only feature', async ({page}) => {\n  skipInCI('Uses local resources')\n\n  await page.goto('/local-feature')\n})\n\ntest('production check', async ({page}) => {\n  onlyInEnv('production')\n\n  await page.goto('/prod-only')\n})\n```\n\n### Describe-Level Conditions\n\n```typescript\ntest.describe('Mobile features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(!isMobile, 'Mobile only tests')\n  })\n\n  test('touch gestures', async ({page}) => {\n    // Only runs on mobile\n  })\n})\n\ntest.describe('Desktop features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(isMobile, 'Desktop only tests')\n  })\n\n  test('hover interactions', async ({page}) => {\n    // Only runs on desktop\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                | Solution                         |\n| --------------------------- | ---------------------- | -------------------------------- |\n| Skipping without reason     | Hard to track why      | Always provide description       |\n| Too many skipped tests      | Test debt accumulates  | Review and clean up regularly    |\n| Using skip instead of fixme | Loses intent           | Use fixme for bugs, skip for N/A |\n| Not using steps             | Hard to debug failures | Group logical actions in steps   |\n\n## Related References\n\n- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n","isInternal":false,"tokens":2574,"sizeBytes":10631},{"name":"assertions-waiting.md","path":".agents/skills/playwright-best-practices/core/assertions-waiting.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/assertions-waiting.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Assertions & Waiting\n\n## Table of Contents\n\n1. [Web-First Assertions](#web-first-assertions)\n2. [Generic Assertions](#generic-assertions)\n3. [Soft Assertions](#soft-assertions)\n4. [Waiting Strategies](#waiting-strategies)\n5. [Polling & Retrying](#polling--retrying)\n6. [Custom Matchers](#custom-matchers)\n\n## Web-First Assertions\n\nAuto-retry until condition is met or timeout. Always prefer these over generic assertions.\n\n### Locator Assertions\n\n```typescript\nimport {expect} from '@playwright/test'\n\n// Visibility\nawait expect(page.getByRole('button')).toBeVisible()\nawait expect(page.getByRole('button')).toBeHidden()\nawait expect(page.getByRole('button')).not.toBeVisible()\n\n// Enabled/Disabled\nawait expect(page.getByRole('button')).toBeEnabled()\nawait expect(page.getByRole('button')).toBeDisabled()\n\n// Text content\nawait expect(page.getByRole('heading')).toHaveText('Welcome')\nawait expect(page.getByRole('heading')).toHaveText(/welcome/i)\nawait expect(page.getByRole('heading')).toContainText('Welcome')\n\n// Count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Attributes\nawait expect(page.getByRole('link')).toHaveAttribute('href', '/home')\nawait expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)\n\n// CSS\nawait expect(page.getByRole('button')).toHaveClass(/primary/)\nawait expect(page.getByRole('button')).toHaveCSS('color', 'rgb(0, 0, 255)')\n\n// Input values\nawait expect(page.getByLabel('Email')).toHaveValue('user@example.com')\nawait expect(page.getByLabel('Email')).toBeEmpty()\n\n// Focus\nawait expect(page.getByLabel('Email')).toBeFocused()\n\n// Checked state\nawait expect(page.getByRole('checkbox')).toBeChecked()\nawait expect(page.getByRole('checkbox')).not.toBeChecked()\n\n// Editable state\nawait expect(page.getByLabel('Name')).toBeEditable()\n```\n\n### Page Assertions\n\n```typescript\n// URL\nawait expect(page).toHaveURL('/dashboard')\nawait expect(page).toHaveURL(/\\/dashboard/)\n\n// Title\nawait expect(page).toHaveTitle('Dashboard - MyApp')\nawait expect(page).toHaveTitle(/dashboard/i)\n```\n\n### Response Assertions\n\n```typescript\nconst response = await page.request.get('/api/users')\nawait expect(response).toBeOK()\nawait expect(response).not.toBeOK()\n```\n\n## Generic Assertions\n\nUse for non-UI values. Do NOT retry - execute immediately.\n\n```typescript\n// Equality\nexpect(value).toBe(5)\nexpect(object).toEqual({name: 'Test'})\nexpect(array).toContain('item')\n\n// Truthiness\nexpect(value).toBeTruthy()\nexpect(value).toBeFalsy()\nexpect(value).toBeNull()\nexpect(value).toBeUndefined()\nexpect(value).toBeDefined()\n\n// Numbers\nexpect(value).toBeGreaterThan(5)\nexpect(value).toBeLessThanOrEqual(10)\nexpect(value).toBeCloseTo(5.5, 1)\n\n// Strings\nexpect(string).toMatch(/pattern/)\nexpect(string).toContain('substring')\n\n// Arrays/Objects\nexpect(array).toHaveLength(3)\nexpect(object).toHaveProperty('key', 'value')\n\n// Exceptions\nexpect(() => fn()).toThrow()\nexpect(() => fn()).toThrow('error message')\nawait expect(asyncFn()).rejects.toThrow()\n```\n\n## Soft Assertions\n\nContinue test execution after failure, report all failures at end.\n\n```typescript\ntest('check multiple elements', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Won't stop on first failure\n  await expect.soft(page.getByRole('heading')).toHaveText('Dashboard')\n  await expect.soft(page.getByRole('button', {name: 'Save'})).toBeEnabled()\n  await expect.soft(page.getByText('Welcome')).toBeVisible()\n\n  // Test continues; all failures reported at end\n})\n```\n\n### Soft Assertions with Early Exit\n\n```typescript\ntest('check form', async ({page}) => {\n  await expect.soft(page.getByRole('form')).toBeVisible()\n\n  // Exit early if form not visible (pointless to check fields)\n  if (expect.soft.hasFailures()) {\n    return\n  }\n\n  await expect.soft(page.getByLabel('Name')).toBeVisible()\n  await expect.soft(page.getByLabel('Email')).toBeVisible()\n})\n```\n\n## Waiting Strategies\n\n### Auto-Waiting (Default)\n\nActions automatically wait for:\n\n- Element to be attached to DOM\n- Element to be visible\n- Element to be stable (no animations)\n- Element to be enabled\n- Element to receive events\n\n```typescript\n// These auto-wait\nawait page.click('button')\nawait page.fill('input', 'text')\nawait page.getByRole('button').click()\n```\n\n### Wait for Navigation\n\n```typescript\n// Wait for URL change\nawait page.waitForURL('/dashboard')\nawait page.waitForURL(/\\/dashboard/)\n\n// Wait for navigation after action\nawait Promise.all([page.waitForURL('**/dashboard'), page.click('a[href=\"/dashboard\"]')])\n\n// Or without Promise.all\nconst urlPromise = page.waitForURL('**/dashboard')\nawait page.click('a')\nawait urlPromise\n```\n\n### Wait for Network\n\n```typescript\n// Wait for specific response\nconst responsePromise = page.waitForResponse('**/api/users')\nawait page.click('button')\nconst response = await responsePromise\nexpect(response.status()).toBe(200)\n\n// Wait for request\nconst requestPromise = page.waitForRequest('**/api/submit')\nawait page.click('button')\nconst request = await requestPromise\n\n// Wait for no network activity\nawait page.waitForLoadState('networkidle')\n```\n\n### Wait for Element State\n\n```typescript\n// Wait for element to appear\nawait page.getByRole('dialog').waitFor({state: 'visible'})\n\n// Wait for element to disappear\nawait page.getByText('Loading...').waitFor({state: 'hidden'})\n\n// Wait for element to be attached\nawait page.getByTestId('result').waitFor({state: 'attached'})\n\n// Wait for element to be detached\nawait page.getByTestId('modal').waitFor({state: 'detached'})\n```\n\n### Wait for Function\n\n```typescript\n// Wait for arbitrary condition\nawait page.waitForFunction(() => {\n  return document.querySelector('.loaded') !== null\n})\n\n// With arguments\nawait page.waitForFunction(\n  (selector) => document.querySelector(selector)?.textContent === 'Ready',\n  '.status',\n)\n```\n\n## Polling & Retrying\n\n### toPass() for Polling\n\nRetry until block passes or times out:\n\n```typescript\nawait expect(async () => {\n  const response = await page.request.get('/api/status')\n  expect(response.status()).toBe(200)\n\n  const data = await response.json()\n  expect(data.ready).toBe(true)\n}).toPass({\n  intervals: [1000, 2000, 5000], // Retry intervals\n  timeout: 30000,\n})\n```\n\n### expect.poll()\n\nPoll a function until assertion passes:\n\n```typescript\n// Poll API until condition met\nawait expect\n  .poll(\n    async () => {\n      const response = await page.request.get('/api/job/123')\n      return (await response.json()).status\n    },\n    {\n      intervals: [1000, 2000, 5000],\n      timeout: 30000,\n    },\n  )\n  .toBe('completed')\n\n// Poll DOM value\nawait expect.poll(() => page.getByTestId('counter').textContent()).toBe('10')\n```\n\n## Custom Matchers\n\n```typescript\n// playwright.config.ts or fixtures\nimport {expect} from '@playwright/test'\n\nexpect.extend({\n  async toHaveDataLoaded(page: Page) {\n    const locator = page.getByTestId('data-container')\n    let pass = false\n    let message = ''\n\n    try {\n      await expect(locator).toBeVisible()\n      await expect(locator).not.toContainText('Loading')\n      pass = true\n    } catch (e) {\n      message = `Expected data to be loaded but found loading state`\n    }\n\n    return {pass, message: () => message}\n  },\n})\n\n// Extend TypeScript types\ndeclare global {\n  namespace PlaywrightTest {\n    interface Matchers<R> {\n      toHaveDataLoaded(): Promise<R>\n    }\n  }\n}\n\n// Usage\nawait expect(page).toHaveDataLoaded()\n```\n\n## Timeouts\n\n### Configure Timeouts\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  timeout: 30000, // Test timeout\n  expect: {\n    timeout: 5000, // Assertion timeout\n  },\n})\n\n// Per-test timeout\ntest('long test', async ({page}) => {\n  test.setTimeout(60000)\n  // ...\n})\n\n// Per-assertion timeout\nawait expect(page.getByRole('button')).toBeVisible({timeout: 10000})\n```\n\n## Best Practices\n\n| Do                             | Don't                          |\n| ------------------------------ | ------------------------------ |\n| Use web-first assertions       | Use generic assertions for DOM |\n| Let auto-waiting work          | Add unnecessary explicit waits |\n| Use `toPass()` for polling     | Write manual retry loops       |\n| Configure appropriate timeouts | Use `waitForTimeout()`         |\n| Check specific conditions      | Wait for arbitrary time        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                                              | Problem                       | Solution                                     |\n| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |\n| `await page.waitForTimeout(5000)`                         | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse`        |\n| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above                 | Use `waitForResponse` or element state waits |\n| Generic assertions on DOM elements                        | No auto-retry, flaky          | Use web-first assertions with `expect()`     |\n\n## Related References\n\n- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions\n- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n","isInternal":false,"tokens":2110,"sizeBytes":9322},{"name":"configuration.md","path":".agents/skills/playwright-best-practices/core/configuration.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/configuration.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Playwright Configuration\n\n## Table of Contents\n\n1. [CLI Quick Reference](#cli-quick-reference)\n2. [Decision Guide](#decision-guide)\n3. [Production-Ready Config](#production-ready-config)\n4. [Patterns](#patterns)\n5. [Anti-Patterns](#anti-patterns)\n6. [Troubleshooting](#troubleshooting)\n7. [Related](#related)\n\n> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.\n\n## CLI Quick Reference\n\n```bash\nnpx playwright init                           # scaffold config + first test\nnpx playwright test --config=custom.config.ts # use alternate config\nnpx playwright test --project=chromium        # run single project\nnpx playwright test --reporter=html           # override reporter\nnpx playwright test --grep @smoke             # run tests tagged @smoke\nnpx playwright test --grep-invert @slow       # exclude @slow tests\nnpx playwright show-report                    # open last HTML report\nDEBUG=pw:api npx playwright test              # verbose logging\n```\n\n## Decision Guide\n\n### Timeout Selection\n\n| Symptom                                   | Setting             | Default       | Recommended       |\n| ----------------------------------------- | ------------------- | ------------- | ----------------- |\n| Test takes too long overall               | `timeout`           | 30s           | 30-60s (max 120s) |\n| Assertion retries too long/short          | `expect.timeout`    | 5s            | 5-10s             |\n| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s           | 10-30s            |\n| `click()`, `fill()` time out              | `actionTimeout`     | 0 (unlimited) | 10-15s            |\n| Dev server slow to start                  | `webServer.timeout` | 60s           | 60-180s           |\n\n### Server Management\n\n| Scenario                     | Approach                                                |\n| ---------------------------- | ------------------------------------------------------- |\n| App in same repo             | `webServer` with `reuseExistingServer: !process.env.CI` |\n| Separate repos               | Manual start or Docker Compose                          |\n| Testing deployed environment | No `webServer`; set `baseURL` via env                   |\n| Multiple services            | Array of `webServer` entries                            |\n\n### Single vs Multi-Project\n\n| Scenario               | Approach                                   |\n| ---------------------- | ------------------------------------------ |\n| Early development      | Single project (chromium only)             |\n| Pre-release validation | Multi-project: chromium + firefox + webkit |\n| Mobile-responsive app  | Add mobile projects alongside desktop      |\n| Auth + non-auth tests  | Setup project with dependencies            |\n| Tight CI budget        | Chromium on PRs; all browsers on main      |\n\n### globalSetup vs Setup Projects vs Fixtures\n\n| Need                    | Use                                |\n| ----------------------- | ---------------------------------- |\n| One-time DB seed        | `globalSetup`                      |\n| Shared browser auth     | Setup project with `dependencies`  |\n| Per-test isolated state | Custom fixture via `test.extend()` |\n| Cleanup after all tests | `globalTeardown`                   |\n\n## Production-Ready Config\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\nimport dotenv from 'dotenv'\n\ndotenv.config({path: path.resolve(__dirname, '.env')})\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n\n  timeout: 30_000,\n  expect: {timeout: 5_000},\n\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:4000',\n    actionTimeout: 10_000,\n    navigationTimeout: 15_000,\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'retain-on-failure',\n    locale: 'en-US',\n    timezoneId: 'America/Los_Angeles',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'webkit', use: {...devices['Desktop Safari']}},\n    {name: 'mobile-chrome', use: {...devices['Pixel 7']}},\n    {name: 'mobile-safari', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: 'npm run start',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    stdout: 'pipe',\n    stderr: 'pipe',\n  },\n})\n```\n\n## Patterns\n\n### Environment-Specific Configuration\n\n**Use when**: Tests run against dev, staging, and production environments.\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\nconst ENV = process.env.TEST_ENV || 'local'\ndotenv.config({path: path.resolve(__dirname, `.env.${ENV}`)})\n\nconst envConfig: Record<string, {baseURL: string; retries: number}> = {\n  local: {baseURL: 'http://localhost:4000', retries: 0},\n  staging: {baseURL: 'https://staging.myapp.com', retries: 2},\n  prod: {baseURL: 'https://myapp.com', retries: 2},\n}\n\nexport default defineConfig({\n  testDir: './e2e',\n  retries: envConfig[ENV].retries,\n  use: {baseURL: envConfig[ENV].baseURL},\n})\n```\n\n```bash\nTEST_ENV=staging npx playwright test\nTEST_ENV=prod npx playwright test --grep @smoke\n```\n\n### Setup Project with Dependencies\n\n**Use when**: Tests need shared authentication state before running.\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /auth\\.setup\\.ts/,\n    },\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```ts\n// e2e/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/session.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n### webServer with Build Step\n\n**Use when**: Tests need a running application server managed by Playwright.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {baseURL: 'http://localhost:4000'},\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run preview' : 'npm run dev',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: 'test',\n      DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',\n    },\n  },\n})\n```\n\n### globalSetup / globalTeardown\n\n**Use when**: One-time non-browser work like seeding a database. Runs once per test run.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  globalSetup: './e2e/setup.ts',\n  globalTeardown: './e2e/teardown.ts',\n})\n```\n\n```ts\n// e2e/setup.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalSetup(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  process.env.TEST_RUN_ID = `run-${Date.now()}`\n}\n```\n\n```ts\n// e2e/teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalTeardown(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db push --force-reset', {stdio: 'inherit'})\n}\n```\n\n### Environment Variables with .env\n\n**Use when**: Managing secrets, URLs, or feature flags without hardcoding.\n\n```bash\n# .env.example (commit this)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=\nAPI_KEY=\n\n# .env.local (gitignored)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=secret123\nAPI_KEY=dev-key-abc\n\n# .env.staging (gitignored)\nBASE_URL=https://staging.myapp.com\nTEST_PASSWORD=staging-pass\nAPI_KEY=staging-key-xyz\n```\n\n```bash\n# .gitignore\n.env\n.env.local\n.env.staging\n.env.production\nplaywright/.auth/\n```\n\nInstall dotenv:\n\n```bash\nnpm install -D dotenv\n```\n\n### Tag-Based Test Filtering\n\n**Use when**: Running subsets of tests in different CI stages (PR vs nightly).\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n\n  // Filter by tags in CI\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n**Project-specific filtering:**\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n```bash\n# Run specific project\nnpx playwright test --project=smoke\nnpx playwright test --project=regression\n```\n\n### Artifact Collection Strategy\n\n| Setting      | Local   | CI                    | Reason                                    |\n| ------------ | ------- | --------------------- | ----------------------------------------- |\n| `trace`      | `'off'` | `'on-first-retry'`    | Traces are large; collect on failure only |\n| `screenshot` | `'off'` | `'only-on-failure'`   | Useful for CI debugging                   |\n| `video`      | `'off'` | `'retain-on-failure'` | Recording slows tests                     |\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n  },\n})\n```\n\n## Anti-Patterns\n\n| Don't                                                            | Problem                                | Do Instead                          |\n| ---------------------------------------------------------------- | -------------------------------------- | ----------------------------------- |\n| `timeout: 300_000` globally                                      | Masks flaky tests; slow CI             | Fix root cause; keep 30s default    |\n| Hardcoded URLs: `page.goto('http://localhost:4000/login')`       | Breaks in other environments           | Use `baseURL` + relative paths      |\n| All browsers on every PR                                         | 3x CI time                             | Chromium on PRs; all on main        |\n| `trace: 'on'` always                                             | Huge artifacts, slow uploads           | `trace: 'on-first-retry'`           |\n| `video: 'on'` always                                             | Massive storage; slow tests            | `video: 'retain-on-failure'`        |\n| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent                | Define once in project config       |\n| `retries: 3` locally                                             | Hides flakiness                        | `retries: 0` local, `retries: 2` CI |\n| No `forbidOnly` in CI                                            | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI`      |\n| `globalSetup` for browser auth                                   | No browser context available           | Use setup project with dependencies |\n| Committing `.env` with credentials                               | Security risk                          | Commit `.env.example` only          |\n\n## Troubleshooting\n\n### baseURL Not Working\n\n**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.\n\n```ts\n// Wrong - ignores baseURL\nawait page.goto('http://localhost:4000/dashboard')\n\n// Correct - uses baseURL\nawait page.goto('/dashboard')\n```\n\n### webServer Starts But Tests Get Connection Refused\n\n**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.\n\n```ts\nwebServer: {\n  command: 'npm run dev',\n  url: 'http://localhost:4000/api/health',  // use real endpoint\n  reuseExistingServer: !process.env.CI,\n  timeout: 120_000,\n},\n```\n\n### Tests Pass Locally But Timeout in CI\n\n**Cause**: CI machines are slower. Increase timeouts and reduce workers:\n\n```ts\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n  },\n})\n```\n\n### \"Target page, context or browser has been closed\"\n\n**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.\n\n**Fix**: Don't increase global timeout. Find slow step using trace:\n\n```bash\nnpx playwright test --trace on\nnpx playwright show-report\n```\n\n## Related\n\n- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`\n- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state\n- [test-suite-structure.md](test-suite-structure.md) - file structure and naming\n- [authentication.md](../advanced/authentication.md) - setup projects for shared auth\n- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns\n","isInternal":false,"tokens":3404,"sizeBytes":14180},{"name":"fixtures-hooks.md","path":".agents/skills/playwright-best-practices/core/fixtures-hooks.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/fixtures-hooks.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Fixtures & Hooks\n\n## Table of Contents\n\n1. [Built-in Fixtures](#built-in-fixtures)\n2. [Custom Fixtures](#custom-fixtures)\n3. [Fixture Scopes](#fixture-scopes)\n4. [Hooks](#hooks)\n5. [Authentication Patterns](#authentication-patterns)\n6. [Database Fixtures](#database-fixtures)\n\n## Built-in Fixtures\n\n### Core Fixtures\n\n```typescript\ntest('example', async ({\n  page, // Isolated page instance\n  context, // Browser context (cookies, localStorage)\n  browser, // Browser instance\n  browserName, // 'chromium', 'firefox', or 'webkit'\n  request, // API request context\n}) => {\n  // Each test gets fresh instances\n})\n```\n\n### Request Fixture\n\n```typescript\ntest('API call', async ({request}) => {\n  const response = await request.get('/api/users')\n  await expect(response).toBeOK()\n\n  const users = await response.json()\n  expect(users).toHaveLength(5)\n})\n```\n\n## Custom Fixtures\n\n### Basic Custom Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base} from '@playwright/test'\n\n// Declare fixture types\ntype MyFixtures = {\n  todoPage: TodoPage\n  apiClient: ApiClient\n}\n\nexport const test = base.extend<MyFixtures>({\n  // Fixture with setup and teardown\n  todoPage: async ({page}, use) => {\n    const todoPage = new TodoPage(page)\n    await todoPage.goto()\n\n    await use(todoPage) // Test runs here\n\n    // Teardown (optional)\n    await todoPage.clearTodos()\n  },\n\n  // Simple fixture\n  apiClient: async ({request}, use) => {\n    await use(new ApiClient(request))\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Fixture with Options\n\n```typescript\ntype Options = {\n  defaultUser: {email: string; password: string}\n}\n\ntype Fixtures = {\n  authenticatedPage: Page\n}\n\nexport const test = base.extend<Options & Fixtures>({\n  // Define option with default\n  defaultUser: [{email: 'test@example.com', password: 'pass123'}, {option: true}],\n\n  // Use option in fixture\n  authenticatedPage: async ({page, defaultUser}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(defaultUser.email)\n    await page.getByLabel('Password').fill(defaultUser.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await use(page)\n  },\n})\n\n// Override in config\nexport default defineConfig({\n  use: {\n    defaultUser: {email: 'admin@example.com', password: 'admin123'},\n  },\n})\n```\n\n### Automatic Fixtures\n\n```typescript\nexport const test = base.extend<{}, {setupDb: void}>({\n  // Auto-fixture runs for every test without explicit usage\n  setupDb: [\n    async ({}, use) => {\n      await seedDatabase()\n      await use()\n      await cleanDatabase()\n    },\n    {auto: true},\n  ],\n})\n```\n\n## Fixture Scopes\n\n### Test Scope (Default)\n\nCreated fresh for each test:\n\n```typescript\ntest.extend({\n  page: async ({browser}, use) => {\n    const page = await browser.newPage()\n    await use(page)\n    await page.close()\n  },\n})\n```\n\n### Worker Scope\n\nShared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):\n\n```typescript\ntype WorkerFixtures = {\n  sharedAccount: Account\n}\n\nexport const test = base.extend<{}, WorkerFixtures>({\n  sharedAccount: [\n    async ({browser}, use) => {\n      // Expensive setup - runs once per worker\n      const account = await createTestAccount()\n      await use(account)\n      await deleteTestAccount(account)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n### Isolate test data between parallel workers\n\nWhen tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:\n\n```typescript\nimport {test as baseTest} from '@playwright/test'\n\ntype WorkerFixtures = {\n  dbUserName: string\n}\n\nexport const test = baseTest.extend<{}, WorkerFixtures>({\n  dbUserName: [\n    async ({}, use, testInfo) => {\n      const userName = `user-${testInfo.workerIndex}`\n      await createUserInTestDatabase(userName)\n      await use(userName)\n      await deleteUserFromTestDatabase(userName)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\nThen each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.\n\n## Hooks\n\n### beforeEach / afterEach\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Runs before each test in file\n  await page.goto('/')\n})\n\ntest.afterEach(async ({page}, testInfo) => {\n  // Runs after each test\n  if (testInfo.status !== 'passed') {\n    await page.screenshot({path: `failed-${testInfo.title}.png`})\n  }\n})\n```\n\n### beforeAll / afterAll\n\n```typescript\ntest.beforeAll(async ({browser}) => {\n  // Runs once before all tests in file\n  // Note: Cannot use page fixture here\n})\n\ntest.afterAll(async () => {\n  // Runs once after all tests in file\n})\n```\n\n### Describe-Level Hooks\n\n```typescript\ntest.describe('User Management', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/users')\n  })\n\n  test('can list users', async ({page}) => {\n    // Starts at /users\n  })\n\n  test('can add user', async ({page}) => {\n    // Starts at /users\n  })\n})\n```\n\n## Authentication Patterns\n\n### Global Setup with Storage State\n\n```typescript\n// auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = '.auth/user.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\n// auth.setup.ts\nsetup('admin auth', async ({page}) => {\n  await login(page, 'admin@example.com', 'adminpass')\n  await page.context().storageState({path: '.auth/admin.json'})\n})\n\nsetup('user auth', async ({page}) => {\n  await login(page, 'user@example.com', 'userpass')\n  await page.context().storageState({path: '.auth/user.json'})\n})\n```\n\n```typescript\n// playwright.config.ts\nprojects: [\n  {\n    name: 'admin tests',\n    testMatch: /.*admin.*\\.spec\\.ts/,\n    use: {storageState: '.auth/admin.json'},\n    dependencies: ['setup'],\n  },\n  {\n    name: 'user tests',\n    testMatch: /.*user.*\\.spec\\.ts/,\n    use: {storageState: '.auth/user.json'},\n    dependencies: ['setup'],\n  },\n]\n```\n\n### Auth Fixture\n\n```typescript\n// fixtures/auth.fixture.ts\nexport const test = base.extend<{adminPage: Page; userPage: Page}>({\n  adminPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/admin.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n\n  userPage: async ({browser}, use) => {\n    const context = await browser.newContext({\n      storageState: '.auth/user.json',\n    })\n    const page = await context.newPage()\n    await use(page)\n    await context.close()\n  },\n})\n```\n\n## Database Fixtures\n\nThis section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:\n\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n### Transaction Rollback Pattern\n\n```typescript\nimport {test as base} from '@playwright/test'\n\nimport {db} from '../db'\n\nexport const test = base.extend<{dbTransaction: Transaction}>({\n  dbTransaction: async ({}, use) => {\n    const transaction = await db.beginTransaction()\n\n    await use(transaction)\n\n    await transaction.rollback() // Clean slate for next test\n  },\n})\n```\n\n### Seed Data Fixture\n\n```typescript\ntype TestData = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<TestData>({\n  testUser: async ({}, use) => {\n    const user = await db.users.create({\n      email: `test-${Date.now()}@example.com`,\n      name: 'Test User',\n    })\n\n    await use(user)\n\n    await db.users.delete(user.id)\n  },\n\n  testProducts: async ({testUser}, use) => {\n    const products = await db.products.createMany([\n      {name: 'Product A', ownerId: testUser.id},\n      {name: 'Product B', ownerId: testUser.id},\n    ])\n\n    await use(products)\n\n    await db.products.deleteMany(products.map((p) => p.id))\n  },\n})\n```\n\n## Fixture Tips\n\n| Tip                | Explanation                                 |\n| ------------------ | ------------------------------------------- |\n| Fixtures are lazy  | Only created when used                      |\n| Compose fixtures   | Use other fixtures as dependencies          |\n| Keep setup minimal | Do heavy lifting in worker-scoped fixtures  |\n| Clean up resources | Use teardown in fixtures, not afterEach     |\n| Avoid shared state | Each fixture instance should be independent |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                                                    | Solution                                                                                                                                                                              |\n| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Shared mutable state between tests        | Race conditions, order dependencies                        | Use fixtures for isolation                                                                                                                                                            |\n| Global variables in tests                 | Tests depend on execution order                            | Use fixtures or beforeEach for setup                                                                                                                                                  |\n| Not cleaning up test data                 | Tests interfere with each other                            | Use fixtures with teardown or database transactions                                                                                                                                   |\n| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |\n| Backend/DB state shared across workers    | Tests in different workers collide on same data            | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker                                                                                                |\n\n## Related References\n\n- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure\n- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n","isInternal":false,"tokens":2524,"sizeBytes":11595},{"name":"global-setup.md","path":".agents/skills/playwright-best-practices/core/global-setup.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/global-setup.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Global Setup & Teardown\n\n## Table of Contents\n\n1. [Global Setup](#global-setup)\n2. [Global Teardown](#global-teardown)\n3. [Database Patterns](#database-patterns)\n4. [Environment Provisioning](#environment-provisioning)\n5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)\n6. [Parallel Execution Caveats](#parallel-execution-caveats)\n\n## Global Setup\n\n### Basic Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  console.log('Running global setup...')\n  // Perform one-time setup: start services, run migrations, etc.\n}\n\nexport default globalSetup\n```\n\n### Configure Global Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n})\n```\n\n> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.\n\n### Global Setup with Return Value\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig): Promise<() => Promise<void>> {\n  const server = await startTestServer()\n\n  // Return cleanup function (alternative to globalTeardown)\n  return async () => {\n    await server.stop()\n  }\n}\n\nexport default globalSetup\n```\n\n### Access Config in Global Setup\n\n```typescript\n// global-setup.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  console.log(`Setting up for ${baseURL}`)\n\n  // Access custom config\n  const workers = config.workers\n  const timeout = config.timeout\n\n  // Access environment\n  const isCI = !!process.env.CI\n}\n\nexport default globalSetup\n```\n\n## Global Teardown\n\n### Basic Global Teardown\n\n```typescript\nimport fs from 'fs'\n\n// global-teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nasync function globalTeardown(config: FullConfig) {\n  console.log('Running global teardown...')\n\n  // Clean up auth files\n  if (fs.existsSync('.auth')) {\n    fs.rmSync('.auth', {recursive: true})\n  }\n\n  // Clean up test data\n  await cleanupTestDatabase()\n\n  // Stop services\n  await stopTestServices()\n}\n\nexport default globalTeardown\n```\n\n### Conditional Teardown\n\n```typescript\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  // Skip cleanup in CI (containers are discarded anyway)\n  if (process.env.CI) {\n    console.log('Skipping teardown in CI')\n    return\n  }\n\n  // Local cleanup\n  await cleanupLocalTestData()\n}\n\nexport default globalTeardown\n```\n\n## Database Patterns\n\nThis section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)\n\n### Database Migration in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Running database migrations...')\n\n  // Run migrations\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n\n  // Seed test data\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Database Snapshot Pattern\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\nimport fs from 'fs'\n\nconst SNAPSHOT_PATH = './test-db-snapshot.sql'\n\nasync function globalSetup() {\n  // Check if snapshot exists\n  if (fs.existsSync(SNAPSHOT_PATH)) {\n    console.log('Restoring database from snapshot...')\n    execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n    return\n  }\n\n  // First run: migrate and create snapshot\n  console.log('Creating database snapshot...')\n  execSync('npx prisma migrate deploy', {stdio: 'inherit'})\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, {stdio: 'inherit'})\n}\n\nexport default globalSetup\n```\n\n### Test Database per Worker\n\n```typescript\n// global-setup.ts\nasync function globalSetup(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  // Create a database for each worker\n  for (let i = 0; i < workerCount; i++) {\n    const dbName = `test_db_worker_${i}`\n    await createDatabase(dbName)\n    await runMigrations(dbName)\n    await seedDatabase(dbName)\n  }\n}\n\n// global-teardown.ts\nasync function globalTeardown(config: FullConfig) {\n  const workerCount = config.workers || 1\n\n  for (let i = 0; i < workerCount; i++) {\n    await dropDatabase(`test_db_worker_${i}`)\n  }\n}\n```\n\n## Environment Provisioning\n\n### Start Services in Setup\n\n```typescript\n// global-setup.ts\nimport {execSync, spawn} from 'child_process'\n\nlet serverProcess: any\n\nasync function globalSetup() {\n  // Start backend server\n  serverProcess = spawn('npm', ['run', 'start:test'], {\n    stdio: 'pipe',\n    detached: true,\n  })\n\n  // Wait for server to be ready\n  await waitForServer('http://localhost:3000/health', 30000)\n\n  // Store PID for teardown\n  process.env.SERVER_PID = serverProcess.pid.toString()\n}\n\nasync function waitForServer(url: string, timeout: number) {\n  const start = Date.now()\n\n  while (Date.now() - start < timeout) {\n    try {\n      const response = await fetch(url)\n      if (response.ok) return\n    } catch {\n      // Server not ready yet\n    }\n    await new Promise((r) => setTimeout(r, 1000))\n  }\n\n  throw new Error(`Server did not start within ${timeout}ms`)\n}\n\nexport default globalSetup\n```\n\n### Docker Compose Setup\n\n```typescript\n// global-setup.ts\nimport {execSync} from 'child_process'\n\nasync function globalSetup() {\n  console.log('Starting Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml up -d', {\n    stdio: 'inherit',\n  })\n\n  // Wait for services to be healthy\n  execSync('docker-compose -f docker-compose.test.yml exec -T db pg_isready', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalSetup\n```\n\n```typescript\n// global-teardown.ts\nimport {execSync} from 'child_process'\n\nasync function globalTeardown() {\n  console.log('Stopping Docker services...')\n\n  execSync('docker-compose -f docker-compose.test.yml down -v', {\n    stdio: 'inherit',\n  })\n}\n\nexport default globalTeardown\n```\n\n### Environment Variables Setup\n\n```typescript\nimport path from 'path'\n\n// global-setup.ts\nimport dotenv from 'dotenv'\n\nasync function globalSetup() {\n  // Load test-specific environment\n  const envFile = process.env.CI ? '.env.ci' : '.env.test'\n  dotenv.config({path: path.resolve(process.cwd(), envFile)})\n\n  // Validate required variables\n  const required = ['DATABASE_URL', 'API_KEY', 'TEST_EMAIL']\n  for (const key of required) {\n    if (!process.env[key]) {\n      throw new Error(`Missing required environment variable: ${key}`)\n    }\n  }\n}\n\nexport default globalSetup\n```\n\n## Setup Projects vs Global Setup\n\n### When to Use Each\n\n| Use Global Setup                      | Use Setup Projects                       |\n| ------------------------------------- | ---------------------------------------- |\n| One-time setup (migrations, services) | Per-project setup (auth states)          |\n| No access to Playwright fixtures      | Need page, request fixtures              |\n| Runs once before all projects         | Can run per-project or have dependencies |\n| Shared across all workers             | Can be parallelized                      |\n\n### Setup Project Pattern\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    // Setup project\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n    // Test projects depend on setup\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Combining Both\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Global: Start services, run migrations\n  globalSetup: require.resolve('./global-setup'),\n  globalTeardown: require.resolve('./global-teardown'),\n\n  projects: [\n    // Setup project: Create auth states\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n## Parallel Execution Caveats\n\n### Understanding Global Setup Execution\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  globalSetup runs ONCE                                      │\n│  ↓                                                          │\n│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │\n│  │ Worker 1│  │ Worker 2│  │ Worker 3│  │ Worker 4│        │\n│  │ tests   │  │ tests   │  │ tests   │  │ tests   │        │\n│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │\n│  ↓                                                          │\n│  globalTeardown runs ONCE                                   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Key implications:**\n\n- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)\n- State created in global setup is **shared** across all workers\n- If tests **modify** shared state, they may conflict with parallel workers\n- Global setup **cannot** react to individual test needs\n\n### When to Prefer Worker-Scoped Fixtures\n\nUse **worker-scoped fixtures** instead of globalSetup when:\n\n| Scenario                             | Why Fixtures Are Better                              |\n| ------------------------------------ | ---------------------------------------------------- |\n| Each worker needs isolated resources | Fixtures can create per-worker databases, servers    |\n| Setup needs Playwright APIs          | Fixtures have access to `page`, `request`, `browser` |\n| Setup depends on test configuration  | Fixtures receive test context and options            |\n| Resources need cleanup per worker    | Worker fixtures auto-cleanup when worker exits       |\n\n### Common Parallel Pitfall\n\n```typescript\n// ❌ BAD: Global setup creates ONE user, all workers fight over it\nasync function globalSetup() {\n  await createUser({email: 'test@example.com'}) // Shared!\n}\n\n// ✅ GOOD: Each worker gets its own user via worker-scoped fixture\n// Uses workerInfo.workerIndex to create unique data per worker\n```\n\n> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                          | Solution                                   |\n| ------------------------------ | -------------------------------- | ------------------------------------------ |\n| Heavy setup in globalSetup     | Slow test startup                | Use setup projects for parallelizable work |\n| Not cleaning up in teardown    | Leaks resources, flaky CI        | Always clean up or use containers          |\n| Hardcoded URLs in setup        | Breaks in different environments | Use config.projects[0].use.baseURL         |\n| No timeout on service wait     | Hangs forever if service fails   | Add timeout with clear error               |\n| Shared mutable state           | Race conditions in parallel      | Use worker-scoped fixtures for isolation   |\n| Global setup for per-test data | Tests conflict                   | Use test-scoped fixtures                   |\n\n## Related References\n\n- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration\n","isInternal":false,"tokens":2797,"sizeBytes":12763},{"name":"locators.md","path":".agents/skills/playwright-best-practices/core/locators.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/locators.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Locator Strategies\n\n## Table of Contents\n\n1. [Priority Order](#priority-order)\n2. [User-Facing Locators](#user-facing-locators)\n3. [Filtering & Chaining](#filtering--chaining)\n4. [Dynamic Content](#dynamic-content)\n5. [Shadow DOM](#shadow-dom)\n6. [Iframes](#iframes)\n\n## Priority Order\n\nUse locators in this order of preference:\n\n1. **Role-based** (most resilient): `getByRole`\n2. **Label-based**: `getByLabel`, `getByPlaceholder`\n3. **Text-based**: `getByText`, `getByTitle`\n4. **Test IDs** (when semantic locators aren't possible): `getByTestId`\n5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`\n\n## User-Facing Locators\n\n### getByRole\n\nMost robust approach - matches how users and assistive technology perceive the page.\n\n```typescript\n// Buttons\npage.getByRole('button', {name: 'Submit', exact: true}) // exact accessible name\npage.getByRole('button', {name: /submit/i}) // flexible case-insensitive match\n\n// Links\npage.getByRole('link', {name: 'Home'})\n\n// Form elements\npage.getByRole('textbox', {name: 'Email'})\npage.getByRole('checkbox', {name: 'Remember me'})\npage.getByRole('combobox', {name: 'Country'})\npage.getByRole('radio', {name: 'Option A'})\n\n// Headings\npage.getByRole('heading', {name: 'Welcome', level: 1})\n\n// Lists & items\npage.getByRole('list').getByRole('listitem')\n\n// Navigation & regions\npage.getByRole('navigation')\npage.getByRole('main')\npage.getByRole('dialog')\npage.getByRole('alert')\n```\n\n### getByLabel\n\nFor form elements with associated labels.\n\n```typescript\n// Input with <label for=\"email\">\npage.getByLabel('Email address')\n\n// Input with aria-label\npage.getByLabel('Search')\n\n// Exact match\npage.getByLabel('Email', {exact: true})\n```\n\n### getByPlaceholder\n\n```typescript\npage.getByPlaceholder('Enter your email')\npage.getByPlaceholder(/email/i)\n```\n\n### getByText\n\n```typescript\n// Partial match (default)\npage.getByText('Welcome')\n\n// Exact match\npage.getByText('Welcome to our site', {exact: true})\n\n// Regex\npage.getByText(/welcome/i)\n```\n\n### getByTestId\n\nConfigure custom test ID attribute in `playwright.config.ts`:\n\n```typescript\nuse: {\n  testIdAttribute: 'data-testid' // default\n}\n```\n\nUsage:\n\n```typescript\n// HTML: <button data-testid=\"submit-btn\">Submit</button>\npage.getByTestId('submit-btn')\n```\n\n## Filtering & Chaining\n\n### filter()\n\nNarrow down locators:\n\n```typescript\n// Filter by text\npage.getByRole('listitem').filter({hasText: 'Product'})\n\n// Filter by NOT having text\npage.getByRole('listitem').filter({hasNotText: 'Out of stock'})\n\n// Filter by child locator\npage.getByRole('listitem').filter({\n  has: page.getByRole('button', {name: 'Buy'}),\n})\n\n// Combine filters\npage\n  .getByRole('listitem')\n  .filter({hasText: 'Product'})\n  .filter({has: page.getByText('$9.99')})\n```\n\n### Chaining\n\n```typescript\n// Navigate down the DOM tree\npage.getByRole('article').getByRole('heading')\n\n// Get parent/ancestor\npage.getByText('Child').locator('..')\npage.getByText('Child').locator('xpath=ancestor::article')\n```\n\n### nth() and first()/last()\n\n```typescript\npage.getByRole('listitem').first()\npage.getByRole('listitem').last()\npage.getByRole('listitem').nth(2) // 0-indexed\n```\n\n## Dynamic Content\n\n### Waiting for Elements\n\nLocators auto-wait for actionability by default. For explicit state waiting:\n\n```typescript\nawait page.getByRole('button').waitFor({state: 'visible'})\nawait page.getByText('Loading').waitFor({state: 'hidden'})\n```\n\n> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n### Lists with Dynamic Items\n\n```typescript\n// Wait for specific count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Get all matching elements\nconst items = await page.getByRole('listitem').all()\nfor (const item of items) {\n  await expect(item).toBeVisible()\n}\n```\n\n## Shadow DOM\n\nPlaywright pierces shadow DOM by default:\n\n```typescript\n// Automatically finds elements inside shadow roots\npage.getByRole('button', {name: 'Shadow Button'})\n\n// Explicit shadow DOM traversal (if needed)\npage.locator('my-component').locator('internal:shadow=button')\n```\n\n## Iframes\n\n```typescript\n// By frame name or URL\nconst frame = page.frameLocator('iframe[name=\"content\"]')\nawait frame.getByRole('button').click()\n\n// By index\nconst frame = page.frameLocator('iframe').first()\n\n// Nested iframes\nconst nestedFrame = page.frameLocator('#outer').frameLocator('#inner')\nawait nestedFrame.getByText('Content').click()\n```\n\n## Debugging Locators\n\n```typescript\n// Highlight element in headed mode\nawait page.getByRole('button').highlight()\n\n// Count matches\nconst count = await page.getByRole('listitem').count()\n\n// Check if exists without waiting\nconst exists = (await page.getByRole('button').count()) > 0\n\n// Use Playwright Inspector\n// PWDEBUG=1 npx playwright test\n```\n\n## Common Issues & Solutions\n\n| Issue                   | Solution                                         |\n| ----------------------- | ------------------------------------------------ |\n| Multiple elements match | Add filters or use `nth()`, `first()`, `last()`  |\n| Element not found       | Check visibility, wait for load, verify selector |\n| Stale element           | Locators are lazy; re-query if DOM changes       |\n| Dynamic IDs             | Use stable attributes like role, text, test-id   |\n| Hidden elements         | Use `{ force: true }` only when necessary        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                      | Problem                           | Solution                                          |\n| --------------------------------- | --------------------------------- | ------------------------------------------------- |\n| `page.locator('.btn-primary')`    | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })`    |\n| `page.locator('#dynamic-id-123')` | Breaks when IDs change            | Use stable attributes like role, text, or test-id |\n| Testing implementation details    | Breaks on refactoring             | Test user-visible behavior                        |\n\n## Related References\n\n- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies\n- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators\n","isInternal":false,"tokens":1540,"sizeBytes":6444},{"name":"page-object-model.md","path":".agents/skills/playwright-best-practices/core/page-object-model.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/page-object-model.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Page Object Model (POM)\n\n## Table of Contents\n\n1. [Overview](#overview)\n2. [Basic Structure](#basic-structure)\n3. [Component Objects](#component-objects)\n4. [Composition Patterns](#composition-patterns)\n5. [Factory Functions](#factory-functions)\n6. [Best Practices](#best-practices)\n\n## Overview\n\nPage Object Model encapsulates page structure and interactions, providing:\n\n- **Maintainability**: Change selectors in one place\n- **Reusability**: Share page interactions across tests\n- **Readability**: Tests express intent, not implementation\n\n## Basic Structure\n\n### Page Class\n\n```typescript\n// pages/login.page.ts\nimport {Page, Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly emailInput: Locator\n  readonly passwordInput: Locator\n  readonly submitButton: Locator\n  readonly errorMessage: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.emailInput = page.getByLabel('Email')\n    this.passwordInput = page.getByLabel('Password')\n    this.submitButton = page.getByRole('button', {name: 'Sign in'})\n    this.errorMessage = page.getByRole('alert')\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n  }\n\n  async login(email: string, password: string) {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n  }\n\n  async expectError(message: string) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n}\n```\n\n### Usage in Tests\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../pages/login.page'\n\ntest.describe('Login', () => {\n  test('successful login redirects to dashboard', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('user@example.com', 'password123')\n    await expect(page).toHaveURL('/dashboard')\n  })\n\n  test('shows error for invalid credentials', async ({page}) => {\n    const loginPage = new LoginPage(page)\n    await loginPage.goto()\n    await loginPage.login('invalid@example.com', 'wrong')\n    await loginPage.expectError('Invalid credentials')\n  })\n})\n```\n\n## Component Objects\n\nFor reusable UI components:\n\n```typescript\n// components/navbar.component.ts\nimport {Page, Locator} from '@playwright/test'\n\nexport class NavbarComponent {\n  readonly container: Locator\n  readonly logo: Locator\n  readonly searchInput: Locator\n  readonly userMenu: Locator\n\n  constructor(page: Page) {\n    this.container = page.getByRole('navigation')\n    this.logo = this.container.getByRole('link', {name: 'Home'})\n    this.searchInput = this.container.getByRole('searchbox')\n    this.userMenu = this.container.getByRole('button', {name: /user menu/i})\n  }\n\n  async search(query: string) {\n    await this.searchInput.fill(query)\n    await this.searchInput.press('Enter')\n  }\n\n  async openUserMenu() {\n    await this.userMenu.click()\n  }\n}\n```\n\n```typescript\n// components/modal.component.ts\nimport {Locator, expect} from '@playwright/test'\n\nexport class ModalComponent {\n  readonly container: Locator\n  readonly title: Locator\n  readonly closeButton: Locator\n  readonly confirmButton: Locator\n\n  constructor(container: Locator) {\n    this.container = container\n    this.title = container.getByRole('heading')\n    this.closeButton = container.getByRole('button', {name: 'Close'})\n    this.confirmButton = container.getByRole('button', {name: 'Confirm'})\n  }\n\n  async expectTitle(title: string) {\n    await expect(this.title).toHaveText(title)\n  }\n\n  async close() {\n    await this.closeButton.click()\n  }\n\n  async confirm() {\n    await this.confirmButton.click()\n  }\n}\n```\n\n## Composition Patterns\n\n### Page with Components\n\n```typescript\n// pages/dashboard.page.ts\nimport {Page, Locator} from '@playwright/test'\n\nimport {ModalComponent} from '../components/modal.component'\nimport {NavbarComponent} from '../components/navbar.component'\n\nexport class DashboardPage {\n  readonly page: Page\n  readonly navbar: NavbarComponent\n  readonly newProjectButton: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.navbar = new NavbarComponent(page)\n    this.newProjectButton = page.getByRole('button', {name: 'New Project'})\n  }\n\n  async goto() {\n    await this.page.goto('/dashboard')\n  }\n\n  async createProject() {\n    await this.newProjectButton.click()\n    return new ModalComponent(this.page.getByRole('dialog'))\n  }\n}\n```\n\n### Page Navigation\n\n```typescript\n// pages/base.page.ts\nimport {Page} from '@playwright/test'\n\nexport abstract class BasePage {\n  constructor(readonly page: Page) {}\n\n  abstract goto(): Promise<void>\n\n  async getTitle(): Promise<string> {\n    return this.page.title()\n  }\n}\n```\n\n```typescript\n// Return new page object on navigation\nexport class LoginPage extends BasePage {\n  async login(email: string, password: string): Promise<DashboardPage> {\n    await this.emailInput.fill(email)\n    await this.passwordInput.fill(password)\n    await this.submitButton.click()\n    return new DashboardPage(this.page)\n  }\n}\n\n// Usage\nconst loginPage = new LoginPage(page)\nawait loginPage.goto()\nconst dashboardPage = await loginPage.login('user@example.com', 'pass')\nawait dashboardPage.expectWelcomeMessage()\n```\n\n## Factory Functions\n\nAlternative to classes for simpler pages:\n\n```typescript\n// pages/login.page.ts\nimport {Page} from '@playwright/test'\n\nexport function createLoginPage(page: Page) {\n  const emailInput = page.getByLabel('Email')\n  const passwordInput = page.getByLabel('Password')\n  const submitButton = page.getByRole('button', {name: 'Sign in'})\n\n  return {\n    goto: () => page.goto('/login'),\n    login: async (email: string, password: string) => {\n      await emailInput.fill(email)\n      await passwordInput.fill(password)\n      await submitButton.click()\n    },\n    emailInput,\n    passwordInput,\n    submitButton,\n  }\n}\n\n// Usage\nconst loginPage = createLoginPage(page)\nawait loginPage.goto()\nawait loginPage.login('user@example.com', 'password')\n```\n\n## Best Practices\n\n### Do\n\n- **Keep locators in page objects** - Single source of truth\n- **Return new page objects** when navigation occurs\n- **Expose elements** for custom assertions in tests\n- **Use descriptive method names** - `submitOrder()` not `clickButton()`\n- **Keep methods focused** - One action per method\n\n### Don't\n\n- **Don't include assertions in page methods** (usually) - Keep in tests\n- **Don't expose implementation details** - Hide complex interactions\n- **Don't make page objects too large** - Split into components\n- **Don't share state** between page object instances\n\n### Directory Structure\n\n```\ntests/\n├── pages/\n│   ├── base.page.ts\n│   ├── login.page.ts\n│   ├── dashboard.page.ts\n│   └── settings.page.ts\n├── components/\n│   ├── navbar.component.ts\n│   ├── modal.component.ts\n│   └── table.component.ts\n├── fixtures/\n│   └── pages.fixture.ts\n└── specs/\n    ├── login.spec.ts\n    └── dashboard.spec.ts\n```\n\n### Using with Fixtures\n\n```typescript\n// fixtures/pages.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {DashboardPage} from '../pages/dashboard.page'\nimport {LoginPage} from '../pages/login.page'\n\ntype Pages = {\n  loginPage: LoginPage\n  dashboardPage: DashboardPage\n}\n\nexport const test = base.extend<Pages>({\n  loginPage: async ({page}, use) => {\n    await use(new LoginPage(page))\n  },\n  dashboardPage: async ({page}, use) => {\n    await use(new DashboardPage(page))\n  },\n})\n\n// Usage in tests\ntest('can login', async ({loginPage}) => {\n  await loginPage.goto()\n  await loginPage.login('user@example.com', 'password')\n})\n```\n\n## Related References\n\n- **Locator strategies**: See [locators.md](locators.md) for selecting elements\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns\n- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites\n","isInternal":false,"tokens":1815,"sizeBytes":7964},{"name":"projects-dependencies.md","path":".agents/skills/playwright-best-practices/core/projects-dependencies.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/projects-dependencies.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Projects & Dependencies\n\n## Table of Contents\n\n1. [Project Configuration](#project-configuration)\n2. [Project Dependencies](#project-dependencies)\n3. [Setup Projects](#setup-projects)\n4. [Filtering & Running Projects](#filtering--running-projects)\n5. [Sharing Configuration](#sharing-configuration)\n6. [Advanced Patterns](#advanced-patterns)\n\n## Project Configuration\n\n### Basic Multi-Browser Setup\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'firefox',\n      use: {...devices['Desktop Firefox']},\n    },\n    {\n      name: 'webkit',\n      use: {...devices['Desktop Safari']},\n    },\n  ],\n})\n```\n\n### Environment-Based Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'staging',\n      use: {\n        baseURL: 'https://staging.example.com',\n      },\n    },\n    {\n      name: 'production',\n      use: {\n        baseURL: 'https://example.com',\n      },\n    },\n    {\n      name: 'local',\n      use: {\n        baseURL: 'http://localhost:3000',\n      },\n    },\n  ],\n})\n```\n\n### Test Type Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'e2e',\n      testDir: './tests/e2e',\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'api',\n      testDir: './tests/api',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'visual',\n      testDir: './tests/visual',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n    },\n  ],\n})\n```\n\n## Project Dependencies\n\n### Setup Dependency\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Setup project runs first\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n    },\n\n    // Browser projects depend on setup\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n### Multiple Auth States\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Auth setup projects\n    {\n      name: 'setup-admin',\n      testMatch: /admin\\.setup\\.ts/,\n    },\n    {\n      name: 'setup-user',\n      testMatch: /user\\.setup\\.ts/,\n    },\n\n    // Admin tests\n    {\n      name: 'admin-tests',\n      testDir: './tests/admin',\n      use: {storageState: '.auth/admin.json'},\n      dependencies: ['setup-admin'],\n    },\n\n    // User tests\n    {\n      name: 'user-tests',\n      testDir: './tests/user',\n      use: {storageState: '.auth/user.json'},\n      dependencies: ['setup-user'],\n    },\n\n    // Tests that need both\n    {\n      name: 'integration-tests',\n      testDir: './tests/integration',\n      dependencies: ['setup-admin', 'setup-user'],\n    },\n  ],\n})\n```\n\n### Chained Dependencies\n\n```typescript\nexport default defineConfig({\n  projects: [\n    // Step 1: Database setup\n    {\n      name: 'db-setup',\n      testMatch: /db\\.setup\\.ts/,\n    },\n\n    // Step 2: Auth setup (needs DB)\n    {\n      name: 'auth-setup',\n      testMatch: /auth\\.setup\\.ts/,\n      dependencies: ['db-setup'],\n    },\n\n    // Step 3: Seed data (needs auth)\n    {\n      name: 'seed-setup',\n      testMatch: /seed\\.setup\\.ts/,\n      dependencies: ['auth-setup'],\n    },\n\n    // Tests (need everything)\n    {\n      name: 'tests',\n      testDir: './tests',\n      dependencies: ['seed-setup'],\n    },\n  ],\n})\n```\n\n## Setup Projects\n\n### Authentication Setup\n\nSetup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.\n\n> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).\n\n### Data Seeding Setup\n\n```typescript\n// seed.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('seed test data', async ({request}) => {\n  // Create test data via API\n  await request.post('/api/test/seed', {\n    data: {\n      users: 10,\n      products: 50,\n      orders: 100,\n    },\n  })\n})\n```\n\n### Cleanup Setup\n\n```typescript\n// cleanup.setup.ts\nimport {test as setup} from '@playwright/test'\n\nsetup('cleanup previous run', async ({request}) => {\n  // Clean up data from previous test runs\n  await request.delete('/api/test/cleanup')\n})\n```\n\n## Filtering & Running Projects\n\n### Run Specific Project\n\n```bash\n# Run single project\nnpx playwright test --project=chromium\n\n# Run multiple projects\nnpx playwright test --project=chromium --project=firefox\n```\n\n### Run by Grep\n\n```bash\n# Run tests matching pattern\nnpx playwright test --grep @smoke\n\n# Run project with grep\nnpx playwright test --project=chromium --grep @critical\n\n# Exclude pattern\nnpx playwright test --grep-invert @slow\n```\n\n### Project-Specific Grep\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n## Sharing Configuration\n\n### Base Configuration\n\n```typescript\n// playwright.config.ts\nconst baseConfig = {\n  timeout: 30000,\n  expect: {timeout: 5000},\n  use: {\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n}\n\nexport default defineConfig({\n  ...baseConfig,\n  projects: [\n    {\n      name: 'chromium',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...baseConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n### Shared Project Settings\n\n```typescript\nconst sharedBrowserConfig = {\n  timeout: 60000,\n  retries: 2,\n  use: {\n    video: 'on-first-retry',\n    trace: 'on-first-retry',\n  },\n}\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Chrome'],\n      },\n    },\n    {\n      name: 'firefox',\n      ...sharedBrowserConfig,\n      use: {\n        ...sharedBrowserConfig.use,\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n})\n```\n\n## Advanced Patterns\n\n### Conditional Projects\n\n```typescript\nconst projects = [\n  {\n    name: 'chromium',\n    use: {...devices['Desktop Chrome']},\n  },\n]\n\n// Add Firefox only in CI\nif (process.env.CI) {\n  projects.push({\n    name: 'firefox',\n    use: {...devices['Desktop Firefox']},\n  })\n}\n\n// Add mobile only for specific test dirs\nif (process.env.TEST_MOBILE) {\n  projects.push({\n    name: 'mobile',\n    use: {...devices['iPhone 14']},\n  })\n}\n\nexport default defineConfig({projects})\n```\n\n### Project Metadata\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      metadata: {\n        platform: 'desktop',\n        browser: 'chromium',\n        priority: 'high',\n      },\n    },\n  ],\n})\n\n// Access in test\ntest('example', async ({page}, testInfo) => {\n  const {platform, priority} = testInfo.project.metadata\n  console.log(`Running on ${platform} with ${priority} priority`)\n})\n```\n\n### Teardown Projects\n\n```typescript\nexport default defineConfig({\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /.*\\.setup\\.ts/,\n      teardown: 'teardown', // Run teardown after this completes\n    },\n    {\n      name: 'teardown',\n      testMatch: /.*\\.teardown\\.ts/,\n    },\n    {\n      name: 'tests',\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```typescript\n// cleanup.teardown.ts\nimport {test as teardown} from '@playwright/test'\n\nteardown('cleanup', async ({request}) => {\n  await request.delete('/api/test/data')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                | Solution                            |\n| -------------------------- | ---------------------- | ----------------------------------- |\n| Too many browser projects  | Slow CI, expensive     | Focus on critical browsers          |\n| Missing setup dependencies | Tests fail randomly    | Declare all dependencies explicitly |\n| Duplicated configuration   | Hard to maintain       | Extract shared config               |\n| Not using setup projects   | Repeated auth in tests | Use setup project + storageState    |\n\n## Related References\n\n- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns\n- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI\n","isInternal":false,"tokens":2156,"sizeBytes":8869},{"name":"test-data.md","path":".agents/skills/playwright-best-practices/core/test-data.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/test-data.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Test Data Factories & Generators\n\nThis file covers **reusable test data builders** (factories, Faker, data generators). For related topics:\n\n- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)\n- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)\n\n## Table of Contents\n\n1. [Factory Pattern](#factory-pattern)\n2. [Faker Integration](#faker-integration)\n3. [Data-Driven Testing](#data-driven-testing)\n4. [Test Data Fixtures](#test-data-fixtures)\n5. [Database Seeding](#database-seeding)\n\n## Factory Pattern\n\n### Basic Factory\n\n```typescript\n// factories/user.factory.ts\ninterface User {\n  id: string\n  email: string\n  name: string\n  role: 'admin' | 'user' | 'guest'\n  createdAt: Date\n}\n\nlet userIdCounter = 0\n\nexport function createUser(overrides: Partial<User> = {}): User {\n  userIdCounter++\n  return {\n    id: `user-${userIdCounter}`,\n    email: `user${userIdCounter}@test.com`,\n    name: `Test User ${userIdCounter}`,\n    role: 'user',\n    createdAt: new Date(),\n    ...overrides,\n  }\n}\n\n// Usage\nconst user = createUser()\nconst admin = createUser({role: 'admin', name: 'Admin User'})\n```\n\n### Factory with Traits\n\n```typescript\n// factories/product.factory.ts\ninterface Product {\n  id: string\n  name: string\n  price: number\n  stock: number\n  category: string\n  featured: boolean\n}\n\ntype ProductTrait = 'outOfStock' | 'featured' | 'expensive' | 'sale'\n\nconst traits: Record<ProductTrait, Partial<Product>> = {\n  outOfStock: {stock: 0},\n  featured: {featured: true},\n  expensive: {price: 999.99},\n  sale: {price: 9.99},\n}\n\nlet productIdCounter = 0\n\nexport function createProduct(\n  overrides: Partial<Product> = {},\n  ...traitNames: ProductTrait[]\n): Product {\n  productIdCounter++\n\n  const appliedTraits = traitNames.reduce((acc, trait) => ({...acc, ...traits[trait]}), {})\n\n  return {\n    id: `prod-${productIdCounter}`,\n    name: `Product ${productIdCounter}`,\n    price: 29.99,\n    stock: 100,\n    category: 'General',\n    featured: false,\n    ...appliedTraits,\n    ...overrides,\n  }\n}\n\n// Usage\nconst product = createProduct()\nconst featuredProduct = createProduct({}, 'featured')\nconst saleItem = createProduct({name: 'Sale Item'}, 'sale', 'featured')\nconst soldOut = createProduct({}, 'outOfStock')\n```\n\n### Factory with Relationships\n\n```typescript\nimport {createProduct, Product} from './product.factory'\n// factories/order.factory.ts\nimport {createUser, User} from './user.factory'\n\ninterface OrderItem {\n  product: Product\n  quantity: number\n}\n\ninterface Order {\n  id: string\n  user: User\n  items: OrderItem[]\n  total: number\n  status: 'pending' | 'paid' | 'shipped' | 'delivered'\n}\n\nlet orderIdCounter = 0\n\nexport function createOrder(overrides: Partial<Order> = {}): Order {\n  orderIdCounter++\n\n  const user = overrides.user ?? createUser()\n  const items = overrides.items ?? [{product: createProduct(), quantity: 1}]\n  const total = items.reduce((sum, item) => sum + item.product.price * item.quantity, 0)\n\n  return {\n    id: `order-${orderIdCounter}`,\n    user,\n    items,\n    total,\n    status: 'pending',\n    ...overrides,\n  }\n}\n\n// Usage\nconst order = createOrder()\nconst bigOrder = createOrder({\n  items: [\n    {product: createProduct({price: 100}), quantity: 5},\n    {product: createProduct({price: 50}), quantity: 2},\n  ],\n})\n```\n\n## Faker Integration\n\n### Setup Faker\n\n```bash\nnpm install -D @faker-js/faker\n```\n\n```typescript\n// factories/faker-user.factory.ts\nimport {faker} from '@faker-js/faker'\n\ninterface User {\n  id: string\n  email: string\n  name: string\n  avatar: string\n  address: {\n    street: string\n    city: string\n    country: string\n    zipCode: string\n  }\n}\n\nexport function createFakeUser(overrides: Partial<User> = {}): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    avatar: faker.image.avatar(),\n    address: {\n      street: faker.location.streetAddress(),\n      city: faker.location.city(),\n      country: faker.location.country(),\n      zipCode: faker.location.zipCode(),\n    },\n    ...overrides,\n  }\n}\n```\n\n### Seeded Faker for Reproducibility\n\n```typescript\nimport {faker} from '@faker-js/faker'\n\n// Set seed for reproducible data\nfaker.seed(12345)\n\nexport function createDeterministicUser(): User {\n  return {\n    id: faker.string.uuid(),\n    email: faker.internet.email(),\n    name: faker.person.fullName(),\n    // Same seed = same data every time\n  }\n}\n\n// Or seed per test\ntest('user profile', async ({page}) => {\n  faker.seed(42) // Reset seed for this test\n  const user = createFakeUser()\n  // user will always have the same data\n})\n```\n\n### Faker Fixture\n\n```typescript\nimport {faker} from '@faker-js/faker'\n// fixtures/faker.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype FakerFixtures = {\n  fake: typeof faker\n}\n\nexport const test = base.extend<FakerFixtures>({\n  fake: async ({}, use, testInfo) => {\n    // Seed based on test name for reproducibility\n    faker.seed(testInfo.title.length)\n    await use(faker)\n  },\n})\n\n// Usage\ntest('create user with fake data', async ({page, fake}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Name').fill(fake.person.fullName())\n  await page.getByLabel('Email').fill(fake.internet.email())\n  await page.getByLabel('Password').fill(fake.internet.password())\n\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n})\n```\n\n## Data-Driven Testing\n\n### test.each with Arrays\n\n```typescript\nconst loginScenarios = [\n  {email: 'user@example.com', password: 'pass123', expected: 'Dashboard'},\n  {email: 'admin@example.com', password: 'admin123', expected: 'Admin Panel'},\n  {\n    email: 'invalid@example.com',\n    password: 'wrong',\n    expected: 'Invalid credentials',\n  },\n]\n\nfor (const {email, password, expected} of loginScenarios) {\n  test(`login with ${email}`, async ({page}) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Sign In'}).click()\n\n    await expect(page.getByText(expected)).toBeVisible()\n  })\n}\n```\n\n### Parameterized Tests\n\n```typescript\n// data/checkout-scenarios.ts\nexport const checkoutScenarios = [\n  {\n    name: 'standard shipping',\n    shipping: 'standard',\n    expectedDays: '5-7 business days',\n    expectedCost: '$5.99',\n  },\n  {\n    name: 'express shipping',\n    shipping: 'express',\n    expectedDays: '2-3 business days',\n    expectedCost: '$14.99',\n  },\n  {\n    name: 'overnight shipping',\n    shipping: 'overnight',\n    expectedDays: 'Next business day',\n    expectedCost: '$29.99',\n  },\n]\n```\n\n```typescript\nimport {checkoutScenarios} from './data/checkout-scenarios'\n\ntest.describe('shipping options', () => {\n  for (const scenario of checkoutScenarios) {\n    test(`checkout with ${scenario.name}`, async ({page}) => {\n      await page.goto('/checkout')\n\n      await page.getByLabel(scenario.shipping, {exact: false}).check()\n\n      await expect(page.getByText(scenario.expectedDays)).toBeVisible()\n      await expect(page.getByText(scenario.expectedCost)).toBeVisible()\n    })\n  }\n})\n```\n\n### CSV/JSON Data Source\n\n```typescript\nimport fs from 'fs'\n\ninterface TestCase {\n  input: string\n  expected: string\n}\n\n// Load test data from JSON\nconst testCases: TestCase[] = JSON.parse(fs.readFileSync('./data/search-tests.json', 'utf-8'))\n\ntest.describe('search functionality', () => {\n  for (const {input, expected} of testCases) {\n    test(`search for \"${input}\"`, async ({page}) => {\n      await page.goto('/search')\n      await page.getByLabel('Search').fill(input)\n      await page.getByLabel('Search').press('Enter')\n\n      await expect(page.getByText(expected)).toBeVisible()\n    })\n  }\n})\n```\n\n## Test Data Fixtures\n\n### Fixture with Factory\n\n```typescript\n// fixtures/data.fixture.ts\nimport {test as base} from '@playwright/test'\n\nimport {createProduct, Product} from '../factories/product.factory'\nimport {createUser, User} from '../factories/user.factory'\n\ntype DataFixtures = {\n  testUser: User\n  testProducts: Product[]\n}\n\nexport const test = base.extend<DataFixtures>({\n  testUser: async ({}, use) => {\n    const user = createUser({name: 'E2E Test User'})\n    await use(user)\n  },\n\n  testProducts: async ({}, use) => {\n    const products = [\n      createProduct({name: 'Test Product 1'}),\n      createProduct({name: 'Test Product 2'}),\n      createProduct({name: 'Test Product 3'}),\n    ]\n    await use(products)\n  },\n})\n\n// Usage\ntest('add product to cart', async ({page, testUser, testProducts}) => {\n  // Mock API with test data\n  await page.route('**/api/user', (route) => route.fulfill({json: testUser}))\n  await page.route('**/api/products', (route) => route.fulfill({json: testProducts}))\n\n  await page.goto('/products')\n  await expect(page.getByText(testProducts[0].name)).toBeVisible()\n})\n```\n\n## Database Seeding\n\n### API-Based Seeding\n\n```typescript\n// fixtures/seed.fixture.ts\nimport {test as base, APIRequestContext} from '@playwright/test'\n\nimport {createUser} from '../factories/user.factory'\n\ntype SeedFixtures = {\n  seedUser: (overrides?: Partial<User>) => Promise<User>\n  cleanupUsers: string[]\n}\n\nexport const test = base.extend<SeedFixtures>({\n  cleanupUsers: [],\n\n  seedUser: async ({request, cleanupUsers}, use) => {\n    await use(async (overrides = {}) => {\n      const userData = createUser(overrides)\n\n      const response = await request.post('/api/test/users', {\n        data: userData,\n      })\n      const user = await response.json()\n\n      cleanupUsers.push(user.id)\n      return user\n    })\n  },\n\n  // Cleanup after test\n  cleanupUsers: async ({request}, use) => {\n    const userIds: string[] = []\n    await use(userIds)\n\n    // Delete all created users\n    for (const id of userIds) {\n      await request.delete(`/api/test/users/${id}`)\n    }\n  },\n})\n\n// Usage\ntest('user profile page', async ({page, seedUser}) => {\n  const user = await seedUser({name: 'John Doe'})\n\n  await page.goto(`/users/${user.id}`)\n  await expect(page.getByText('John Doe')).toBeVisible()\n})\n```\n\n### Transaction Rollback Seeding\n\n```typescript\n// fixtures/db.fixture.ts\nexport const test = base.extend<{}, {db: DbTransaction}>({\n  db: [\n    async ({}, use) => {\n      const client = await pool.connect()\n      await client.query('BEGIN')\n\n      await use({\n        query: (sql: string, params?: any[]) => client.query(sql, params),\n        seed: async (table: string, data: object) => {\n          const keys = Object.keys(data)\n          const values = Object.values(data)\n          const placeholders = keys.map((_, i) => `$${i + 1}`)\n\n          const result = await client.query(\n            `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`,\n            values,\n          )\n          return result.rows[0]\n        },\n      })\n\n      await client.query('ROLLBACK')\n      client.release()\n    },\n    {scope: 'test'},\n  ],\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                    | Problem                         | Solution                   |\n| ------------------------------- | ------------------------------- | -------------------------- |\n| Hardcoded test data             | Brittle, repetitive             | Use factories              |\n| Random data without seed        | Non-reproducible failures       | Seed faker per test        |\n| Shared mutable test data        | Tests interfere with each other | Create fresh data per test |\n| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories    |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns\n- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n","isInternal":false,"tokens":2895,"sizeBytes":11803},{"name":"test-suite-structure.md","path":".agents/skills/playwright-best-practices/core/test-suite-structure.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/test-suite-structure.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Test Suite Structure\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [E2E Tests](#e2e-tests)\n3. [Component Tests](#component-tests)\n4. [API Tests](#api-tests)\n5. [Visual Regression Tests](#visual-regression-tests)\n6. [Directory Structure](#directory-structure)\n7. [Tagging & Filtering](#tagging--filtering)\n\n### Project Setup\n\n```bash\nnpm init playwright@latest\n```\n\n## Configuration\n\n### Essential Configuration\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: [['html'], ['list']],\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n  projects: [\n    {name: 'setup', testMatch: /.*\\.setup\\.ts/},\n    {\n      name: 'chromium',\n      use: {...devices['Desktop Chrome']},\n      dependencies: ['setup'],\n    },\n  ],\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n})\n```\n\n## E2E Tests\n\nFull user journey tests through the browser.\n\n### Structure\n\n```typescript\n// tests/e2e/checkout.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Checkout Flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/products')\n  })\n\n  test('complete purchase as guest', async ({page}) => {\n    // Add to cart\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await expect(page.getByTestId('cart-count')).toHaveText('1')\n\n    // Go to checkout\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n\n    // Fill shipping\n    await page.getByLabel('Email').fill('guest@example.com')\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByRole('button', {name: 'Continue'}).click()\n\n    // Payment\n    await page.getByLabel('Card Number').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay Now'}).click()\n\n    // Confirmation\n    await expect(page.getByRole('heading')).toHaveText('Order Confirmed')\n  })\n\n  test('apply discount code', async ({page}) => {\n    await page.getByRole('button', {name: 'Add to Cart'}).first().click()\n    await page.getByRole('link', {name: 'Cart'}).click()\n\n    await page.getByLabel('Discount Code').fill('SAVE10')\n    await page.getByRole('button', {name: 'Apply'}).click()\n\n    await expect(page.getByText('10% discount applied')).toBeVisible()\n  })\n})\n```\n\n### Best Practices\n\n- Test critical user journeys\n- Keep tests independent\n- Use realistic data\n- Clean up test data in teardown\n\n## Component Tests\n\nTest individual components in isolation using Playwright Component Testing.\n\n```bash\nnpm init playwright@latest -- --ct\n```\n\nFor comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.\n\n## API Tests\n\nTest backend APIs without browser.\n\n### API Mocking Patterns\n\nFor E2E tests that need to mock API responses:\n\n```typescript\n// Mock single endpoint\ntest('displays mocked users', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 200,\n      json: [{id: 1, name: 'Test User'}],\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Test User')).toBeVisible()\n})\n\n// Mock with different responses\ntest('handles API errors', async ({page}) => {\n  await page.route('**/api/users', (route) =>\n    route.fulfill({\n      status: 500,\n      json: {error: 'Server error'},\n    }),\n  )\n\n  await page.goto('/users')\n  await expect(page.getByText('Server error')).toBeVisible()\n})\n\n// Conditional mocking\ntest('mocks based on request', async ({page}) => {\n  await page.route('**/api/users', (route, request) => {\n    if (request.method() === 'GET') {\n      route.fulfill({json: [{id: 1, name: 'User'}]})\n    } else {\n      route.continue()\n    }\n  })\n})\n\n// Mock with delay (simulate slow network)\ntest('handles slow API', async ({page}) => {\n  await page.route('**/api/data', (route) =>\n    route.fulfill({\n      json: {data: 'test'},\n      delay: 2000, // 2 second delay\n    }),\n  )\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('Loading...')).toBeVisible()\n  await expect(page.getByText('test')).toBeVisible()\n})\n```\n\nFor advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.\n\n## Visual Regression Tests\n\nCompare screenshots to detect visual changes.\n\n### Basic Visual Test\n\n```typescript\n// tests/visual/homepage.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('homepage visual', async ({page}) => {\n  await page.goto('/')\n  await expect(page).toHaveScreenshot('homepage.png')\n})\n\ntest('component visual', async ({page}) => {\n  await page.goto('/components')\n\n  const button = page.getByRole('button', {name: 'Primary'})\n  await expect(button).toHaveScreenshot('primary-button.png')\n})\n```\n\n### Visual Test Options\n\n```typescript\ntest('dashboard visual', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page).toHaveScreenshot('dashboard.png', {\n    fullPage: true, // Capture entire scrollable page\n    maxDiffPixels: 100, // Allow up to 100 different pixels\n    maxDiffPixelRatio: 0.01, // Or 1% difference\n    threshold: 0.2, // Pixel comparison threshold\n    animations: 'disabled', // Disable animations\n    mask: [page.getByTestId('date')], // Mask dynamic content\n  })\n})\n```\n\n### Handling Dynamic Content\n\n```typescript\ntest('page with dynamic content', async ({page}) => {\n  await page.goto('/profile')\n\n  // Mask elements that change\n  await expect(page).toHaveScreenshot('profile.png', {\n    mask: [page.getByTestId('timestamp'), page.getByTestId('avatar'), page.getByRole('img')],\n  })\n})\n\n// Or hide elements via CSS\ntest('page hiding dynamic elements', async ({page}) => {\n  await page.goto('/profile')\n\n  await page.addStyleTag({\n    content: `\n      .dynamic-content { visibility: hidden !important; }\n      [data-testid=\"ad-banner\"] { display: none !important; }\n    `,\n  })\n\n  await expect(page).toHaveScreenshot('profile-stable.png')\n})\n```\n\n### Visual Test Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  expect: {\n    toHaveScreenshot: {\n      maxDiffPixels: 50,\n      animations: 'disabled',\n    },\n  },\n  projects: [\n    {\n      name: 'visual-chrome',\n      use: {\n        ...devices['Desktop Chrome'],\n        viewport: {width: 1280, height: 720},\n      },\n      testMatch: /.*visual.*\\.spec\\.ts/,\n    },\n  ],\n})\n```\n\n### Update Snapshots\n\n```bash\n# Update all snapshots\nnpx playwright test --update-snapshots\n\n# Update specific test\nnpx playwright test homepage.spec.ts --update-snapshots\n```\n\n## Directory Structure\n\n```\ntests/\n├── e2e/                    # End-to-end tests\n│   ├── auth.spec.ts\n│   ├── checkout.spec.ts\n│   └── dashboard.spec.ts\n├── component/              # Component tests\n│   ├── Button.spec.tsx\n│   └── Modal.spec.tsx\n├── api/                    # API tests\n│   ├── users.spec.ts\n│   └── products.spec.ts\n├── visual/                 # Visual regression tests\n│   └── homepage.spec.ts\n├── fixtures/               # Custom fixtures\n│   ├── auth.fixture.ts\n│   └── api.fixture.ts\n└── pages/                  # Page objects\n    ├── login.page.ts\n    └── dashboard.page.ts\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                            | Solution                  |\n| ------------------------------------- | ---------------------------------- | ------------------------- |\n| Long test files                       | Hard to maintain, slow to navigate | Split by feature, use POM |\n| Tests depend on execution order       | Flaky, hard to debug               | Keep tests independent    |\n| Testing multiple features in one test | Hard to debug failures             | One feature per test      |\n\n## Related References\n\n- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns\n- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering\n- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions\n- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data\n\n## Tagging & Filtering\n\n### Using Tags\n\n```typescript\ntest('user login @smoke @auth', async ({page}) => {\n  // ...\n})\n\ntest('checkout flow @e2e @critical', async ({page}) => {\n  // ...\n})\n\ntest.describe('API tests @api', () => {\n  test('create user', async ({request}) => {\n    // ...\n  })\n})\n```\n\n### Running Tagged Tests\n\n```bash\n# Run smoke tests\nnpx playwright test --grep @smoke\n\n# Run all except slow tests\nnpx playwright test --grep-invert @slow\n\n# Combine tags\nnpx playwright test --grep \"@smoke|@critical\"\n```\n\nFor project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.\n","isInternal":false,"tokens":2283,"sizeBytes":9395},{"name":"test-tags.md","path":".agents/skills/playwright-best-practices/core/test-tags.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/core/test-tags.md","title":"Core Skill","category":"anthropic-skill","format":"markdown","content":"# Test Tags\n\n## Table of Contents\n\n1. [Basic Tagging](#basic-tagging)\n2. [Tagging Describe Blocks](#tagging-describe-blocks)\n3. [Running Tagged Tests](#running-tagged-tests)\n4. [Filtering by Tags](#filtering-by-tags)\n5. [Configuration-Based Filtering](#configuration-based-filtering)\n6. [Tag Organization Patterns](#tag-organization-patterns)\n7. [Common Tag Categories](#common-tag-categories)\n8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)\n9. [Related References](#related-references)\n\n## Basic Tagging\n\n### Tag via Details Object\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest(\n  'test login page',\n  {\n    tag: '@fast',\n  },\n  async ({page}) => {\n    await page.goto('/login')\n    await expect(page.getByRole('heading')).toBeVisible()\n  },\n)\n\ntest(\n  'test dashboard',\n  {\n    tag: '@slow',\n  },\n  async ({page}) => {\n    await page.goto('/dashboard')\n    await expect(page.getByTestId('charts')).toBeVisible()\n  },\n)\n```\n\n### Tag via Title (not recommended)\n\n```typescript\ntest('test full report @slow', async ({page}) => {\n  await page.goto('/reports/full')\n  await expect(page.getByText('Report loaded')).toBeVisible()\n})\n\ntest('quick validation @fast @smoke', async ({page}) => {\n  await page.goto('/')\n  await expect(page.locator('body')).toBeVisible()\n})\n```\n\n## Tagging Describe Blocks\n\n### Tag All Tests in Group\n\n```typescript\ntest.describe(\n  'report tests',\n  {\n    tag: '@report',\n  },\n  () => {\n    test('test report header', async ({page}) => {\n      // Inherits @report tag\n    })\n\n    test('test report footer', async ({page}) => {\n      // Inherits @report tag\n    })\n  },\n)\n```\n\n### Combine Group and Test Tags\n\n```typescript\ntest.describe(\n  'admin features',\n  {\n    tag: '@admin',\n  },\n  () => {\n    test('admin dashboard', async ({page}) => {\n      // Has @admin tag\n    })\n\n    test(\n      'admin settings',\n      {\n        tag: ['@slow', '@critical'],\n      },\n      async ({page}) => {\n        // Has @admin, @slow, @critical tags\n      },\n    )\n  },\n)\n```\n\n## Running Tagged Tests\n\n### Run Tests with Specific Tag\n\n```bash\n# Run all @fast tests\nnpx playwright test --grep @fast\n```\n\n### Exclude Tests with Tag\n\n```bash\n# Run all tests except @slow\nnpx playwright test --grep-invert @slow\n```\n\n## Filtering by Tags\n\n### Logical OR (Either Tag)\n\n```bash\n# Run tests with @fast OR @smoke\nnpx playwright test --grep \"@fast|@smoke\"\n```\n\n### Logical AND (Both Tags)\n\n```bash\n# Run tests with both @fast AND @critical\nnpx playwright test --grep \"(?=.*@fast)(?=.*@critical)\"\n```\n\n### Complex Patterns\n\n```bash\n# Run @e2e tests that are also @critical\nnpx playwright test --grep \"(?=.*@e2e)(?=.*@critical)\"\n\n# Run @api tests excluding @slow\nnpx playwright test --grep \"@api\" --grep-invert \"@slow\"\n```\n\n## Configuration-Based Filtering\n\n### Filter in playwright.config.ts\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  grep: /@smoke/,\n  grepInvert: /@flaky/,\n})\n```\n\n### Project-Specific Tags\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n    },\n  ],\n})\n```\n\n### Environment-Based Filtering\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nconst isCI = !!process.env.CI\n\nexport default defineConfig({\n  grep: isCI ? /@smoke|@critical/ : undefined,\n  grepInvert: isCI ? /@flaky/ : undefined,\n})\n```\n\n## Tag Organization Patterns\n\n### By Test Type\n\n```typescript\n// Smoke tests - quick validation\ntest('homepage loads', {tag: '@smoke'}, async ({page}) => {})\ntest('login works', {tag: '@smoke'}, async ({page}) => {})\n\n// Regression tests - comprehensive\ntest('full checkout flow', {tag: '@regression'}, async ({page}) => {})\ntest('all payment methods', {tag: '@regression'}, async ({page}) => {})\n\n// E2E tests - user journeys\ntest('complete user journey', {tag: '@e2e'}, async ({page}) => {})\n```\n\n### By Priority\n\n```typescript\ntest(\n  'payment processing',\n  {\n    tag: ['@critical', '@p0'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'user preferences',\n  {\n    tag: ['@p1'],\n  },\n  async ({page}) => {},\n)\n\ntest(\n  'theme customization',\n  {\n    tag: ['@p2'],\n  },\n  async ({page}) => {},\n)\n```\n\n### By Feature Area\n\n```typescript\ntest.describe(\n  'authentication',\n  {\n    tag: '@auth',\n  },\n  () => {\n    test('login @smoke', async ({page}) => {})\n    test('logout', async ({page}) => {})\n    test('password reset @slow', async ({page}) => {})\n  },\n)\n\ntest.describe(\n  'payments',\n  {\n    tag: '@payments',\n  },\n  () => {\n    test('credit card @critical', async ({page}) => {})\n    test('paypal @critical', async ({page}) => {})\n  },\n)\n```\n\n## Common Tag Categories\n\n| Category        | Tags                                          | Purpose                       |\n| --------------- | --------------------------------------------- | ----------------------------- |\n| **Speed**       | `@fast`, `@slow`                              | Execution time classification |\n| **Priority**    | `@critical`, `@p0`, `@p1`, `@p2`              | Business importance           |\n| **Type**        | `@smoke`, `@regression`, `@e2e`               | Test suite categorization     |\n| **Feature**     | `@auth`, `@payments`, `@settings`             | Feature area grouping         |\n| **Pipeline**    | `@pr`, `@nightly`, `@release`                 | CI/CD execution timing        |\n| **Status**      | `@flaky`, `@wip`, `@quarantine`               | Test health tracking          |\n| **Environment** | `@local`, `@staging`, `@prod`                 | Target environment            |\n| **Team**        | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment               |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                  | Solution                                       |\n| ------------------------ | ------------------------ | ---------------------------------------------- |\n| Too many tags per test   | Hard to maintain         | Limit to 2-3 relevant tags                     |\n| Inconsistent naming      | Confusing filtering      | Establish naming conventions                   |\n| Missing `@` prefix       | Tags won't match filters | Always prefix with `@`                         |\n| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics                     |\n| Not using tags           | Can't selectively run    | Tag by type, priority, or feature              |\n| Tags in test title       | Hard to parse/filter     | Use the details object for tags, not the title |\n\n## Related References\n\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow\n- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup\n","isInternal":false,"tokens":1773,"sizeBytes":6989},{"name":"console-errors.md","path":".agents/skills/playwright-best-practices/debugging/console-errors.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/debugging/console-errors.md","title":"Debugging Skill","category":"anthropic-skill","format":"markdown","content":"# Browser Console & JavaScript Error Handling\n\n## Table of Contents\n\n1. [Capturing Console Messages](#capturing-console-messages)\n2. [Failing on Console Errors](#failing-on-console-errors)\n3. [JavaScript Error Detection](#javascript-error-detection)\n4. [Monitoring Warnings](#monitoring-warnings)\n5. [Console Fixtures](#console-fixtures)\n\n## Capturing Console Messages\n\n### Basic Console Capture\n\n```typescript\ntest('capture console logs', async ({page}) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`${msg.type()}: ${msg.text()}`)\n  })\n\n  await page.goto('/')\n\n  // Check what was logged\n  console.log('Captured logs:', logs)\n})\n```\n\n### Capture by Type\n\n```typescript\ntest('capture specific console types', async ({page}) => {\n  const errors: string[] = []\n  const warnings: string[] = []\n  const infos: string[] = []\n\n  page.on('console', (msg) => {\n    switch (msg.type()) {\n      case 'error':\n        errors.push(msg.text())\n        break\n      case 'warning':\n        warnings.push(msg.text())\n        break\n      case 'info':\n      case 'log':\n        infos.push(msg.text())\n        break\n    }\n  })\n\n  await page.goto('/dashboard')\n\n  expect(errors).toHaveLength(0)\n  console.log('Warnings:', warnings)\n})\n```\n\n### Capture with Stack Trace\n\n```typescript\ntest('capture errors with location', async ({page}) => {\n  const errors: {message: string; location?: string}[] = []\n\n  page.on('console', async (msg) => {\n    if (msg.type() === 'error') {\n      const location = msg.location()\n      errors.push({\n        message: msg.text(),\n        location: location ? `${location.url}:${location.lineNumber}` : undefined,\n      })\n    }\n  })\n\n  await page.goto('/buggy-page')\n\n  // Log errors with source location\n  errors.forEach((e) => {\n    console.log(`Error: ${e.message}`)\n    if (e.location) console.log(`  at ${e.location}`)\n  })\n})\n```\n\n## Failing on Console Errors\n\n### Fail Test on Any Error\n\n```typescript\ntest('no console errors allowed', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Load Data'}).click()\n\n  // Fail if any console errors\n  expect(errors, `Console errors found:\\n${errors.join('\\n')}`).toHaveLength(0)\n})\n```\n\n### Fail with Allowed Exceptions\n\n```typescript\ntest('no unexpected console errors', async ({page}) => {\n  const allowedErrors = [/Failed to load resource.*favicon/, /ResizeObserver loop/]\n\n  const unexpectedErrors: string[] = []\n\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      const text = msg.text()\n      const isAllowed = allowedErrors.some((pattern) => pattern.test(text))\n      if (!isAllowed) {\n        unexpectedErrors.push(text)\n      }\n    }\n  })\n\n  await page.goto('/')\n\n  expect(\n    unexpectedErrors,\n    `Unexpected console errors:\\n${unexpectedErrors.join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Auto-Fail Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleFixtures = {\n  failOnConsoleError: void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  failOnConsoleError: [\n    async ({page}, use, testInfo) => {\n      const errors: string[] = []\n\n      page.on('console', (msg) => {\n        if (msg.type() === 'error') {\n          errors.push(msg.text())\n        }\n      })\n\n      await use()\n\n      // After test, check for errors\n      if (errors.length > 0) {\n        testInfo.annotations.push({\n          type: 'console-errors',\n          description: errors.join('\\n'),\n        })\n        throw new Error(`Console errors detected:\\n${errors.join('\\n')}`)\n      }\n    },\n    {auto: true}, // Runs for every test\n  ],\n})\n```\n\n## JavaScript Error Detection\n\n### Catch Uncaught Exceptions\n\n```typescript\ntest('no uncaught exceptions', async ({page}) => {\n  const pageErrors: Error[] = []\n\n  page.on('pageerror', (error) => {\n    pageErrors.push(error)\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Trigger Action'}).click()\n\n  expect(\n    pageErrors,\n    `Uncaught exceptions:\\n${pageErrors.map((e) => e.message).join('\\n')}`,\n  ).toHaveLength(0)\n})\n```\n\n### Capture Error Details\n\n```typescript\ntest('capture JS error details', async ({page}) => {\n  const errors: {message: string; stack?: string}[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push({\n      message: error.message,\n      stack: error.stack,\n    })\n  })\n\n  await page.goto('/error-page')\n\n  if (errors.length > 0) {\n    console.log('JavaScript errors:')\n    errors.forEach((e) => {\n      console.log(`  Message: ${e.message}`)\n      console.log(`  Stack: ${e.stack}`)\n    })\n  }\n})\n```\n\n### Test Error Boundary Triggers\n\n```typescript\ntest('error boundary catches render error', async ({page}) => {\n  let errorCaught = false\n\n  page.on('pageerror', () => {\n    // Note: React error boundaries catch errors before they become pageerrors\n    // This would only fire for unhandled errors\n    errorCaught = true\n  })\n\n  // Trigger component error via props\n  await page.route(\n    '**/api/data',\n    (route) => route.fulfill({json: null}), // Will cause \"cannot read property of null\"\n  )\n\n  await page.goto('/dashboard')\n\n  // Error boundary should show fallback, not crash\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  expect(errorCaught).toBe(false) // Error was caught by boundary\n})\n```\n\n## Monitoring Warnings\n\n### Capture Deprecation Warnings\n\n```typescript\ntest('no deprecation warnings', async ({page}) => {\n  const deprecations: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('deprecated') || text.includes('Deprecation'))) {\n      deprecations.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  if (deprecations.length > 0) {\n    console.warn('Deprecation warnings found:')\n    deprecations.forEach((d) => console.warn(`  - ${d}`))\n  }\n\n  // Optionally fail\n  // expect(deprecations).toHaveLength(0);\n})\n```\n\n### React Development Warnings\n\n```typescript\ntest('no React warnings', async ({page}) => {\n  const reactWarnings: string[] = []\n\n  page.on('console', (msg) => {\n    const text = msg.text()\n    if (msg.type() === 'warning' && (text.includes('Warning:') || text.includes('React'))) {\n      reactWarnings.push(text)\n    }\n  })\n\n  await page.goto('/')\n\n  // Common React warnings to check\n  const criticalWarnings = reactWarnings.filter(\n    (w) =>\n      w.includes('Each child in a list should have a unique') ||\n      w.includes('Cannot update a component') ||\n      w.includes(\"Can't perform a React state update\"),\n  )\n\n  expect(criticalWarnings, `React warnings:\\n${criticalWarnings.join('\\n')}`).toHaveLength(0)\n})\n```\n\n## Console Fixtures\n\n### Comprehensive Console Fixture\n\n```typescript\n// fixtures/console.fixture.ts\ntype ConsoleMessage = {\n  type: string\n  text: string\n  location?: {url: string; line: number}\n  timestamp: number\n}\n\ntype ConsoleFixtures = {\n  consoleMessages: ConsoleMessage[]\n  getConsoleErrors: () => ConsoleMessage[]\n  getConsoleWarnings: () => ConsoleMessage[]\n  assertNoErrors: (allowedPatterns?: RegExp[]) => void\n}\n\nexport const test = base.extend<ConsoleFixtures>({\n  consoleMessages: async ({page}, use) => {\n    const messages: ConsoleMessage[] = []\n\n    page.on('console', (msg) => {\n      const location = msg.location()\n      messages.push({\n        type: msg.type(),\n        text: msg.text(),\n        location: location ? {url: location.url, line: location.lineNumber} : undefined,\n        timestamp: Date.now(),\n      })\n    })\n\n    await use(messages)\n  },\n\n  getConsoleErrors: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'error'))\n  },\n\n  getConsoleWarnings: async ({consoleMessages}, use) => {\n    await use(() => consoleMessages.filter((m) => m.type === 'warning'))\n  },\n\n  assertNoErrors: async ({getConsoleErrors}, use) => {\n    await use((allowedPatterns = []) => {\n      const errors = getConsoleErrors()\n      const unexpected = errors.filter((e) => !allowedPatterns.some((p) => p.test(e.text)))\n\n      if (unexpected.length > 0) {\n        throw new Error(`Unexpected console errors:\\n${unexpected.map((e) => e.text).join('\\n')}`)\n      }\n    })\n  },\n})\n\n// Usage\ntest('page loads without errors', async ({page, assertNoErrors}) => {\n  await page.goto('/dashboard')\n  await page.getByRole('button', {name: 'Load'}).click()\n\n  assertNoErrors([/favicon/]) // Allow favicon errors\n})\n```\n\n### Attach Console to Report\n\n```typescript\ntest('capture console for debugging', async ({page}, testInfo) => {\n  const logs: string[] = []\n\n  page.on('console', (msg) => {\n    logs.push(`[${msg.type()}] ${msg.text()}`)\n  })\n\n  page.on('pageerror', (error) => {\n    logs.push(`[EXCEPTION] ${error.message}`)\n  })\n\n  await page.goto('/')\n  // ... test actions\n\n  // Attach console log to test report\n  await testInfo.attach('console-log', {\n    body: logs.join('\\n'),\n    contentType: 'text/plain',\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern               | Problem                    | Solution                    |\n| -------------------------- | -------------------------- | --------------------------- |\n| Ignoring console errors    | Bugs go unnoticed          | Check for errors in tests   |\n| Too strict error checking  | Tests fail on minor issues | Allow known/expected errors |\n| Not capturing stack traces | Hard to debug              | Include location info       |\n| Checking only at end       | Miss errors during actions | Capture continuously        |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for troubleshooting\n- **Error Testing**: See [error-testing.md](error-testing.md) for error scenarios\n","isInternal":false,"tokens":2380,"sizeBytes":9773},{"name":"debugging.md","path":".agents/skills/playwright-best-practices/debugging/debugging.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/debugging/debugging.md","title":"Debugging Skill","category":"anthropic-skill","format":"markdown","content":"# Debugging & Troubleshooting\n\n## Table of Contents\n\n1. [Debug Tools](#debug-tools)\n2. [Trace Viewer](#trace-viewer)\n3. [Identifying Flaky Tests](#identifying-flaky-tests)\n4. [Debugging Network Issues](#debugging-network-issues)\n5. [Debugging in CI](#debugging-in-ci)\n6. [Debugging Authentication](#debugging-authentication)\n7. [Debugging Screenshots](#debugging-screenshots)\n8. [Common Issues](#common-issues)\n9. [Logging](#logging)\n\n## Debug Tools\n\n### Playwright Inspector\n\n```bash\n# Run with inspector\nPWDEBUG=1 npx playwright test\n# Or specific test\nPWDEBUG=1 npx playwright test login.spec.ts\n```\n\nFeatures:\n\n- Step through test actions\n- Pick locators visually\n- Inspect DOM state\n- Edit and re-run\n\n### Headed Mode\n\n```bash\n# Run with visible browser\nnpx playwright test --headed\n\n# Interactive debugging (headed, paused, step-through)\nnpx playwright test --debug\n```\n\nYou can also set `slowMo` to add an `N` ms delay per action, making test execution easier to follow while debugging.\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    launchOptions: {\n      slowMo: 500,\n    },\n  },\n})\n```\n\n### UI Mode\n\n```bash\n# Interactive test runner\nnpx playwright test --ui\n```\n\nFeatures:\n\n- Watch mode\n- Test timeline\n- DOM snapshots\n- Network logs\n- Console logs\n\n### Debug in Code\n\n```typescript\ntest('debug example', async ({page}) => {\n  await page.goto('/')\n\n  // Pause and open inspector\n  await page.pause()\n\n  // Continue test...\n  await page.click('button')\n})\n```\n\n## Trace Viewer\n\n### Enable Traces\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    trace: 'on-first-retry', // Record on retry\n    // trace: 'on',                 // Always record\n    // trace: 'retain-on-failure',  // Keep only failures\n  },\n})\n```\n\n### View Traces\n\n```bash\n# Open trace file\nnpx playwright show-trace trace.zip\n\n# From test-results\nnpx playwright show-trace test-results/test-name/trace.zip\n```\n\n### Trace Contents\n\n- Screenshots at each action\n- DOM snapshots\n- Network requests/responses\n- Console logs\n- Action timeline\n- Source code\n\n### Programmatic Traces\n\n```typescript\ntest('manual trace', async ({page, context}) => {\n  await context.tracing.start({screenshots: true, snapshots: true})\n\n  await page.goto('/')\n  await page.click('button')\n\n  await context.tracing.stop({path: 'trace.zip'})\n})\n```\n\n## Identifying Flaky Tests\n\nIf a test fails intermittently, it's likely flaky. Quick checks:\n\n| Behavior                               | Likely Cause                  | Next Step                                    |\n| -------------------------------------- | ----------------------------- | -------------------------------------------- |\n| Fails sometimes, passes other times    | Flaky - timing/race condition | [flaky-tests.md](flaky-tests.md)             |\n| Fails only with multiple workers       | Flaky - parallelism/isolation | [flaky-tests.md](flaky-tests.md)             |\n| Fails only in CI                       | Environment difference        | [CI Debugging](#debugging-in-ci) below       |\n| Always fails                           | Bug in test or app            | Debug with tools above                       |\n| Always passes locally, always fails CI | CI-specific issue             | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) |\n\n> **For flaky test detection commands, root cause analysis, and fixing strategies**, see [flaky-tests.md](flaky-tests.md).\n\n## Debugging Network Issues\n\n### Monitor All Requests\n\n```typescript\ntest('debug network', async ({page}) => {\n  const requests: string[] = []\n  const failures: string[] = []\n\n  page.on('request', (req) => requests.push(`>> ${req.method()} ${req.url()}`))\n  page.on('requestfinished', (req) => {\n    const resp = req.response()\n    requests.push(`<< ${resp?.status()} ${req.url()}`)\n  })\n  page.on('requestfailed', (req) => {\n    failures.push(`FAILED: ${req.url()} - ${req.failure()?.errorText}`)\n  })\n\n  await page.goto('/dashboard')\n\n  // Log summary\n  console.log('Requests:', requests.length)\n  if (failures.length) console.log('Failures:', failures)\n})\n```\n\n### Wait for Specific API Response\n\nWhen debugging network-dependent issues, wait for specific API responses instead of arbitrary timeouts.\n\n```typescript\n// Start waiting BEFORE triggering the request\nconst responsePromise = page.waitForResponse(\n  (resp) => resp.url().includes('/api/data') && resp.status() === 200,\n)\nawait page.getByRole('button', {name: 'Load'}).click()\nconst response = await responsePromise\nconsole.log('Status:', response.status())\n```\n\n> **For comprehensive waiting patterns** (navigation, element state, network, polling), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies).\n\n### Debug Slow Requests\n\n```typescript\ntest('find slow requests', async ({page}) => {\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const total = timing.responseEnd - timing.requestStart\n    if (total > 1000) {\n      console.log(`SLOW (${total}ms): ${request.url()}`)\n    }\n  })\n\n  await page.goto('/')\n})\n```\n\n## Debugging in CI\n\n### Simulate CI Locally\n\n```bash\n# Run in headless mode like CI\nCI=true npx playwright test\n\n# Match CI browser versions\nnpx playwright install --with-deps\n\n# Run in Docker (same as CI)\ndocker run --rm -v $(pwd):/work -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### CI-Specific Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // More artifacts in CI for debugging\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n  },\n\n  // More retries in CI (but investigate failures!)\n  retries: process.env.CI ? 2 : 0,\n})\n```\n\n### Debug CI Environment\n\n```typescript\ntest('CI environment check', async ({page}, testInfo) => {\n  console.log('CI:', process.env.CI)\n  console.log('Project:', testInfo.project.name)\n  console.log('Worker:', testInfo.workerIndex)\n  console.log('Retry:', testInfo.retry)\n  console.log('Base URL:', testInfo.project.use.baseURL)\n\n  // Check viewport\n  const viewport = page.viewportSize()\n  console.log('Viewport:', viewport)\n})\n```\n\n## Debugging Authentication\n\n```typescript\ntest('debug auth', async ({page, context}) => {\n  // Inspect current storage state\n  const storage = await context.storageState()\n  console.log(\n    'Cookies:',\n    storage.cookies.map((c) => c.name),\n  )\n\n  // Check if auth cookies are present\n  const cookies = await context.cookies()\n  const authCookie = cookies.find((c) => c.name.includes('session'))\n  console.log('Auth cookie:', authCookie ? 'present' : 'MISSING')\n\n  await page.goto('/protected')\n\n  // Check if redirected to login (auth failed)\n  if (page.url().includes('/login')) {\n    console.error('Auth failed - redirected to login')\n    // Save state for inspection\n    await context.storageState({path: 'debug-auth.json'})\n  }\n})\n```\n\n## Debugging Screenshots\n\n### Compare Visual State\n\n```typescript\ntest('visual debug', async ({page}, testInfo) => {\n  await page.goto('/')\n\n  // Screenshot before action\n  await page.screenshot({\n    path: testInfo.outputPath('before.png'),\n    fullPage: true,\n  })\n\n  await page.getByRole('button', {name: 'Open Menu'}).click()\n\n  // Screenshot after action\n  await page.screenshot({\n    path: testInfo.outputPath('after.png'),\n    fullPage: true,\n  })\n\n  // Attach to report\n  await testInfo.attach('before', {\n    path: testInfo.outputPath('before.png'),\n    contentType: 'image/png',\n  })\n})\n```\n\n### Screenshot Specific Element\n\n```typescript\ntest('element screenshot', async ({page}) => {\n  await page.goto('/')\n\n  const element = page.getByTestId('problem-area')\n\n  // Screenshot just the element\n  await element.screenshot({path: 'element-debug.png'})\n\n  // Highlight element in full page screenshot\n  await element.evaluate((el) => (el.style.border = '3px solid red'))\n  await page.screenshot({path: 'highlighted.png'})\n})\n```\n\n## Common Issues\n\n### Element Not Found\n\n```typescript\n// Debug: Check if element exists\nconsole.log(await page.getByRole('button').count())\n\n// Debug: Log all buttons\nconst buttons = await page.getByRole('button').all()\nfor (const button of buttons) {\n  console.log(await button.textContent())\n}\n\n// Debug: Screenshot before action\nawait page.screenshot({path: 'debug.png'})\nawait page.getByRole('button').click()\n```\n\n### Timeout Issues\n\n```typescript\n// Increase timeout for slow operations\nawait expect(page.getByText('Loaded')).toBeVisible({timeout: 30000})\n\n// Global timeout increase\ntest.setTimeout(60000)\n\n// Check what's blocking\ntest('debug timeout', async ({page}) => {\n  await page.goto('/slow-page')\n\n  // Log network activity\n  page.on('request', (request) => console.log('>>', request.url()))\n  page.on('response', (response) => console.log('<<', response.url(), response.status()))\n})\n```\n\n### Selector Issues\n\n```typescript\n// Debug: Highlight element\nawait page.getByRole('button').highlight()\n\n// Debug: Evaluate selector in browser console\n// Run in Inspector console:\n// playwright.locator('button').first().highlight()\n\n// Debug: Get element info\nconst element = page.getByRole('button')\nconsole.log('Count:', await element.count())\nconsole.log('Visible:', await element.isVisible())\nconsole.log('Enabled:', await element.isEnabled())\n```\n\n### Frame Issues\n\n```typescript\n// Debug: List all frames\nfor (const frame of page.frames()) {\n  console.log('Frame:', frame.url())\n}\n\n// Debug: Check if element is in iframe\nconst frame = page.frameLocator('iframe').first()\nconsole.log(await frame.getByRole('button').count())\n```\n\n## Logging\n\n### Capture Browser Console\n\n```typescript\ntest('with logging', async ({page}) => {\n  page.on('console', (msg) => console.log('Browser:', msg.text()))\n  page.on('pageerror', (error) => console.log('Page error:', error.message))\n  await page.goto('/')\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Custom Test Attachments\n\n```typescript\ntest('with attachments', async ({page}, testInfo) => {\n  // Attach screenshot to report\n  const screenshot = await page.screenshot()\n  await testInfo.attach('screenshot', {\n    body: screenshot,\n    contentType: 'image/png',\n  })\n\n  // Attach logs or data\n  await testInfo.attach('logs', {\n    body: 'Custom log data',\n    contentType: 'text/plain',\n  })\n\n  // Use testInfo for output paths\n  const outputPath = testInfo.outputPath('debug-file.json')\n})\n```\n\n## Troubleshooting Checklist\n\n### By Symptom\n\n| Symptom                                       | Common Causes                                                | Quick Fixes                                                         | Reference                                                                                  |\n| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| **Element not found**                         | Wrong selector, element not visible, in iframe, timing issue | Check locator with Inspector, wait for visibility, use frameLocator | [locators.md](../core/locators.md), [assertions-waiting.md](../core/assertions-waiting.md) |\n| **Timeout errors**                            | Slow network, heavy page load, waiting for wrong condition   | Increase timeout, wait for specific response, check network tab     | [assertions-waiting.md](../core/assertions-waiting.md)                                     |\n| **Flaky tests**                               | Race conditions, shared state, timing dependencies           | See comprehensive flaky test guide                                  | [flaky-tests.md](flaky-tests.md)                                                           |\n| **Tests pass locally, fail in CI**            | Environment differences, missing dependencies, timing        | Simulate CI locally, check CI logs, verify environment vars         | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md), [flaky-tests.md](flaky-tests.md)             |\n| **Slow test execution**                       | Not parallelized, heavy network calls, unnecessary waits     | Enable parallelization, mock APIs, optimize waits                   | [performance.md](../infrastructure-ci-cd/performance.md)                                   |\n| **Selector works in browser but not in test** | Element not attached, wrong context, dynamic content         | Use auto-waiting, check iframe, verify element state                | [locators.md](../core/locators.md)                                                         |\n| **Test fails on retry**                       | Non-deterministic data, external dependencies                | Use test data fixtures, mock external services                      | [fixtures-hooks.md](../core/fixtures-hooks.md)                                             |\n\n### Step-by-Step Debugging Process\n\n1. **Reproduce the issue**\n\n   ```bash\n   # Run with trace enabled\n   npx playwright test tests/failing.spec.ts --trace on\n\n   # If intermittent, run multiple times\n   npx playwright test --repeat-each=10\n   ```\n\n2. **Inspect the failure**\n\n   ```bash\n   # View trace\n   npx playwright show-trace test-results/path-to-trace.zip\n\n   # Run in headed mode to watch\n   npx playwright test --headed\n\n   # Use inspector for step-by-step\n   PWDEBUG=1 npx playwright test\n   ```\n\n3. **Isolate the problem**\n\n   ```typescript\n   // Add debugging points\n   await page.pause()\n\n   // Log element state\n   console.log('Element count:', await page.getByRole('button').count())\n   console.log('Element visible:', await page.getByRole('button').isVisible())\n\n   // Take screenshot at failure point\n   await page.screenshot({path: 'debug.png'})\n   ```\n\n4. **Check related areas**\n   - Network requests: Are API calls completing? (see [Debugging Network Issues](#debugging-network-issues))\n   - Timing: Is auto-waiting working correctly?\n   - State: Is the test isolated? (see [flaky-tests.md](flaky-tests.md))\n   - Environment: Does it work locally but fail in CI? (see [Debugging in CI](#debugging-in-ci))\n\n5. **Apply fix and verify**\n   - Fix the root cause (not just symptoms)\n   - Run multiple times to confirm stability: `--repeat-each=10`\n   - Check related tests aren't affected\n\n## Related References\n\n- **Flaky tests**: See [flaky-tests.md](flaky-tests.md) for comprehensive flaky test guide\n- **Locator issues**: See [locators.md](../core/locators.md) for selector strategies\n- **Waiting problems**: See [assertions-waiting.md](../core/assertions-waiting.md) for waiting patterns\n- **Test isolation**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for fixtures and isolation\n- **CI issues**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI configuration\n","isInternal":false,"tokens":3442,"sizeBytes":15026},{"name":"error-testing.md","path":".agents/skills/playwright-best-practices/debugging/error-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/debugging/error-testing.md","title":"Debugging Skill","category":"anthropic-skill","format":"markdown","content":"# Error & Edge Case Testing\n\n## Table of Contents\n\n1. [Error Boundaries](#error-boundaries)\n2. [Network Failures](#network-failures)\n3. [Offline Testing](#offline-testing)\n4. [Loading States](#loading-states)\n5. [Form Validation](#form-validation)\n\n## Error Boundaries\n\n### Test Component Errors\n\n```typescript\ntest('error boundary catches component error', async ({page}) => {\n  // Trigger error via mock\n  await page.route('**/api/user', (route) => {\n    route.fulfill({\n      json: null, // Will cause component to throw\n    })\n  })\n\n  await page.goto('/profile')\n\n  // Error boundary should render fallback\n  await expect(page.getByText('Something went wrong')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Try Again'})).toBeVisible()\n})\n```\n\n### Test Error Recovery\n\n```typescript\ntest('recover from error state', async ({page}) => {\n  let requestCount = 0\n\n  await page.route('**/api/data', (route) => {\n    requestCount++\n    if (requestCount === 1) {\n      return route.fulfill({status: 500})\n    }\n    return route.fulfill({\n      json: {data: 'success'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Error state\n  await expect(page.getByText('Failed to load')).toBeVisible()\n\n  // Retry\n  await page.getByRole('button', {name: 'Retry'}).click()\n\n  // Success state\n  await expect(page.getByText('success')).toBeVisible()\n})\n```\n\n### Test JavaScript Errors\n\n```typescript\ntest('handles runtime error gracefully', async ({page}) => {\n  const errors: string[] = []\n\n  page.on('pageerror', (error) => {\n    errors.push(error.message)\n  })\n\n  await page.goto('/buggy-page')\n\n  // App should still be functional despite error\n  await expect(page.getByRole('navigation')).toBeVisible()\n\n  // Error was logged\n  expect(errors.length).toBeGreaterThan(0)\n})\n```\n\n## Network Failures\n\n### Test API Errors\n\n```typescript\ntest.describe('API error handling', () => {\n  const errorCodes = [400, 401, 403, 404, 500, 502, 503]\n\n  for (const status of errorCodes) {\n    test(`handles ${status} error`, async ({page}) => {\n      await page.route('**/api/data', (route) =>\n        route.fulfill({\n          status,\n          json: {error: `Error ${status}`},\n        }),\n      )\n\n      await page.goto('/dashboard')\n\n      // Appropriate error message shown\n      await expect(page.getByRole('alert')).toBeVisible()\n    })\n  }\n})\n```\n\n### Test Timeout\n\n```typescript\ntest('handles request timeout', async ({page}) => {\n  await page.route('**/api/slow', async (route) => {\n    // Never respond - simulates timeout\n    await new Promise(() => {})\n  })\n\n  await page.goto('/slow-page')\n\n  // Should show timeout message (app should have its own timeout)\n  await expect(page.getByText('Request timed out')).toBeVisible({\n    timeout: 15000,\n  })\n})\n```\n\n### Test Connection Reset\n\n```typescript\ntest('handles connection failure', async ({page}) => {\n  await page.route('**/api/data', (route) => {\n    route.abort('connectionfailed')\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByText('Connection failed')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Test Mid-Request Failure\n\n```typescript\ntest('handles failure during request', async ({page}) => {\n  let requestStarted = false\n\n  await page.route('**/api/upload', async (route) => {\n    requestStarted = true\n    // Abort after small delay (mid-request)\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.abort('failed')\n  })\n\n  await page.goto('/upload')\n  await page.getByLabel('File').setInputFiles('./fixtures/large-file.pdf')\n  await page.getByRole('button', {name: 'Upload'}).click()\n\n  // Should show failure, not hang\n  await expect(page.getByText('Upload failed')).toBeVisible()\n  expect(requestStarted).toBe(true)\n})\n```\n\n## Offline Testing\n\nThis section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing).\n\n### Go Offline During Session\n\n```typescript\ntest('handles going offline', async ({page, context}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByTestId('data')).toBeVisible()\n\n  // Go offline unexpectedly\n  await context.setOffline(true)\n\n  // Try to refresh data\n  await page.getByRole('button', {name: 'Refresh'}).click()\n\n  // Should show offline indicator\n  await expect(page.getByText(\"You're offline\")).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n\n  // Should recover\n  await page.getByRole('button', {name: 'Refresh'}).click()\n  await expect(page.getByText(\"You're offline\")).toBeHidden()\n})\n```\n\n### Test Network Recovery\n\n```typescript\ntest('recovers gracefully when connection returns', async ({page, context}) => {\n  await page.goto('/dashboard')\n\n  // Simulate connection drop\n  await context.setOffline(true)\n\n  // App should show degraded state\n  await expect(page.getByRole('alert')).toContainText(/offline|connection/i)\n\n  // Connection restored\n  await context.setOffline(false)\n\n  // Retry should work\n  await page.getByRole('button', {name: 'Retry'}).click()\n  await expect(page.getByTestId('data')).toBeVisible()\n})\n```\n\n## Loading States\n\n### Test Skeleton Loaders\n\n```typescript\ntest('shows skeleton during load', async ({page}) => {\n  // Add delay to API response\n  await page.route('**/api/posts', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 1000))\n    route.fulfill({\n      json: [{id: 1, title: 'Post 1'}],\n    })\n  })\n\n  await page.goto('/posts')\n\n  // Skeleton should appear immediately\n  await expect(page.getByTestId('skeleton')).toBeVisible()\n\n  // Then content replaces skeleton\n  await expect(page.getByText('Post 1')).toBeVisible()\n  await expect(page.getByTestId('skeleton')).toBeHidden()\n})\n```\n\n### Test Loading Indicators\n\n```typescript\ntest('shows loading state for actions', async ({page}) => {\n  await page.route('**/api/save', async (route) => {\n    await new Promise((resolve) => setTimeout(resolve, 500))\n    route.fulfill({json: {success: true}})\n  })\n\n  await page.goto('/editor')\n  await page.getByLabel('Content').fill('New content')\n\n  const saveButton = page.getByRole('button', {name: 'Save'})\n  await saveButton.click()\n\n  // Button should show loading state\n  await expect(saveButton).toBeDisabled()\n  await expect(page.getByTestId('spinner')).toBeVisible()\n\n  // Then success state\n  await expect(saveButton).toBeEnabled()\n  await expect(page.getByText('Saved')).toBeVisible()\n})\n```\n\n### Test Empty States\n\n```typescript\ntest('shows empty state when no data', async ({page}) => {\n  await page.route('**/api/items', (route) => route.fulfill({json: []}))\n\n  await page.goto('/items')\n\n  await expect(page.getByText('No items yet')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Create First Item'})).toBeVisible()\n})\n```\n\n## Form Validation\n\n### Test Client-Side Validation\n\n```typescript\ntest('validates required fields', async ({page}) => {\n  await page.goto('/signup')\n\n  // Submit empty form\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Should show validation errors\n  await expect(page.getByText('Email is required')).toBeVisible()\n  await expect(page.getByText('Password is required')).toBeVisible()\n\n  // Form should not submit\n  await expect(page).toHaveURL('/signup')\n})\n```\n\n### Test Format Validation\n\n```typescript\ntest('validates email format', async ({page}) => {\n  await page.goto('/signup')\n\n  await page.getByLabel('Email').fill('invalid-email')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeVisible()\n\n  // Fix the error\n  await page.getByLabel('Email').fill('valid@email.com')\n  await page.getByLabel('Email').blur()\n\n  await expect(page.getByText('Invalid email address')).toBeHidden()\n})\n```\n\n### Test Server-Side Validation\n\n```typescript\ntest('handles server validation errors', async ({page}) => {\n  await page.route('**/api/register', (route) =>\n    route.fulfill({\n      status: 422,\n      json: {\n        errors: {\n          email: 'Email already exists',\n          username: 'Username is taken',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('taken@email.com')\n  await page.getByLabel('Username').fill('takenuser')\n  await page.getByLabel('Password').fill('password123')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  // Server errors should display\n  await expect(page.getByText('Email already exists')).toBeVisible()\n  await expect(page.getByText('Username is taken')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                               |\n| ------------------------ | ------------------------------ | -------------------------------------- |\n| Only testing happy path  | Misses error handling bugs     | Test all error scenarios               |\n| No network failure tests | App crashes on poor connection | Test offline/slow/failed requests      |\n| Skipping loading states  | Janky UX not caught            | Assert loading UI appears              |\n| Ignoring validation      | Form bugs slip through         | Test both client and server validation |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for mock patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for error assertions\n","isInternal":false,"tokens":2237,"sizeBytes":9499},{"name":"flaky-tests.md","path":".agents/skills/playwright-best-practices/debugging/flaky-tests.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/debugging/flaky-tests.md","title":"Debugging Skill","category":"anthropic-skill","format":"markdown","content":"# Debugging and Managing Flaky Tests\n\n## Table of Contents\n\n1. [Understanding Flakiness Types](#understanding-flakiness-types)\n2. [Detection and Reproduction](#detection-and-reproduction)\n3. [Root Cause Analysis](#root-cause-analysis)\n4. [Fixing Strategies by Type](#fixing-strategies-by-type)\n5. [CI-Specific Flakiness](#ci-specific-flakiness)\n6. [Quarantine and Management](#quarantine-and-management)\n7. [Prevention Strategies](#prevention-strategies)\n\n## Understanding Flakiness Types\n\n### Categories of Flakiness\n\nMost flaky tests fall into distinct categories requiring different remediation:\n\n| Category                    | Symptoms                        | Common Causes                                          |\n| --------------------------- | ------------------------------- | ------------------------------------------------------ |\n| **UI-driven**               | Element not found, click missed | Missing waits, animations, dynamic rendering           |\n| **Environment-driven**      | CI-only failures                | Slower CPU, memory limits, cold browser starts         |\n| **Data/parallelism-driven** | Fails with multiple workers     | Shared backend data, reused accounts, state collisions |\n| **Test-suite-driven**       | Fails when run with other tests | Leaked state, shared fixtures, order dependencies      |\n\n### Flakiness Decision Tree\n\n```\nTest fails intermittently\n├─ Fails locally too?\n│  ├─ YES → Timing/async issue → Check waits and assertions\n│  └─ NO → CI-specific → Check environment differences\n│\n├─ Fails only with multiple workers?\n│  └─ YES → Parallelism issue → Check data isolation\n│\n├─ Fails only when run after specific tests?\n│  └─ YES → State leak → Check fixtures and cleanup\n│\n└─ Fails randomly regardless of conditions?\n   └─ External dependency → Check network/API stability\n```\n\n## Detection and Reproduction\n\n### Confirming Flakiness\n\n```bash\n# Run test multiple times to confirm instability\nnpx playwright test tests/checkout.spec.ts --repeat-each=20\n\n# Run with single worker to isolate parallelism issues\nnpx playwright test --workers=1\n\n# Run in CI-like conditions locally\nCI=true npx playwright test --repeat-each=10\n```\n\n### Reproduction Strategies\n\n```typescript\n// playwright.config.ts - Enable artifacts for flaky test investigation\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0,\n  use: {\n    trace: 'on-first-retry', // Capture trace on retry\n    video: 'retain-on-failure',\n    screenshot: 'only-on-failure',\n  },\n})\n```\n\n### Identify Flaky Tests Programmatically\n\n```typescript\n// Track test results across runs\ntest.afterEach(async ({}, testInfo) => {\n  if (testInfo.retry > 0 && testInfo.status === 'passed') {\n    console.warn(`FLAKY: ${testInfo.title} passed on retry ${testInfo.retry}`)\n    // Log to your tracking system\n  }\n})\n```\n\n## Root Cause Analysis\n\n### Event Logging for Race Conditions\n\nAdd comprehensive event logging to expose timing issues:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  page.on('console', (msg) => console.log(`CONSOLE [${msg.type()}]:`, msg.text()))\n  page.on('pageerror', (err) => console.error('PAGE ERROR:', err.message))\n  page.on('requestfailed', (req) => console.error(`REQUEST FAILED: ${req.url()}`))\n})\n```\n\n> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).\n\n### Network Timing Analysis\n\n```typescript\n// Capture slow or failed requests\ntest.beforeEach(async ({page}) => {\n  const slowRequests: string[] = []\n\n  page.on('requestfinished', (request) => {\n    const timing = request.timing()\n    const duration = timing.responseEnd - timing.requestStart\n    if (duration > 2000) {\n      slowRequests.push(`${request.url()} took ${duration}ms`)\n    }\n  })\n\n  page.on('requestfailed', (request) => {\n    console.error(`Failed: ${request.url()} - ${request.failure()?.errorText}`)\n  })\n})\n```\n\n### Trace Analysis\n\n```bash\n# View trace from failed CI run\nnpx playwright show-trace path/to/trace.zip\n\n# Generate trace for specific test\nnpx playwright test tests/flaky.spec.ts --trace on\n```\n\n## Fixing Strategies by Type\n\n### UI-Driven Flakiness\n\n**Problem: Element not ready when action executes**\n\n```typescript\n// ❌ BAD: No wait for element state\nawait page.click('#submit')\nawait page.fill('#username', 'test') // Element may not be ready\n\n// ✅ GOOD: Actions + assertions pattern (auto-waiting built-in)\nawait page.getByRole('button', {name: 'Submit'}).click()\nawait expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n```\n\n**Problem: Animations or transitions interfere**\n\n```typescript\n// ❌ BAD: Click during animation\nawait page.click('.menu-item')\n\n// ✅ GOOD: Wait for animation to complete\nawait page.getByRole('menuitem', {name: 'Settings'}).click()\nawait expect(page.getByRole('dialog')).toBeVisible()\n// Or disable animations entirely\nawait page.emulateMedia({reducedMotion: 'reduce'})\n```\n\n**Problem: Brittle selectors**\n\n```typescript\n// ❌ BAD: Fragile CSS chain\nawait page.click('div.container > div:nth-child(2) > button.btn-primary')\n\n// ✅ GOOD: Semantic selectors\nawait page.getByRole('button', {name: 'Continue'}).click()\nawait page.getByTestId('checkout-button').click()\nawait page.getByLabel('Email address').fill('test@example.com')\n```\n\n### Async/Timing Flakiness\n\n**Problem: Race between test and application**\n\n```typescript\n// ❌ BAD: Arbitrary sleep\nawait page.click('#load-data')\nawait page.waitForTimeout(3000) // Hope data loads in 3s\n\n// ✅ GOOD: Wait for specific condition\nawait page.click('#load-data')\nawait expect(page.locator('.data-row')).toHaveCount(10, {timeout: 10000})\n\n// ✅ BETTER: Wait for network response, then assert\nconst responsePromise = page.waitForResponse(\n  (r) => r.url().includes('/api/data') && r.request().method() === 'GET' && r.ok(),\n)\nawait page.click('#load-data')\nawait responsePromise\nawait expect(page.locator('.data-row')).toHaveCount(10)\n```\n\n> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).\n\n**Problem: Complex async state**\n\n```typescript\n// Custom wait for application-specific conditions\nawait page.waitForFunction(() => {\n  const app = (window as any).__APP_STATE__\n  return app?.isReady && !app?.isLoading\n})\n\n// Wait for multiple conditions\nawait Promise.all([\n  page.waitForResponse('**/api/user'),\n  page.waitForResponse('**/api/settings'),\n  page.getByRole('button', {name: 'Load'}).click(),\n])\n```\n\n### Data/Parallelism-Driven Flakiness\n\n**Problem: Tests share backend data**\n\n```typescript\n// ❌ BAD: All workers use same user\nconst testUser = {email: 'test@example.com', password: 'pass123'}\n\n// ✅ GOOD: Unique data per worker\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend<{}, {testUser: {email: string; id: string}}>({\n  testUser: [\n    async ({}, use, workerInfo) => {\n      const email = `test-${workerInfo.workerIndex}-${Date.now()}@example.com`\n      const user = await createTestUser(email)\n      await use(user)\n      await deleteTestUser(user.id)\n    },\n    {scope: 'worker'},\n  ],\n})\n```\n\n**Problem: Shared storageState across workers**\n\n```typescript\n// ❌ BAD: All workers share same auth state\nuse: {\n  storageState: '.auth/user.json',\n}\n\n// ✅ GOOD: Per-worker auth state\nexport const test = base.extend<{}, { workerStorageState: string }>({\n  workerStorageState: [\n    async ({ browser }, use, workerInfo) => {\n      const id = workerInfo.workerIndex;\n      const fileName = `.auth/user-${id}.json`;\n\n      if (!fs.existsSync(fileName)) {\n        const page = await browser.newPage({ storageState: undefined });\n        await authenticateUser(page, `worker${id}@test.com`);\n        await page.context().storageState({ path: fileName });\n        await page.close();\n      }\n\n      await use(fileName);\n    },\n    { scope: \"worker\" },\n  ],\n});\n```\n\n### Test-Suite-Driven Flakiness (State Leaks)\n\n**Problem: Tests affect each other**\n\n```typescript\n// ❌ BAD: Module-level state persists across tests\nlet sharedPage: Page\n\ntest.beforeAll(async ({browser}) => {\n  sharedPage = await browser.newPage() // Shared across tests!\n})\n\n// ✅ GOOD: Use Playwright's default isolation (fresh context per test)\ntest('first test', async ({page}) => {\n  // Fresh page for this test\n})\n\ntest('second test', async ({page}) => {\n  // Fresh page for this test\n})\n```\n\n**Problem: Fixture cleanup not happening**\n\n```typescript\n// ✅ GOOD: Proper fixture with cleanup\nexport const test = base.extend<{tempFile: string}>({\n  tempFile: async ({}, use) => {\n    const file = `/tmp/test-${Date.now()}.json`\n    fs.writeFileSync(file, '{}')\n\n    await use(file)\n\n    // Cleanup always runs, even on failure\n    if (fs.existsSync(file)) {\n      fs.unlinkSync(file)\n    }\n  },\n})\n```\n\n## CI-Specific Flakiness\n\n### Why Tests Fail Only in CI\n\n| CI Condition       | Impact                                | Solution                                             |\n| ------------------ | ------------------------------------- | ---------------------------------------------------- |\n| Slower CPU         | Actions complete later than expected  | Use auto-waiting, not timeouts                       |\n| Cold browser start | No cached assets, slower initial load | Add explicit waits for first navigation              |\n| Headless mode      | Different rendering behavior          | Test locally in headless mode                        |\n| Shared runners     | Resource contention                   | Reduce parallelism or use dedicated runners          |\n| Network latency    | API calls slower                      | Mock external APIs, increase timeouts for real calls |\n\n### Simulating CI Locally\n\n```bash\n# Run headless with CI environment variable\nCI=true npx playwright test\n\n# Limit CPU (Linux/Mac)\ncpulimit -l 50 -- npx playwright test\n\n# Run in Docker matching CI environment\ndocker run -it --rm \\\n  -v $(pwd):/work \\\n  -w /work \\\n  mcr.microsoft.com/playwright:v1.40.0-jammy \\\n  npx playwright test\n```\n\n### Consistent Viewport and Scale\n\n```typescript\n// playwright.config.ts - Match CI rendering exactly\nexport default defineConfig({\n  use: {\n    viewport: {width: 1280, height: 720},\n    deviceScaleFactor: 1,\n  },\n})\n```\n\n### Network Stubbing for External APIs\n\n```typescript\n// Eliminate external API flakiness\ntest.beforeEach(async ({page}) => {\n  // Stub unstable third-party APIs\n  await page.route('**/api.analytics.com/**', (route) => route.fulfill({body: ''}))\n  await page.route('**/api.payment-provider.com/**', (route) =>\n    route.fulfill({json: {status: 'ok'}}),\n  )\n})\n\n// Test-specific stub\ntest('checkout with payment', async ({page}) => {\n  await page.route('**/api/payment', (route) =>\n    route.fulfill({json: {success: true, transactionId: 'test-123'}}),\n  )\n  // Test proceeds with deterministic response\n})\n```\n\n## Quarantine and Management\n\n### Quarantine Pattern\n\n```typescript\n// playwright.config.ts - Separate flaky tests\nexport default defineConfig({\n  projects: [\n    {\n      name: 'stable',\n      testIgnore: ['**/*.flaky.spec.ts'],\n    },\n    {\n      name: 'quarantine',\n      testMatch: ['**/*.flaky.spec.ts'],\n      retries: 3,\n    },\n  ],\n})\n```\n\n### Annotation-Based Quarantine\n\n```typescript\n// Mark flaky tests with annotations\ntest('intermittent checkout issue', async ({page}, testInfo) => {\n  testInfo.annotations.push({\n    type: 'flaky',\n    description: 'Investigating payment API timing - JIRA-1234',\n  })\n\n  // Test implementation\n})\n\n// Skip flaky test conditionally\ntest('known CI flaky', async ({page}) => {\n  test.skip(!!process.env.CI, 'Flaky in CI - investigating JIRA-5678')\n  // Test implementation\n})\n```\n\n## Prevention Strategies\n\n### Test Burn-In\n\n```bash\n# Run new tests many times before merging\nnpx playwright test tests/new-feature.spec.ts --repeat-each=50\n\n# Run in parallel to expose race conditions\nnpx playwright test tests/new-feature.spec.ts --repeat-each=20 --workers=4\n```\n\n### Isolation Checklist\n\n```typescript\n// ✅ Each test should be self-contained\ntest.describe('User profile', () => {\n  test('can update name', async ({page, testUser}) => {\n    // Uses unique testUser fixture\n    // No dependency on other tests\n    // Cleanup handled by fixture\n  })\n\n  test('can update email', async ({page, testUser}) => {\n    // Independent of \"can update name\"\n    // Own testUser, own state\n  })\n})\n```\n\n### Defensive Assertions\n\n```typescript\n// ❌ BAD: Single point of failure\nawait expect(page.locator('.items')).toHaveCount(5)\n\n// ✅ GOOD: Progressive assertions that help diagnose\nawait expect(page.locator('.items-container')).toBeVisible()\nawait expect(page.locator('.loading')).not.toBeVisible()\nawait expect(page.locator('.items')).toHaveCount(5)\n```\n\n### Retry Budget\n\n```typescript\n// playwright.config.ts - Limit retries to avoid masking issues\nexport default defineConfig({\n  retries: process.env.CI ? 2 : 0, // Only retry in CI\n  expect: {\n    timeout: 10000, // Reasonable assertion timeout\n  },\n  timeout: 60000, // Test timeout\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                              | Problem                             | Solution                                       |\n| ----------------------------------------- | ----------------------------------- | ---------------------------------------------- |\n| `waitForTimeout()` as primary wait        | Arbitrary, hides real timing issues | Use auto-waiting assertions                    |\n| Increasing global timeout to \"fix\" flakes | Masks root cause, slows all tests   | Find and fix actual timing issue               |\n| Retrying until pass                       | Hides systemic problems             | Fix root cause, use retries for diagnosis only |\n| Shared test data across workers           | Race conditions, collisions         | Isolate data per worker                        |\n| Testing real external APIs                | Network variability                 | Mock external dependencies                     |\n| Module-level mutable state                | Leaks between tests                 | Use fixtures with proper cleanup               |\n| Ignoring flaky tests                      | Problem compounds over time         | Quarantine and track for fixing                |\n\n## Related References\n\n- **Debugging**: See [debugging.md](debugging.md) for trace viewer and inspector\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped isolation\n- **Performance**: See [performance.md](../infrastructure-ci-cd/performance.md) for parallel execution patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for auto-waiting patterns\n- **Global Setup**: See [global-setup.md](../core/global-setup.md) for setup vs fixtures decision\n","isInternal":false,"tokens":3437,"sizeBytes":14917},{"name":"angular.md","path":".agents/skills/playwright-best-practices/frameworks/angular.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/frameworks/angular.md","title":"Frameworks Skill","category":"anthropic-skill","format":"markdown","content":"# Angular Testing with Playwright\n\n## Table of Contents\n\n1. [Configuration](#configuration)\n2. [Locator Strategies](#locator-strategies)\n3. [Reactive Forms](#reactive-forms)\n4. [Angular Material Components](#angular-material-components)\n5. [Router Navigation](#router-navigation)\n6. [Lazy-Loaded Modules](#lazy-loaded-modules)\n7. [Signals and Observables](#signals-and-observables)\n8. [Zone.js and Change Detection](#zonejs-and-change-detection)\n9. [SSR Testing](#ssr-testing)\n10. [Protractor Migration Reference](#protractor-migration-reference)\n11. [Build Configurations](#build-configurations)\n12. [CDK Overlay Container](#cdk-overlay-container)\n13. [Anti-Patterns](#anti-patterns)\n14. [Related](#related)\n\n> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.\n> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)\n\n## Configuration\n\n### Playwright Config\n\n```typescript\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:4200',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI\n      ? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'\n      : 'npx ng serve',\n    url: 'http://localhost:4200',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Project Structure\n\n```text\nmy-angular-app/\n  src/\n  e2e/\n    tests/\n      dashboard.spec.ts\n      login.spec.ts\n    fixtures/\n      auth.fixture.ts\n  playwright.config.ts\n  angular.json\n```\n\n### Package Scripts\n\n```json\n{\n  \"scripts\": {\n    \"e2e\": \"playwright test\",\n    \"e2e:headed\": \"playwright test --headed\",\n    \"e2e:debug\": \"playwright test --debug\",\n    \"e2e:report\": \"playwright show-report\"\n  }\n}\n```\n\n## Locator Strategies\n\nAngular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.\n\n```typescript\ntest('use semantic locators for Angular apps', async ({page}) => {\n  await page.goto('/projects')\n\n  // Role-based locators work with Angular Material and native HTML\n  await page.getByRole('button', {name: 'New project'}).click()\n  await expect(page.getByRole('heading', {name: 'Create Project'})).toBeVisible()\n\n  // Label-based for form fields\n  await page.getByLabel('Project title').fill('Alpha')\n\n  // Test IDs for complex components without semantic roles\n  const chart = page.getByTestId('metrics-chart')\n  await expect(chart).toBeVisible()\n\n  // Scope locators within component boundaries\n  const projectTable = page.getByRole('table', {name: 'Projects'})\n  const activeRow = projectTable.getByRole('row').filter({\n    has: page.getByRole('cell', {name: 'Active'}),\n  })\n  await activeRow.getByRole('button', {name: 'Edit'}).click()\n})\n```\n\n## Reactive Forms\n\nPlaywright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.\n\n```typescript\ntest.describe('form validation', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('displays validation errors on blur', async ({page}) => {\n    const emailField = page.getByLabel('Email')\n    await emailField.click()\n    await emailField.blur()\n    await expect(page.getByText('Email is required')).toBeVisible()\n\n    await emailField.fill('invalid')\n    await emailField.blur()\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('validates password confirmation', async ({page}) => {\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Mismatch')\n    await page.getByLabel('Confirm password').blur()\n\n    await expect(page.getByText('Passwords must match')).toBeVisible()\n\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await expect(page.getByText('Passwords must match')).toBeHidden()\n  })\n\n  test('handles FormArray add/remove', async ({page}) => {\n    await page.goto('/contacts/edit')\n\n    await page.getByRole('button', {name: 'Add email'}).click()\n    const emailInputs = page.getByLabel(/Email address/)\n    await expect(emailInputs).toHaveCount(2)\n\n    await emailInputs.nth(1).fill('backup@example.com')\n    await page.getByRole('button', {name: 'Remove email 1'}).click()\n\n    await expect(emailInputs).toHaveCount(1)\n    await expect(emailInputs.first()).toHaveValue('backup@example.com')\n  })\n\n  test('disables submit until form is valid', async ({page}) => {\n    const submitBtn = page.getByRole('button', {name: 'Register'})\n    await expect(submitBtn).toBeDisabled()\n\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secret123!')\n    await page.getByLabel('Confirm password').fill('Secret123!')\n    await page.getByLabel('Accept terms').check()\n\n    await expect(submitBtn).toBeEnabled()\n  })\n\n  test('shows async validator loading state', async ({page}) => {\n    await page.route('**/api/username-check*', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({json: {available: true}})\n    })\n\n    await page.getByLabel('Username').fill('alice')\n    await page.getByLabel('Username').blur()\n\n    await expect(page.getByTestId('username-loading')).toBeVisible()\n    await expect(page.getByTestId('username-loading')).toBeHidden()\n    await expect(page.getByText('Username available')).toBeVisible()\n  })\n})\n```\n\n## Angular Material Components\n\nAngular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.\n\n```typescript\ntest.describe('Material components', () => {\n  test('mat-select dropdown', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Language'}).click()\n    await page.getByRole('option', {name: 'Spanish'}).click()\n\n    await expect(page.getByRole('combobox', {name: 'Language'})).toContainText('Spanish')\n  })\n\n  test('mat-autocomplete suggestions', async ({page}) => {\n    await page.goto('/members/add')\n\n    const roleField = page.getByRole('combobox', {name: 'Role'})\n    await roleField.fill('dev')\n\n    await expect(page.getByRole('option', {name: 'Developer'})).toBeVisible()\n    await expect(page.getByRole('option', {name: 'DevOps'})).toBeVisible()\n\n    await page.getByRole('option', {name: 'Developer'}).click()\n    await expect(roleField).toHaveValue('Developer')\n  })\n\n  test('mat-dialog interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove item'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByText('Confirm deletion?')).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('mat-table sorting', async ({page}) => {\n    await page.goto('/members')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    const header = page.getByRole('columnheader', {name: 'Name'})\n    await expect(header).toHaveAttribute('aria-sort', 'ascending')\n\n    await page.getByRole('columnheader', {name: 'Name'}).click()\n    await expect(header).toHaveAttribute('aria-sort', 'descending')\n  })\n\n  test('mat-paginator navigation', async ({page}) => {\n    await page.goto('/members')\n\n    await expect(page.getByText('1 - 10 of 100')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next page'}).click()\n    await expect(page.getByText('11 - 20 of 100')).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Items per page'}).click()\n    await page.getByRole('option', {name: '50'}).click()\n    await expect(page.getByText('1 - 50 of 100')).toBeVisible()\n  })\n\n  test('mat-snack-bar notification', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Changes saved')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Close'}).click()\n    await expect(page.getByText('Changes saved')).toBeHidden()\n  })\n\n  test('mat-stepper wizard', async ({page}) => {\n    await page.goto('/wizard')\n\n    await expect(page.getByText('Step 1 of 3')).toBeVisible()\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 2 of 3')).toBeVisible()\n    await page.getByLabel('Organization').fill('Acme')\n    await page.getByRole('button', {name: 'Next'}).click()\n\n    await expect(page.getByText('Step 3 of 3')).toBeVisible()\n    await expect(page.getByText('Bob')).toBeVisible()\n    await expect(page.getByText('Acme')).toBeVisible()\n  })\n})\n```\n\n## Router Navigation\n\n```typescript\ntest.describe('Angular Router', () => {\n  test('lazy-loaded module loads on navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.getByRole('link', {name: 'Reports'}).click()\n    await page.waitForURL('/reports')\n\n    await expect(page.getByRole('heading', {name: 'Reports Dashboard'})).toBeVisible()\n  })\n\n  test('route guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/settings')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n  })\n\n  test('resolver prefetches data', async ({page}) => {\n    const resolverPromise = page.waitForResponse('**/api/items/*')\n    await page.goto('/items/42')\n    await resolverPromise\n\n    await expect(page.getByRole('heading', {level: 1})).toContainText('Item')\n  })\n\n  test('nested router-outlet renders children', async ({page}) => {\n    await page.goto('/account/profile')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Profile', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Security'}).click()\n    await page.waitForURL('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n  })\n\n  test('query parameters drive filters', async ({page}) => {\n    await page.goto('/products?type=hardware&page=3')\n\n    await expect(page.getByRole('heading', {name: 'Hardware'})).toBeVisible()\n    await expect(page.getByText('Page 3')).toBeVisible()\n  })\n\n  test('browser back navigates history', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Products'}).click()\n    await page.waitForURL('/products')\n    await page.getByRole('link', {name: 'About'}).click()\n    await page.waitForURL('/about')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/products/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n})\n```\n\n## Lazy-Loaded Modules\n\n```typescript\ntest('lazy module loads without chunk errors', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') consoleErrors.push(msg.text())\n  })\n\n  await page.goto('/')\n\n  const chunkRequest = page.waitForResponse((r) => r.url().includes('.js') && r.status() === 200)\n  await page.getByRole('link', {name: 'Analytics'}).click()\n  await chunkRequest\n\n  await page.waitForURL('/analytics')\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n\n  const chunkErrors = consoleErrors.filter(\n    (e) => e.includes('ChunkLoadError') || e.includes('Loading chunk'),\n  )\n  expect(chunkErrors).toEqual([])\n})\n```\n\n## Signals and Observables\n\nPlaywright cannot subscribe to observables or read signals directly. Test through the rendered output.\n\n```typescript\ntest.describe('signals through UI', () => {\n  test('signal-based counter updates DOM', async ({page}) => {\n    await page.goto('/counter')\n\n    await expect(page.getByTestId('value')).toHaveText('0')\n\n    await page.getByRole('button', {name: 'Increment'}).click()\n    await expect(page.getByTestId('value')).toHaveText('1')\n\n    await page.getByRole('button', {name: 'Reset'}).click()\n    await expect(page.getByTestId('value')).toHaveText('0')\n  })\n\n  test('computed signal updates derived values', async ({page}) => {\n    await page.goto('/cart')\n    await expect(page.getByTestId('total')).toHaveText('$0.00')\n\n    await page.goto('/catalog')\n    await page\n      .getByRole('listitem')\n      .filter({hasText: '$19.99'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await expect(page.getByTestId('total')).toHaveText('$19.99')\n  })\n})\n\ntest.describe('observables through UI', () => {\n  test('debounced search batches API calls', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/search*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('playwright', {\n      delay: 50,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(5)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n\n  test('switchMap cancels stale requests', async ({page}) => {\n    await page.goto('/search')\n\n    await page.getByRole('textbox', {name: 'Search'}).fill('initial')\n    await page.getByRole('textbox', {name: 'Search'}).fill('final')\n\n    await expect(page.getByRole('listitem').first()).toContainText(/final/i)\n  })\n})\n```\n\n## Zone.js and Change Detection\n\nAngular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.\n\n- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.\n- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.\n- **Long-running async**: `setInterval` or long-running observables keep Angular \"not stable.\" This does not affect Playwright (unlike Protractor).\n\n## SSR Testing\n\n```typescript\n// playwright.config.ts for SSR\nwebServer: {\n  command: process.env.CI\n    ? 'npx ng build --ssr && node dist/my-app/server/server.mjs'\n    : 'npx ng serve --ssr',\n  url: 'http://localhost:4200',\n  reuseExistingServer: !process.env.CI,\n  timeout: 180_000,\n},\n```\n\n```typescript\ntest('no hydration errors', async ({page}) => {\n  const errors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error' && msg.text().includes('hydration')) {\n      errors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  expect(errors).toEqual([])\n})\n```\n\n## Protractor Migration Reference\n\n| Protractor                              | Playwright                                     |\n| --------------------------------------- | ---------------------------------------------- |\n| `element(by.css('.btn'))`               | `page.getByRole('button', { name: '...' })`    |\n| `element(by.id('login'))`               | `page.getByTestId('login')`                    |\n| `element(by.buttonText('Submit'))`      | `page.getByRole('button', { name: 'Submit' })` |\n| `element(by.model('user.name'))`        | `page.getByLabel('Name')`                      |\n| `element(by.binding('user.name'))`      | `page.getByText(expectedValue)`                |\n| `element(by.repeater('item in items'))` | `page.getByRole('listitem')`                   |\n| `browser.waitForAngular()`              | Not needed — Playwright auto-waits             |\n| `browser.sleep(3000)`                   | `await expect(locator).toBeVisible()`          |\n| `browser.get('/path')`                  | `await page.goto('/path')`                     |\n| `protractor.ExpectedConditions`         | `await expect(locator).toBeVisible()`          |\n\n## Build Configurations\n\n| Scenario      | Command                                                       | Notes                          |\n| ------------- | ------------------------------------------------------------- | ------------------------------ |\n| Local dev     | `npx ng serve`                                                | Fast rebuild, source maps      |\n| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle        |\n| CI SSR        | `npx ng build --ssr && node dist/app/server/server.mjs`       | Tests server-side rendering    |\n| Staging       | No `webServer`                                                | Point `baseURL` to staging URL |\n\nThe `-s` flag on `http-server` enables SPA fallback for Angular Router.\n\n## CDK Overlay Container\n\nAngular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:\n\n```typescript\nconst dialog = page.getByRole('dialog')\nconst menu = page.getByRole('menu')\nconst listbox = page.getByRole('listbox')\n```\n\n## Anti-Patterns\n\n| Anti-Pattern                         | Problem                                        | Solution                                           |\n| ------------------------------------ | ---------------------------------------------- | -------------------------------------------------- |\n| `page.locator('[_ngcontent-xyz]')`   | Scoped style attributes change every build     | Use `getByRole`, `getByLabel`, `getByTestId`       |\n| `page.locator('[ng-reflect-model]')` | Only exists in dev mode                        | Test rendered value: `expect(input).toHaveValue()` |\n| `page.locator('app-my-component')`   | Component selectors are implementation details | Target rendered content with semantic locators     |\n| `page.locator('.mat-mdc-button')`    | Material classes change between versions       | `page.getByRole('button', { name: '...' })`        |\n| `page.evaluate(() => window.ng)`     | Not available in production builds             | Test through the DOM                               |\n| `await page.waitForTimeout(500)`     | Zone.js timing varies                          | Use auto-retrying assertions                       |\n| `browser.waitForAngular()`           | Does not exist in Playwright                   | Remove entirely                                    |\n| `ng serve` in CI                     | Slower, includes debug code                    | Use `ng build && http-server`                      |\n\n## Related\n\n- [core/locators.md](../core/locators.md) — locator strategies for Angular Material\n- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions\n- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed\n","isInternal":false,"tokens":4588,"sizeBytes":19436},{"name":"nextjs.md","path":".agents/skills/playwright-best-practices/frameworks/nextjs.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/frameworks/nextjs.md","title":"Frameworks Skill","category":"anthropic-skill","format":"markdown","content":"# Next.js Testing Patterns\n\n## Table of Contents\n\n1. [Setup](#setup)\n2. [App Router Patterns](#app-router-patterns)\n3. [Pages Router Patterns](#pages-router-patterns)\n4. [Dynamic Routes](#dynamic-routes)\n5. [API Routes](#api-routes)\n6. [Middleware Testing](#middleware-testing)\n7. [Hydration Testing](#hydration-testing)\n8. [next/image Testing](#nextimage-testing)\n9. [NextAuth.js Authentication](#nextauthjs-authentication)\n10. [Tips](#tips)\n11. [Anti-Patterns](#anti-patterns)\n12. [Related](#related)\n\n> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Setup\n\n### Configuration with webServer\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: process.env.CI ? 'production' : 'test',\n    },\n  },\n})\n```\n\n### Environment Variables\n\nNext.js loads `.env.test` when `NODE_ENV=test`:\n\n```bash\n# .env.test (commit this)\nNEXT_PUBLIC_API_URL=http://localhost:3000/api\nDATABASE_URL=postgresql://localhost:5432/test_db\n\n# .env.test.local (gitignored)\nNEXTAUTH_SECRET=test-secret-local\n```\n\n## App Router Patterns\n\n### Server Component Content\n\n```typescript\ntest('renders server component content', async ({page}) => {\n  await page.goto('/')\n\n  await expect(page.getByRole('heading', {name: 'Welcome', level: 1})).toBeVisible()\n  await expect(page.getByRole('navigation', {name: 'Main'})).toBeVisible()\n})\n```\n\n### Loading States with Streaming\n\n```typescript\ntest('loading state during data streaming', async ({page}) => {\n  await page.route('**/api/stats', async (route) => {\n    await new Promise((r) => setTimeout(r, 2000))\n    await route.continue()\n  })\n\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('progressbar')).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByRole('progressbar')).toBeHidden()\n})\n```\n\n### Nested Layouts\n\n```typescript\ntest('layouts persist across navigation', async ({page}) => {\n  await page.goto('/dashboard/analytics')\n\n  const sidebar = page.getByRole('navigation', {name: 'Dashboard'})\n  await expect(sidebar).toBeVisible()\n\n  await sidebar.getByRole('link', {name: 'Settings'}).click()\n  await page.waitForURL('/dashboard/settings')\n\n  await expect(sidebar).toBeVisible()\n  await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible()\n})\n```\n\n## Pages Router Patterns\n\n### SSR with getServerSideProps\n\n```typescript\ntest('page with getServerSideProps renders data', async ({page}) => {\n  await page.goto('/blog')\n\n  await expect(page.getByRole('heading', {name: 'Blog', level: 1})).toBeVisible()\n  await expect(page.getByRole('article')).toHaveCount(10)\n  await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n})\n```\n\n### Static Generation with getStaticProps\n\n```typescript\ntest('static page shows pre-rendered content', async ({page}) => {\n  await page.goto('/about')\n\n  await expect(page.getByRole('heading', {name: 'About Us'})).toBeVisible()\n  await expect(page.getByText('Founded in 2020')).toBeVisible()\n})\n```\n\n## Dynamic Routes\n\n### Slug Parameters\n\n```typescript\ntest('dynamic [slug] renders correct content', async ({page}) => {\n  await page.goto('/blog/testing-guide')\n\n  await expect(page.getByRole('heading', {level: 1})).toContainText('Testing Guide')\n  await expect(page.getByText('Page not found')).toBeHidden()\n})\n\ntest('non-existent slug shows 404', async ({page}) => {\n  const response = await page.goto('/blog/nonexistent-post')\n\n  expect(response?.status()).toBe(404)\n  await expect(page.getByRole('heading', {name: '404'})).toBeVisible()\n})\n```\n\n### Catch-All Routes\n\n```typescript\ntest('catch-all handles nested paths', async ({page}) => {\n  await page.goto('/docs/getting-started/installation')\n  await expect(page.getByRole('heading', {name: 'Installation'})).toBeVisible()\n\n  await page.goto('/docs/api/configuration')\n  await expect(page.getByRole('heading', {name: 'Configuration'})).toBeVisible()\n})\n```\n\n### Query Parameters\n\n```typescript\ntest('query parameters filter content', async ({page}) => {\n  await page.goto('/products?category=electronics&sort=price-asc')\n\n  await expect(page.getByRole('heading', {name: 'Electronics'})).toBeVisible()\n\n  const prices = await page.getByTestId('product-price').allTextContents()\n  const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')))\n  expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))\n})\n```\n\n## API Routes\n\n### Direct API Testing\n\n```typescript\ntest('GET /api/products returns list', async ({request}) => {\n  const response = await request.get('/api/products')\n\n  expect(response.ok()).toBeTruthy()\n  const body = await response.json()\n  expect(body.products).toBeInstanceOf(Array)\n  expect(body.products[0]).toHaveProperty('id')\n  expect(body.products[0]).toHaveProperty('name')\n})\n\ntest('POST /api/products creates item', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: 'Test Product', price: 29.99},\n  })\n\n  expect(response.status()).toBe(201)\n  const body = await response.json()\n  expect(body.product.name).toBe('Test Product')\n})\n\ntest('POST /api/products validates fields', async ({request}) => {\n  const response = await request.post('/api/products', {\n    data: {name: ''},\n  })\n\n  expect(response.status()).toBe(400)\n  const body = await response.json()\n  expect(body.error).toContainEqual(expect.objectContaining({field: 'price'}))\n})\n```\n\n### API Through UI\n\n```typescript\ntest('form submission calls API', async ({page}) => {\n  await page.goto('/products/new')\n\n  await page.getByLabel('Product name').fill('Widget')\n  await page.getByLabel('Price').fill('19.99')\n  await page.getByRole('button', {name: 'Create product'}).click()\n\n  await expect(page.getByText('Product created successfully')).toBeVisible()\n  await page.waitForURL('/products/**')\n})\n```\n\n## Middleware Testing\n\n### Auth Redirects\n\n```typescript\ntest('unauthenticated user redirected to login', async ({page}) => {\n  await page.goto('/dashboard')\n\n  expect(page.url()).toContain('/login')\n  await expect(page.getByRole('heading', {name: 'Sign in'})).toBeVisible()\n})\n\ntest('redirect preserves return URL', async ({page}) => {\n  await page.goto('/dashboard/settings')\n\n  const url = new URL(page.url())\n  expect(url.pathname).toBe('/login')\n  expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo')).toContain(\n    '/dashboard/settings',\n  )\n})\n```\n\n### Security Headers\n\n```typescript\ntest('middleware sets security headers', async ({page}) => {\n  const response = await page.goto('/')\n\n  const headers = response!.headers()\n  expect(headers['x-frame-options']).toBe('DENY')\n  expect(headers['x-content-type-options']).toBe('nosniff')\n})\n```\n\n### Locale Rewrites\n\n```typescript\ntest('middleware rewrites based on locale', async ({page, context}) => {\n  await context.setExtraHTTPHeaders({\n    'Accept-Language': 'fr-FR,fr;q=0.9',\n  })\n\n  await page.goto('/')\n\n  await expect(page.getByText('Bienvenue')).toBeVisible()\n})\n```\n\n## Hydration Testing\n\n### Console Error Detection\n\n```typescript\ntest('no hydration errors in console', async ({page}) => {\n  const consoleErrors: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'error') {\n      consoleErrors.push(msg.text())\n    }\n  })\n\n  await page.goto('/')\n  await page.getByRole('button', {name: 'Get started'}).click()\n\n  const hydrationErrors = consoleErrors.filter(\n    (e) => e.includes('Hydration') || e.includes('hydration') || e.includes('did not match'),\n  )\n  expect(hydrationErrors).toEqual([])\n})\n```\n\n### Interactive Elements After Hydration\n\n```typescript\ntest('interactive elements work after hydration', async ({page}) => {\n  await page.goto('/')\n\n  const counter = page.getByTestId('counter-value')\n  await expect(counter).toHaveText('0')\n\n  await page.getByRole('button', {name: 'Increment'}).click()\n  await expect(counter).toHaveText('1')\n})\n```\n\n## next/image Testing\n\n```typescript\ntest('hero image loads with srcset', async ({page}) => {\n  await page.goto('/')\n\n  const heroImage = page.getByRole('img', {name: 'Hero banner'})\n  await expect(heroImage).toBeVisible()\n\n  const srcset = await heroImage.getAttribute('srcset')\n  expect(srcset).toBeTruthy()\n  expect(srcset).toContain('w=')\n\n  const loading = await heroImage.getAttribute('loading')\n  expect(loading).not.toBe('lazy')\n})\n\ntest('offscreen images lazy load', async ({page}) => {\n  await page.goto('/gallery')\n\n  const offscreenImage = page.getByRole('img', {name: 'Gallery item 20'})\n\n  await offscreenImage.scrollIntoViewIfNeeded()\n  await expect(offscreenImage).toBeVisible()\n\n  const naturalWidth = await offscreenImage.evaluate((img: HTMLImageElement) => img.naturalWidth)\n  expect(naturalWidth).toBeGreaterThan(0)\n})\n```\n\n## NextAuth.js Authentication\n\n### Setup Project\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'setup', testMatch: /auth\\.setup\\.ts/},\n    {\n      name: 'authenticated',\n      use: {storageState: 'playwright/.auth/user.json'},\n      dependencies: ['setup'],\n    },\n    {name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts'},\n  ],\n})\n```\n\n### Auth Setup\n\n```typescript\n// tests/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/user.json'\n\nsetup('authenticate via credentials', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Sign in'}).click()\n\n  await page.waitForURL('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  await page.context().storageState({path: authFile})\n})\n```\n\n### Authenticated Tests\n\n```typescript\ntest('authenticated user sees dashboard', async ({page}) => {\n  await page.goto('/dashboard')\n\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(page.getByText('test@example.com')).toBeVisible()\n})\n```\n\n## Tips\n\n### Dev Server vs Production Build\n\n| Scenario          | Command                          | Trade-off                              |\n| ----------------- | -------------------------------- | -------------------------------------- |\n| Local development | `npm run dev`                    | Fast iteration, no production behavior |\n| CI pipeline       | `npm run build && npm run start` | Tests real production bundle           |\n\n### Turbopack\n\n```typescript\nwebServer: {\n  command: process.env.CI\n    ? 'npm run build && npm run start'\n    : 'npx next dev --turbopack',\n  url: 'http://localhost:3000',\n  reuseExistingServer: !process.env.CI,\n},\n```\n\n### Multiple webServer Entries\n\n```typescript\nwebServer: [\n  {\n    command: 'npm run dev:api',\n    url: 'http://localhost:4000/health',\n    reuseExistingServer: !process.env.CI,\n  },\n  {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n  },\n],\n```\n\n## Anti-Patterns\n\n| Don't Do This                               | Problem                             | Do This Instead                                                           |\n| ------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |\n| `await page.waitForTimeout(3000)`           | Arbitrary waits are fragile         | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |\n| Test `getServerSideProps` directly          | Depends on req/res context          | Navigate to page and verify rendered output                               |\n| Mock your own API routes                    | Hides real API bugs                 | Let real API handle requests; mock only external services                 |\n| `page.goto('http://localhost:3000/path')`   | Breaks when port changes            | Use `page.goto('/path')` with `baseURL`                                   |\n| Run `npm run build` locally for every test  | Extremely slow                      | Use `npm run dev` locally with `reuseExistingServer: true`                |\n| Test `next/image` by checking exact URLs    | Paths change between dev/prod       | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset`                 |\n| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons)                                       |\n\n## Related\n\n- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`\n- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`\n- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context\n- [react.md](react.md) -- React patterns for Next.js client components\n","isInternal":false,"tokens":3242,"sizeBytes":13697},{"name":"react.md","path":".agents/skills/playwright-best-practices/frameworks/react.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/frameworks/react.md","title":"Frameworks Skill","category":"anthropic-skill","format":"markdown","content":"# React Application Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Setup](#setup)\n3. [Framework Tips](#framework-tips)\n4. [Anti-Patterns](#anti-patterns)\n5. [Related](#related)\n\n> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.\n> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)\n\n## Patterns\n\n### Testing Context and Global State\n\n**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.\n**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('theme switching', () => {\n  test('toggle applies dark mode across pages', async ({page}) => {\n    await page.goto('/preferences')\n\n    const root = page.locator('html')\n    await expect(root).not.toHaveClass(/dark-mode/)\n\n    await page.getByRole('switch', {name: 'Enable dark theme'}).click()\n    await expect(root).toHaveClass(/dark-mode/)\n\n    await page.getByRole('link', {name: 'Dashboard'}).click()\n    await expect(page.locator('html')).toHaveClass(/dark-mode/)\n  })\n})\n\ntest.describe('cart state persistence', () => {\n  test('item count updates globally', async ({page}) => {\n    await page.goto('/catalog')\n\n    const badge = page.getByTestId('cart-badge')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Wireless Headphones'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await expect(badge).toHaveText('1')\n  })\n})\n\ntest.describe('auth state', () => {\n  test('login updates header across components', async ({page}) => {\n    await page.goto('/')\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Login'}).click()\n    await page.getByLabel('Username').fill('testuser')\n    await page.getByLabel('Password').fill('secret123')\n    await page.getByRole('button', {name: 'Submit'}).click()\n\n    await expect(page.getByRole('link', {name: 'Login'})).toBeHidden()\n    await expect(page.getByText('testuser')).toBeVisible()\n  })\n})\n```\n\n### React Router Navigation\n\n**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.\n**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('client routing', () => {\n  test('navigation preserves SPA state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__spaMarker = 'active'\n    })\n\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n\n    const marker = await page.evaluate(() => (window as any).__spaMarker)\n    expect(marker).toBe('active')\n  })\n\n  test('query params filter content', async ({page}) => {\n    await page.goto('/items?type=books')\n\n    await expect(page.getByRole('heading', {name: 'Books'})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Music'}).click()\n    await page.waitForURL('/items?type=music')\n\n    await expect(page.getByRole('heading', {name: 'Music'})).toBeVisible()\n  })\n\n  test('nested routes render layouts', async ({page}) => {\n    await page.goto('/account/security')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible()\n\n    await page.getByRole('link', {name: 'Privacy'}).click()\n    await page.waitForURL('/account/privacy')\n\n    await expect(page.getByRole('heading', {name: 'Account'})).toBeVisible()\n    await expect(page.getByRole('heading', {name: 'Privacy', level: 2})).toBeVisible()\n  })\n\n  test('history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Inventory'}).click()\n    await page.waitForURL('/inventory')\n    await page.getByRole('link', {name: 'Help'}).click()\n    await page.waitForURL('/help')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/inventory/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n  })\n\n  test('protected route redirects', async ({page}) => {\n    await page.goto('/admin/users')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('unknown route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-path')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Testing Hooks Through UI\n\n**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.\n**Avoid when**: Hook logic is pure computation—use unit tests instead.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('useDebounce via SearchBox', () => {\n  test('batches rapid input', async ({page}) => {\n    await page.goto('/search')\n\n    const apiCalls: string[] = []\n    await page.route('**/api/query*', async (route) => {\n      apiCalls.push(route.request().url())\n      await route.continue()\n    })\n\n    await page.getByRole('textbox', {name: 'Search'}).pressSequentially('testing', {\n      delay: 40,\n    })\n\n    await expect(page.getByRole('listitem')).toHaveCount(3)\n    expect(apiCalls.length).toBeLessThanOrEqual(2)\n  })\n})\n\ntest.describe('usePagination via DataGrid', () => {\n  test('page controls work', async ({page}) => {\n    await page.goto('/records')\n\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Next'}).click()\n    await expect(page.getByText('Page 2 of 10')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Previous'}).click()\n    await expect(page.getByText('Page 1 of 10')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Previous'})).toBeDisabled()\n  })\n})\n```\n\n### Form Libraries (React Hook Form, Formik)\n\n**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('signup form', () => {\n  test.beforeEach(async ({page}) => {\n    await page.goto('/signup')\n  })\n\n  test('validation on empty submit', async ({page}) => {\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByText('Email required')).toBeVisible()\n    await expect(page.getByText('Password required')).toBeVisible()\n  })\n\n  test('inline validation on blur', async ({page}) => {\n    const email = page.getByLabel('Email')\n    await email.fill('invalid')\n    await email.blur()\n\n    await expect(page.getByText('Invalid email format')).toBeVisible()\n  })\n\n  test('password strength indicator', async ({page}) => {\n    const pwd = page.getByLabel('Password', {exact: true})\n\n    await pwd.fill('weak')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/)\n\n    await pwd.fill('StrongPass1!')\n    await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/)\n  })\n\n  test('successful submission redirects', async ({page}) => {\n    await page.getByLabel('Name').fill('Alice')\n    await page.getByLabel('Email').fill('alice@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await page.waitForURL('/welcome')\n    await expect(page.getByText('Hello, Alice')).toBeVisible()\n  })\n\n  test('submit button disabled during request', async ({page}) => {\n    await page.route('**/api/signup', async (route) => {\n      await new Promise((r) => setTimeout(r, 800))\n      await route.fulfill({status: 201, json: {id: 1}})\n    })\n\n    await page.getByLabel('Name').fill('Bob')\n    await page.getByLabel('Email').fill('bob@test.com')\n    await page.getByLabel('Password', {exact: true}).fill('Secure123!')\n    await page.getByLabel('Confirm').fill('Secure123!')\n    await page.getByLabel('Accept terms').check()\n\n    await page.getByRole('button', {name: 'Register'}).click()\n\n    await expect(page.getByRole('button', {name: /Registering|Loading/})).toBeDisabled()\n  })\n})\n```\n\n### Portals (Modals, Tooltips, Dropdowns)\n\n**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('portal components', () => {\n  test('modal interaction', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm removal'})\n    await expect(dialog).toBeVisible()\n    await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeFocused()\n\n    await dialog.getByRole('button', {name: 'Remove'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('escape closes modal', async ({page}) => {\n    await page.goto('/items')\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog')\n    await expect(dialog).toBeVisible()\n\n    await page.keyboard.press('Escape')\n    await expect(dialog).toBeHidden()\n  })\n\n  test('tooltip on hover', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Help'}).hover()\n    await expect(page.getByRole('tooltip')).toBeVisible()\n\n    await page.mouse.move(0, 0)\n    await expect(page.getByRole('tooltip')).toBeHidden()\n  })\n\n  test('dropdown menu', async ({page}) => {\n    await page.goto('/panel')\n\n    await page.getByRole('button', {name: 'Actions'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await menu.getByRole('menuitem', {name: 'Rename'}).click()\n    await expect(menu).toBeHidden()\n  })\n\n  test('toast auto-dismisses', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('button', {name: 'Save'}).click()\n    await expect(page.getByText('Preferences saved')).toBeVisible()\n\n    await expect(page.getByText('Preferences saved')).toBeHidden({timeout: 8000})\n  })\n})\n```\n\n### Error Boundaries\n\n**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.\n**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('error boundary', () => {\n  test('shows fallback on crash', async ({page}) => {\n    await page.route('**/api/widgets', (route) => {\n      route.fulfill({\n        status: 200,\n        json: {widgets: null},\n      })\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n    await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n    await expect(page.getByRole('navigation')).toBeVisible()\n  })\n\n  test('retry recovers component', async ({page}) => {\n    let calls = 0\n    await page.route('**/api/widgets', (route) => {\n      calls++\n      if (calls === 1) {\n        route.fulfill({status: 200, json: {widgets: null}})\n      } else {\n        route.fulfill({status: 200, json: {widgets: [{id: 1, name: 'Chart'}]}})\n      }\n    })\n\n    await page.goto('/panel')\n\n    await expect(page.getByText('Something went wrong')).toBeVisible()\n\n    await page.getByRole('button', {name: 'Retry'}).click()\n\n    await expect(page.getByText('Something went wrong')).toBeHidden()\n    await expect(page.getByText('Chart')).toBeVisible()\n  })\n})\n```\n\n### Component Testing (Experimental)\n\n**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.\n**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-react'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport { test, expect } from '@playwright/experimental-ct-react';\nimport Stepper from '../../src/components/Stepper';\n\ntest('increments on click', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} />);\n\n  await expect(component.getByText('Value: 0')).toBeVisible();\n  await component.getByRole('button', { name: '+' }).click();\n  await expect(component.getByText('Value: 1')).toBeVisible();\n});\n\ntest('fires onChange callback', async ({ mount }) => {\n  const values: number[] = [];\n  const component = await mount(\n    <Stepper initial={0} onChange={(v) => values.push(v)} />\n  );\n\n  await component.getByRole('button', { name: '+' }).click();\n  await component.getByRole('button', { name: '+' }).click();\n\n  expect(values).toEqual([1, 2]);\n});\n\ntest('respects min boundary', async ({ mount }) => {\n  const component = await mount(<Stepper initial={0} min={0} />);\n\n  await expect(component.getByRole('button', { name: '-' })).toBeDisabled();\n});\n```\n\n## Setup\n\n### E2E Config (Vite)\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### CRA vs Vite Differences\n\n| Aspect           | Create React App             | Vite                           |\n| ---------------- | ---------------------------- | ------------------------------ |\n| Default port     | `3000`                       | `5173`                         |\n| Build output     | `build/`                     | `dist/`                        |\n| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |\n| Env var prefix   | `REACT_APP_*`                | `VITE_*`                       |\n\n## Framework Tips\n\n### Strict Mode Double Effects\n\nReact Strict Mode runs effects twice in development. Tests should be resilient:\n\n- Don't assert exact API call counts in dev mode\n- Run against production build for call count assertions, or account for double invocations\n\n### Suspense and Lazy Components\n\n```typescript\ntest('lazy route loads content', async ({page}) => {\n  await page.goto('/')\n\n  await page.getByRole('link', {name: 'Analytics'}).click()\n\n  await expect(page.getByRole('heading', {name: 'Analytics'})).toBeVisible()\n})\n```\n\n### Detecting Memory Leaks\n\n```typescript\ntest('no unmounted state warnings', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('unmounted')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/panel')\n  await page.getByRole('link', {name: 'Settings'}).click()\n  await page.goBack()\n  await page.getByRole('link', {name: 'Profile'}).click()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Don't                                          | Problem                             | Do Instead                                                  |\n| ---------------------------------------------- | ----------------------------------- | ----------------------------------------------------------- |\n| `page.evaluate(() => store.getState())`        | Couples tests to implementation     | Assert on UI: `expect(badge).toHaveText('3')`               |\n| Import components in E2E tests                 | E2E runs in Node, not browser       | Use `@playwright/experimental-ct-react` for components      |\n| `page.waitForTimeout(500)` after state changes | Timing varies across machines       | `expect(locator).toHaveText('value')` auto-retries          |\n| `page.locator('.MuiButton-root')`              | Class names change between versions | `page.getByRole('button', { name: 'Submit' })`              |\n| Test every component with CT                   | Overhead for simple components      | CT for complex widgets, unit tests for logic, E2E for flows |\n| Skip keyboard navigation tests                 | Accessibility regressions common    | Test Tab, Enter, Escape, Arrow interactions                 |\n| Assert on `__REACT_FIBER__` internals          | Not stable across versions          | Only interact with rendered DOM                             |\n\n## Related\n\n- [locators.md](../core/locators.md) — locator strategies for any React component library\n- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes\n- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns\n- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing\n- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions\n- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR\n","isInternal":false,"tokens":4302,"sizeBytes":17961},{"name":"vue.md","path":".agents/skills/playwright-best-practices/frameworks/vue.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/frameworks/vue.md","title":"Frameworks Skill","category":"anthropic-skill","format":"markdown","content":"# Vue and Nuxt Testing\n\n## Table of Contents\n\n1. [Commands](#commands)\n2. [Configuration](#configuration)\n3. [Patterns](#patterns)\n4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)\n5. [Component Testing Dependencies](#component-testing-dependencies)\n6. [Testing v-model](#testing-v-model)\n7. [Capturing Vue Warnings](#capturing-vue-warnings)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.\n\n## Commands\n\n```bash\nnpm init playwright@latest\nnpm install -D @playwright/experimental-ct-vue\nnpx playwright test\nnpx playwright test -c playwright-ct.config.ts\n```\n\n## Configuration\n\n### Vue with Vite\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  use: {\n    baseURL: 'http://localhost:5173',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'mobile', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',\n    url: 'http://localhost:5173',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n})\n```\n\n### Nuxt 3\n\nNuxt uses port 3000 and requires a build step before testing.\n\n```typescript\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './tests/e2e',\n  testMatch: '**/*.spec.ts',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n\n  webServer: {\n    command: process.env.CI ? 'npx nuxi build && npx nuxi preview' : 'npx nuxi dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',\n    },\n  },\n})\n```\n\n### Component Testing\n\n```typescript\n// playwright-ct.config.ts\nimport {defineConfig, devices} from '@playwright/experimental-ct-vue'\n\nexport default defineConfig({\n  testDir: './tests/components',\n  testMatch: '**/*.ct.ts',\n\n  use: {\n    trace: 'on-first-retry',\n    ctPort: 3100,\n  },\n\n  projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],\n})\n```\n\n## Patterns\n\n### Component Testing with Experimental CT\n\n**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).\n\n**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.\n\n```typescript\n// tests/components/Stepper.ct.ts\nimport {test, expect} from '@playwright/experimental-ct-vue'\n\nimport Stepper from '../../src/components/Stepper.vue'\n\ntest('increments value on button click', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n  })\n\n  await expect(component.getByText('Value: 0')).toBeVisible()\n  await component.getByRole('button', {name: '+'}).click()\n  await expect(component.getByText('Value: 1')).toBeVisible()\n})\n\ntest('emits change event', async ({mount}) => {\n  const changes: number[] = []\n  const component = await mount(Stepper, {\n    props: {value: 10},\n    on: {\n      change: (val: number) => changes.push(val),\n    },\n  })\n\n  await component.getByRole('button', {name: '+'}).click()\n  await component.getByRole('button', {name: '+'}).click()\n\n  expect(changes).toEqual([11, 12])\n})\n\ntest('renders slot content', async ({mount}) => {\n  const component = await mount(Stepper, {\n    props: {value: 0},\n    slots: {\n      default: '<span class=\"label\">Quantity</span>',\n    },\n  })\n\n  await expect(component.getByText('Quantity')).toBeVisible()\n})\n```\n\n### Pinia Store Testing Through UI\n\n**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.\n\n**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('shopping cart store', () => {\n  test('adding products updates cart badge', async ({page}) => {\n    await page.goto('/shop')\n\n    const badge = page.getByTestId('cart-badge')\n    await expect(badge).toHaveText('0')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('1')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Cap'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n    await expect(badge).toHaveText('2')\n\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.waitForURL('/cart')\n\n    await expect(page.getByText('Hoodie')).toBeVisible()\n    await expect(page.getByText('Cap')).toBeVisible()\n  })\n\n  test('persisted state survives reload', async ({page}) => {\n    await page.goto('/shop')\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Hoodie'})\n      .getByRole('button', {name: 'Add'})\n      .click()\n\n    await page.reload()\n\n    await expect(page.getByTestId('cart-badge')).toHaveText('1')\n  })\n})\n```\n\n### Vue Router Navigation\n\n**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('router navigation', () => {\n  test('client-side navigation preserves state', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('dynamic route params render content', async ({page}) => {\n    await page.goto('/items/99')\n\n    await expect(page.getByRole('heading', {level: 1})).toBeVisible()\n    await expect(page.getByText('Item #99')).toBeVisible()\n  })\n\n  test('navigation guard redirects unauthorized users', async ({page}) => {\n    await page.goto('/admin/dashboard')\n\n    await expect(page).toHaveURL(/\\/login/)\n    await expect(page.getByRole('heading', {name: 'Login'})).toBeVisible()\n  })\n\n  test('browser history navigation works', async ({page}) => {\n    await page.goto('/')\n    await page.getByRole('link', {name: 'Shop'}).click()\n    await page.waitForURL('/shop')\n    await page.getByRole('link', {name: 'Contact'}).click()\n    await page.waitForURL('/contact')\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/shop/)\n\n    await page.goBack()\n    await expect(page).toHaveURL(/\\/$/)\n\n    await page.goForward()\n    await expect(page).toHaveURL(/\\/shop/)\n  })\n\n  test('query params update reactive state', async ({page}) => {\n    await page.goto('/items?sort=price&type=clothing')\n\n    await expect(page.getByRole('heading', {name: 'Clothing'})).toBeVisible()\n\n    await page.getByRole('combobox', {name: 'Sort'}).selectOption('name')\n    await expect(page).toHaveURL(/sort=name/)\n  })\n\n  test('catch-all route shows 404', async ({page}) => {\n    await page.goto('/nonexistent-page')\n\n    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()\n  })\n})\n```\n\n### Teleport Components\n\n**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('teleported elements', () => {\n  test('modal is visible and interactive', async ({page}) => {\n    await page.goto('/items')\n\n    await page.getByRole('button', {name: 'Remove'}).first().click()\n\n    const dialog = page.getByRole('dialog', {name: 'Confirm'})\n    await expect(dialog).toBeVisible()\n\n    await dialog.getByRole('button', {name: 'Cancel'}).click()\n    await expect(dialog).toBeHidden()\n  })\n\n  test('notification auto-dismisses', async ({page}) => {\n    await page.goto('/profile')\n\n    await page.getByRole('button', {name: 'Update'}).click()\n\n    const alert = page.getByRole('alert')\n    await expect(alert).toBeVisible()\n    await expect(alert).toContainText('Saved')\n\n    await expect(alert).toBeHidden({timeout: 10_000})\n  })\n\n  test('dropdown closes on outside click', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('button', {name: 'Menu'}).click()\n\n    const menu = page.getByRole('menu')\n    await expect(menu).toBeVisible()\n\n    await page.locator('body').click({position: {x: 10, y: 10}})\n    await expect(menu).toBeHidden()\n  })\n})\n```\n\n### Transitions and Animations\n\n**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('transitions', () => {\n  test('item appears after add', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Write tests')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Write tests')).toBeVisible()\n  })\n\n  test('item disappears after delete', async ({page}) => {\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Temp item')\n    await page.getByRole('button', {name: 'Add'}).click()\n    await expect(page.getByText('Temp item')).toBeVisible()\n\n    await page\n      .getByRole('listitem')\n      .filter({hasText: 'Temp item'})\n      .getByRole('button', {name: 'Remove'})\n      .click()\n\n    await expect(page.getByText('Temp item')).toBeHidden()\n  })\n\n  test('disable animations for faster tests', async ({page}) => {\n    await page.addStyleTag({\n      content: `\n        *, *::before, *::after {\n          animation-duration: 0s !important;\n          animation-delay: 0s !important;\n          transition-duration: 0s !important;\n          transition-delay: 0s !important;\n        }\n      `,\n    })\n\n    await page.goto('/tasks')\n\n    await page.getByRole('textbox', {name: 'Task'}).fill('Quick task')\n    await page.getByRole('button', {name: 'Add'}).click()\n\n    await expect(page.getByText('Quick task')).toBeVisible()\n  })\n})\n```\n\n### Composition API Components\n\n**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('composition API', () => {\n  test('computed properties update reactively', async ({page}) => {\n    await page.goto('/pricing')\n\n    await page.getByLabel('Amount').fill('50')\n    await page.getByLabel('Qty').fill('4')\n\n    await expect(page.getByTestId('sum')).toHaveText('$200.00')\n\n    await page.getByLabel('Discount').fill('20')\n    await expect(page.getByTestId('sum')).toHaveText('$160.00')\n  })\n\n  test('watcher triggers on change', async ({page}) => {\n    await page.goto('/preferences')\n\n    await page.getByRole('combobox', {name: 'Locale'}).selectOption('de')\n\n    await expect(page.getByRole('heading', {name: 'Einstellungen'})).toBeVisible()\n  })\n\n  test('composable provides debounced search', async ({page}) => {\n    await page.goto('/shop')\n\n    const input = page.getByRole('textbox', {name: 'Search'})\n    await input.pressSequentially('hoodie', {delay: 50})\n\n    await expect(page.getByRole('listitem')).toHaveCount(2)\n    await expect(page.getByText('Black Hoodie')).toBeVisible()\n  })\n\n  test('provide/inject updates all consumers', async ({page}) => {\n    await page.goto('/home')\n\n    await page.getByRole('switch', {name: 'Dark theme'}).click()\n\n    await expect(page.locator('body')).toHaveClass(/dark/)\n  })\n})\n```\n\n### Nuxt-Specific Patterns\n\n**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('nuxt features', () => {\n  test('SSR renders server-fetched data', async ({page}) => {\n    await page.goto('/posts')\n\n    await expect(page.getByRole('article')).toHaveCount(10)\n    await expect(page.getByRole('article').first()).toContainText(/\\w+/)\n  })\n\n  test('server route returns data', async ({request}) => {\n    const response = await request.get('/api/items')\n\n    expect(response.ok()).toBeTruthy()\n    const data = await response.json()\n    expect(data).toBeInstanceOf(Array)\n    expect(data[0]).toHaveProperty('id')\n  })\n\n  test('middleware redirects unauthorized', async ({page}) => {\n    await page.goto('/admin')\n\n    await expect(page).toHaveURL(/\\/login/)\n  })\n\n  test('NuxtLink enables SPA navigation', async ({page}) => {\n    await page.goto('/')\n\n    await page.evaluate(() => {\n      ;(window as any).__marker = 'spa'\n    })\n\n    await page.getByRole('link', {name: 'Posts'}).click()\n    await page.waitForURL('/posts')\n\n    const marker = await page.evaluate(() => (window as any).__marker)\n    expect(marker).toBe('spa')\n  })\n\n  test('useHead sets meta tags', async ({page}) => {\n    await page.goto('/posts/hello-world')\n\n    const title = await page.title()\n    expect(title).toContain('Hello World')\n\n    const desc = await page.locator('meta[name=\"description\"]').getAttribute('content')\n    expect(desc).toBeTruthy()\n    expect(desc!.length).toBeGreaterThan(50)\n  })\n})\n```\n\n## Vue vs Nuxt Differences\n\n| Aspect             | Vue 3 (Vite)                        | Nuxt 3                                      |\n| ------------------ | ----------------------------------- | ------------------------------------------- |\n| Default port       | `5173`                              | `3000`                                      |\n| Dev command        | `npm run dev`                       | `npx nuxi dev`                              |\n| Build + preview    | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview`        |\n| SSR                | Optional                            | Built-in                                    |\n| API routes         | External backend                    | `/server/api/` built-in                     |\n| Env variables      | `VITE_*` prefix                     | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |\n| File-based routing | No                                  | Yes                                         |\n\n## Component Testing Dependencies\n\nComponents depending on Pinia or Vue Router need these provided:\n\n```typescript\n// playwright/index.ts\nimport {beforeMount} from '@playwright/experimental-ct-vue/hooks'\nimport {createPinia} from 'pinia'\nimport {createMemoryHistory, createRouter} from 'vue-router'\n\nbeforeMount(async ({app, hooksConfig}) => {\n  const pinia = createPinia()\n  app.use(pinia)\n\n  if (hooksConfig?.routes) {\n    const router = createRouter({\n      history: createMemoryHistory(),\n      routes: hooksConfig.routes,\n    })\n    app.use(router)\n  }\n})\n```\n\n## Testing v-model\n\n`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:\n\n```typescript\nawait page.getByLabel('Email').fill('user@test.com')\nawait page.getByRole('checkbox', {name: 'Subscribe'}).check()\nawait page.getByRole('combobox', {name: 'Country'}).selectOption('US')\n```\n\n## Capturing Vue Warnings\n\n```typescript\ntest('no Vue warnings during render', async ({page}) => {\n  const warnings: string[] = []\n  page.on('console', (msg) => {\n    if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {\n      warnings.push(msg.text())\n    }\n  })\n\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n\n  expect(warnings).toEqual([])\n})\n```\n\n## Anti-Patterns\n\n| Avoid                                                                 | Problem                                            | Instead                                                   |\n| --------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |\n| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades         | Assert on UI that state produces                          |\n| `page.locator('[data-v-abc123]')`                                     | Scoped style hashes change on every build          | Use `getByRole`, `getByText`, `getByTestId`               |\n| Import `.vue` files in E2E tests                                      | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |\n| `page.waitForTimeout(300)` for transitions                            | Arbitrary waits are fragile                        | `await expect(locator).toBeVisible()` auto-waits          |\n| Mock Pinia by patching `window.__pinia`                               | Fragile; may not trigger reactivity                | Control state through UI or mock API responses            |\n| Test composables via `page.evaluate`                                  | Composables need Vue's setup context               | Test through components or unit test with Vitest          |\n| `page.locator('.v-btn')` for Vuetify                                  | Class names change between versions                | `page.getByRole('button', { name: 'Submit' })`            |\n| Run Nuxt dev server in CI                                             | Dev mode is slower with hot reload overhead        | Use `npx nuxi build && npx nuxi preview`                  |\n","isInternal":false,"tokens":4295,"sizeBytes":17879},{"name":"ci-cd.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"# CI/CD Integration\n\n## Table of Contents\n\n1. [GitHub Actions](#github-actions)\n2. [Docker](#docker)\n3. [Reporting](#reporting)\n4. [Sharding](#sharding)\n5. [Environment Management](#environment-management)\n6. [Caching](#caching)\n\n## GitHub Actions\n\n### Basic Workflow\n\n```yaml\n# .github/workflows/playwright.yml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 30\n```\n\n### With Sharding\n\n```yaml\nname: Playwright Tests\n\non:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shardIndex: [1, 2, 3, 4]\n        shardTotal: [4]\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps\n\n      - name: Run Playwright tests\n        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n\n      - name: Upload blob report\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: blob-report-${{ matrix.shardIndex }}\n          path: blob-report\n          retention-days: 1\n\n  merge-reports:\n    if: ${{ !cancelled() }}\n    needs: [test]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter html ./all-blob-reports\n\n      - name: Upload HTML report\n        uses: actions/upload-artifact@v4\n        with:\n          name: html-report\n          path: playwright-report\n          retention-days: 14\n```\n\n### With Container\n\n```yaml\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    container:\n      # Use latest or more appropriate playwright version (match package.json)\n      image: mcr.microsoft.com/playwright:v1.40.0-jammy\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n```\n\n## Docker\n\n### Dockerfile\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.40.0-jammy\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\n### Docker Compose\n\n```yaml\n# docker-compose.yml\nversion: '3.8'\n\nservices:\n  playwright:\n    build: .\n    volumes:\n      - ./playwright-report:/app/playwright-report\n      - ./test-results:/app/test-results\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n\n  app:\n    build: ./app\n    ports:\n      - '3000:3000'\n```\n\n### Run with Docker\n\n```bash\n# Build and run\ndocker build -t playwright-tests .\ndocker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests\n\n# With docker-compose\ndocker-compose run --rm playwright\n```\n\n## Reporting\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: [\n    // Always generate\n    ['html', {outputFolder: 'playwright-report'}],\n\n    // Console output\n    ['list'],\n\n    // CI-friendly\n    ['github'], // GitHub Actions annotations\n\n    // JUnit for CI integration\n    ['junit', {outputFile: 'results.xml'}],\n\n    // JSON for custom processing\n    ['json', {outputFile: 'results.json'}],\n\n    // Blob for merging shards\n    ['blob', {outputDir: 'blob-report'}],\n  ],\n})\n```\n\n### CI-Specific Reporter\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n})\n```\n\n## Sharding\n\n### Command Line\n\n```bash\n# Split into 4 shards, run shard 1\nnpx playwright test --shard=1/4\n\n# Run shard 2\nnpx playwright test --shard=2/4\n```\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  // Evenly distribute tests across shards\n  fullyParallel: true,\n\n  // For blob reporter to merge later\n  reporter: process.env.CI ? [['blob']] : [['html']],\n})\n```\n\n### Merge Sharded Reports\n\n```bash\n# After all shards complete, merge blob reports\nnpx playwright merge-reports --reporter html ./all-blob-reports\n```\n\n## Environment Management\n\n### Environment Variables\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\n// Load env file based on environment\ndotenv.config({path: `.env.${process.env.NODE_ENV || 'development'}`})\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n### Multiple Environments\n\n```yaml\n# .github/workflows/playwright.yml\njobs:\n  test:\n    strategy:\n      matrix:\n        environment: [staging, production]\n    steps:\n      - name: Run tests\n        run: npx playwright test\n        env:\n          BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}\n          TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}\n```\n\n### Secrets Management\n\n```yaml\n# GitHub Actions secrets\n- name: Run tests\n  run: npx playwright test\n  env:\n    TEST_EMAIL: ${{ secrets.TEST_EMAIL }}\n    TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n```typescript\n// tests use environment variables\ntest('login', async ({page}) => {\n  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n})\n```\n\n## Caching\n\n### Cache Playwright Browsers\n\n```yaml\n- name: Cache Playwright browsers\n  uses: actions/cache@v4\n  id: playwright-cache\n  with:\n    path: ~/.cache/ms-playwright\n    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n- name: Install Playwright browsers\n  if: steps.playwright-cache.outputs.cache-hit != 'true'\n  run: npx playwright install --with-deps\n\n- name: Install system deps only\n  if: steps.playwright-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Cache Node Modules\n\n```yaml\n- uses: actions/setup-node@v4\n  with:\n    node-version: 22\n    cache: 'npm'\n\n- name: Install dependencies\n  run: npm ci\n```\n\n## Tag-Based Test Filtering\n\n### Run Specific Tags in CI\n\n```yaml\n# Run smoke tests on PR\n- name: Run smoke tests\n  run: npx playwright test --grep @smoke\n\n# Run full regression nightly\n- name: Run regression\n  run: npx playwright test --grep @regression\n\n# Exclude flaky tests\n- name: Run stable tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### PR vs Nightly Strategy\n\n```yaml\n# .github/workflows/pr.yml - Fast feedback\n- name: Run critical tests\n  run: npx playwright test --grep \"@smoke|@critical\"\n\n# .github/workflows/nightly.yml - Full coverage\n- name: Run all tests\n  run: npx playwright test --grep-invert @flaky\n```\n\n### Tag Filtering in Config\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n### Project-Based Tag Filtering\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n    },\n  ],\n})\n```\n\n## Best Practices\n\n| Practice                      | Benefit                   |\n| ----------------------------- | ------------------------- |\n| Use `npm ci`                  | Deterministic installs    |\n| Run headless in CI            | Faster, no display needed |\n| Set retries in CI only        | Handle flakiness          |\n| Upload artifacts on failure   | Debug failures            |\n| Use sharding for large suites | Faster execution          |\n| Cache browsers                | Faster setup              |\n| Use blob reporter for shards  | Merge reports correctly   |\n| Use tags for PR vs nightly    | Fast feedback + coverage  |\n| Exclude @flaky in CI          | Stable pipeline           |\n\n## CI Configuration Reference\n\n```typescript\n// playwright.config.ts - CI optimized\nexport default defineConfig({\n  testDir: './tests',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: process.env.CI ? [['github'], ['blob'], ['html']] : [['list'], ['html']],\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'on-first-retry',\n  },\n})\n```\n\n## Related References\n\n- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns\n- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization\n- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage\n","isInternal":false,"tokens":2563,"sizeBytes":9963},{"name":"docker.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"# Container-Based Testing\n\n## Table of Contents\n\n1. [Patterns](#patterns)\n2. [Decision Guide](#decision-guide)\n3. [Anti-Patterns](#anti-patterns)\n4. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.\n\n## Patterns\n\n### Official Image Usage\n\nRun tests without building a custom image:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -w /app \\\n  -e CI=true \\\n  -e BASE_URL=http://host.docker.internal:3000 \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\nExtract reports with bind mounts:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app \\\n  -v $(pwd)/playwright-report:/app/playwright-report \\\n  -v $(pwd)/test-results:/app/test-results \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n\n### Custom Dockerfile\n\nBuild a custom image when you need additional dependencies or pre-installed packages:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\n\nWORKDIR /app\n\nCOPY package.json package-lock.json ./\nRUN npm ci\n\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nChromium-only slim image:\n\n```dockerfile\nFROM node:latest-slim\n\nRUN npx playwright install --with-deps chromium\n\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY . .\n\nCMD [\"npx\", \"playwright\", \"test\", \"--project=chromium\"]\n```\n\n### Docker Compose Stack\n\nFull application stack with database, cache, and test runner:\n\n```yaml\nservices:\n  app:\n    build: .\n    ports:\n      - '3000:3000'\n    environment:\n      - NODE_ENV=test\n      - DATABASE_URL=postgresql://postgres:postgres@db:5432/test\n    depends_on:\n      db:\n        condition: service_healthy\n\n  db:\n    image: postgres:latest-alpine\n    environment:\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: postgres\n      POSTGRES_DB: test\n    healthcheck:\n      test: ['CMD-SHELL', 'pg_isready -U postgres']\n      interval: 5s\n      timeout: 5s\n      retries: 5\n    tmpfs:\n      - /var/lib/postgresql/data\n\n  e2e:\n    image: mcr.microsoft.com/playwright:v1.48.0-noble\n    working_dir: /app\n    volumes:\n      - .:/app\n      - /app/node_modules\n    environment:\n      - CI=true\n      - BASE_URL=http://app:3000\n    depends_on:\n      - app\n    command: bash -c \"npm ci && npx playwright test\"\n    profiles:\n      - test\n```\n\nRun commands:\n\n```bash\ndocker compose --profile test up --abort-on-container-exit --exit-code-from e2e\n\ndocker compose --profile test down -v\n```\n\n### CI Container Jobs\n\n**GitHub Actions:**\n\n```yaml\njobs:\n  test:\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n    steps:\n      - uses: actions/checkout@v4\n      - run: npm ci\n      - run: npx playwright test\n        env:\n          HOME: /root\n```\n\n**GitLab CI:**\n\n```yaml\ntest:\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  script:\n    - npm ci\n    - npx playwright test\n```\n\n**Jenkins:**\n\n```groovy\npipeline {\n    agent {\n        docker {\n            image 'mcr.microsoft.com/playwright:v1.48.0-noble'\n            args '-u root'\n        }\n    }\n    stages {\n        stage('Test') {\n            steps {\n                sh 'npm ci'\n                sh 'npx playwright test'\n            }\n        }\n    }\n}\n```\n\n### Dev Container Setup\n\nVS Code Dev Container or GitHub Codespaces configuration:\n\n```json\n{\n  \"name\": \"Playwright Dev\",\n  \"image\": \"mcr.microsoft.com/playwright:v1.48.0-noble\",\n  \"features\": {\n    \"ghcr.io/devcontainers/features/node:latest\": {\n      \"version\": \"20\"\n    }\n  },\n  \"postCreateCommand\": \"npm ci\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"ms-playwright.playwright\"]\n    }\n  },\n  \"forwardPorts\": [3000, 9323],\n  \"remoteUser\": \"root\"\n}\n```\n\n## Decision Guide\n\n| Scenario                          | Approach                                       |\n| --------------------------------- | ---------------------------------------------- |\n| Simple CI pipeline                | Official image as CI container                 |\n| Tests need database + cache       | Docker Compose with app, db, e2e services      |\n| Team needs identical environments | Dev Container or custom Dockerfile             |\n| Only testing Chromium             | Slim image with `install --with-deps chromium` |\n| Cross-browser testing             | Official image (all browsers pre-installed)    |\n| Local development                 | Run directly on host for faster iteration      |\n\n## Anti-Patterns\n\n| Anti-Pattern                                     | Problem                            | Solution                                                        |\n| ------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------- |\n| Installing browsers at runtime                   | Wastes 60-90 seconds per run       | Use official image or bake browsers into custom image           |\n| Running as non-root without sandbox config       | Chromium sandbox permission errors | Run as root or disable sandbox                                  |\n| Bind-mounting `node_modules` from host           | Platform-specific binary crashes   | Use anonymous volume: `-v /app/node_modules`                    |\n| No health checks on dependent services           | Tests start before database ready  | Add `healthcheck` with `depends_on: condition: service_healthy` |\n| Building application inside Playwright container | Large image, slow builds           | Separate app and e2e containers                                 |\n\n## Troubleshooting\n\n### \"browserType.launch: Executable doesn't exist\"\n\nPlaywright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:\n\n```bash\nnpm ls @playwright/test\ndocker pull mcr.microsoft.com/playwright:v<matching-version>-noble\n```\n\n### \"net::ERR_CONNECTION_REFUSED\" in docker-compose\n\nTests trying to reach `localhost` instead of service name. Configure `baseURL`:\n\n```typescript\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n  },\n})\n```\n\n```yaml\ne2e:\n  environment:\n    - BASE_URL=http://app:3000\n```\n\n### Permission denied on mounted volumes\n\nMatch user IDs or run as root:\n\n```bash\ndocker run --rm -u $(id -u):$(id -g) \\\n  -v $(pwd):/app -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  npx playwright test\n```\n\n### Slow container tests on macOS/Windows\n\nDocker Desktop I/O overhead. Copy files instead of mounting:\n\n```dockerfile\nFROM mcr.microsoft.com/playwright:v1.48.0-noble\nWORKDIR /app\nCOPY . .\nRUN npm ci\nCMD [\"npx\", \"playwright\", \"test\"]\n```\n\nOr use delegated mount:\n\n```bash\ndocker run --rm \\\n  -v $(pwd):/app:delegated \\\n  -w /app \\\n  mcr.microsoft.com/playwright:v1.48.0-noble \\\n  bash -c \"npm ci && npx playwright test\"\n```\n","isInternal":false,"tokens":1705,"sizeBytes":6917},{"name":"github-actions.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"# GitHub Actions for Playwright\n\n## Table of Contents\n\n1. [CLI Commands](#cli-commands)\n2. [Workflow Patterns](#workflow-patterns)\n3. [Scenario Guide](#scenario-guide)\n4. [Common Mistakes](#common-mistakes)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.\n\n## CLI Commands\n\n```bash\nnpx playwright install --with-deps    # browsers + OS dependencies\nnpx playwright test --shard=1/4       # run shard 1 of 4\nnpx playwright test --reporter=github # PR annotations\nnpx playwright merge-reports ./blob-report  # combine shard reports\n```\n\n## Workflow Patterns\n\n### Basic Workflow\n\n**Use when**: Starting a new project or running a small test suite.\n\n```yaml\n# .github/workflows/e2e.yml\nname: E2E Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - run: npx playwright test\n\n      - name: Upload report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n\n      - name: Upload traces\n        uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: traces\n          path: test-results/\n          retention-days: 7\n```\n\n### Sharded Execution\n\n**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.\n**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.\n\n```yaml\n# .github/workflows/e2e-sharded.yml\nname: E2E Tests (Sharded)\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nconcurrency:\n  group: e2e-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  CI: true\n\njobs:\n  test:\n    timeout-minutes: 20\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1/4, 2/4, 3/4, 4/4]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests (shard ${{ matrix.shard }})\n        run: npx playwright test --shard=${{ matrix.shard }}\n\n      - name: Upload blob report\n        uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: blob-${{ strategy.job-index }}\n          path: blob-report/\n          retention-days: 1\n\n  merge:\n    if: ${{ !cancelled() }}\n    needs: test\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blobs\n          pattern: blob-*\n          merge-multiple: true\n\n      - name: Merge reports\n        run: npx playwright merge-reports --reporter=html ./all-blobs\n\n      - name: Upload merged report\n        uses: actions/upload-artifact@v4\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Config for sharding**—enable blob reporter:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['github']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Container-Based Execution\n\n**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.\n**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.\n\n```yaml\n# .github/workflows/e2e-container.yml\nname: E2E Tests (Container)\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    container:\n      image: mcr.microsoft.com/playwright:v1.48.0-noble\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Run tests\n        run: npx playwright test\n        env:\n          HOME: /root\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Environment Secrets\n\n**Use when**: Tests target staging/production with credentials.\n**Avoid when**: Tests only run against local dev server.\n\n```yaml\n# .github/workflows/e2e-staging.yml\nname: Staging Tests\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n    environment: staging\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n      TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}\n      API_TOKEN: ${{ secrets.API_TOKEN }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run smoke tests\n        run: npx playwright test --grep @smoke\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: staging-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n### Scheduled Runs\n\n**Use when**: Full regression suite is too slow for every PR—run nightly instead.\n**Avoid when**: Suite runs under 15 minutes and can run on every PR.\n\n```yaml\n# .github/workflows/nightly.yml\nname: Nightly Regression\n\non:\n  schedule:\n    - cron: '0 3 * * 1-5'\n  workflow_dispatch:\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ vars.STAGING_URL }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: npm ci\n\n      - name: Install browsers\n        run: npx playwright install --with-deps\n\n      - name: Run full regression\n        run: npx playwright test --grep @regression\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: nightly-${{ github.run_number }}\n          path: playwright-report/\n          retention-days: 30\n\n      - name: Notify on failure\n        if: failure()\n        uses: slackapi/slack-github-action@latest\n        with:\n          payload: |\n            {\n              \"text\": \"Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"\n            }\n        env:\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n```\n\n### Reusable Workflow\n\n**Use when**: Multiple repositories share the same Playwright setup.\n**Avoid when**: Single repo with one workflow.\n\n```yaml\n# .github/workflows/pw-reusable.yml\nname: Playwright Reusable\n\non:\n  workflow_call:\n    inputs:\n      node-version:\n        type: string\n        default: 'lts/*'\n      test-command:\n        type: string\n        default: 'npx playwright test'\n    secrets:\n      BASE_URL:\n        required: false\n      TEST_PASSWORD:\n        required: false\n\njobs:\n  test:\n    timeout-minutes: 30\n    runs-on: ubuntu-latest\n\n    env:\n      CI: true\n      BASE_URL: ${{ secrets.BASE_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ inputs.node-version }}\n          cache: npm\n\n      - run: npm ci\n\n      - name: Cache browsers\n        id: browser-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}\n\n      - name: Install browsers\n        if: steps.browser-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      - name: Install OS dependencies\n        if: steps.browser-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      - name: Run tests\n        run: ${{ inputs.test-command }}\n\n      - uses: actions/upload-artifact@v4\n        if: ${{ !cancelled() }}\n        with:\n          name: test-report\n          path: playwright-report/\n          retention-days: 14\n```\n\n**Calling the reusable workflow:**\n\n```yaml\n# .github/workflows/ci.yml\nname: CI\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  e2e:\n    uses: ./.github/workflows/pw-reusable.yml\n    with:\n      node-version: 'lts/*'\n    secrets:\n      BASE_URL: ${{ secrets.STAGING_URL }}\n      TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}\n```\n\n## Scenario Guide\n\n| Scenario                   | Approach                                   |\n| -------------------------- | ------------------------------------------ |\n| Small suite (< 5 min)      | Single job, no sharding                    |\n| Medium suite (5-20 min)    | 2-4 shards with matrix                     |\n| Large suite (20+ min)      | 4-8 shards + blob merge                    |\n| Cross-browser on PRs       | Chromium only on PRs; all browsers on main |\n| Staging/prod smoke tests   | Separate workflow with `environment:`      |\n| Nightly full regression    | `schedule` trigger + `workflow_dispatch`   |\n| Multiple repos, same setup | Reusable workflow with `workflow_call`     |\n| Reproducible env needed    | Container job with Playwright image        |\n\n## Common Mistakes\n\n| Mistake                         | Problem                      | Fix                                                         |\n| ------------------------------- | ---------------------------- | ----------------------------------------------------------- |\n| No `concurrency` group          | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |\n| `fail-fast: true` with sharding | One failure cancels others   | Set `fail-fast: false`                                      |\n| No browser caching              | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright`                              |\n| No `timeout-minutes`            | Stuck jobs run for 6 hours   | Set explicit timeout: 20-30 minutes                         |\n| Artifacts only on failure       | No report when tests pass    | Use `if: ${{ !cancelled() }}`                               |\n| Hardcoded secrets               | Security risk                | Use GitHub Secrets and Environments                         |\n| All browsers on every PR        | 3x CI cost                   | Chromium on PR; cross-browser on main                       |\n| No artifact retention           | Default 90-day fills storage | Set `retention-days: 7-14`                                  |\n| Missing `--with-deps`           | Browser launch failures      | Always use `npx playwright install --with-deps`             |\n\n## Troubleshooting\n\n### Browser launch fails: \"Missing dependencies\"\n\n**Cause**: Browsers restored from cache but OS dependencies weren't cached.\n\n**Fix**: Run `npx playwright install-deps` on cache hit:\n\n```yaml\n- name: Install OS dependencies\n  if: steps.browser-cache.outputs.cache-hit == 'true'\n  run: npx playwright install-deps\n```\n\n### Tests pass locally but timeout in CI\n\n**Cause**: CI runners have fewer resources than dev machines.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Sharded reports incomplete\n\n**Cause**: Artifact names collide or `merge-multiple` not set.\n\n**Fix**: Unique names per shard and enable merge:\n\n```yaml\n# Upload in each shard\n- uses: actions/upload-artifact@v4\n  with:\n    name: blob-${{ strategy.job-index }}\n    path: blob-report/\n\n# Download in merge job\n- uses: actions/download-artifact@v4\n  with:\n    path: all-blobs\n    pattern: blob-*\n    merge-multiple: true\n```\n\n### `webServer` fails: \"port already in use\"\n\n**Cause**: Zombie process from previous run.\n\n**Fix**: Kill stale processes before starting:\n\n```yaml\n- name: Kill stale processes\n  run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true\n```\n\n### No PR annotations\n\n**Cause**: `github` reporter not configured.\n\n**Fix**: Add `github` reporter for CI:\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n})\n```\n\n## Related\n\n- [test-tags.md](../core/test-tags.md) — tagging and filtering tests\n- [parallel-sharding.md](parallel-sharding.md) — sharding strategies\n- [reporting.md](reporting.md) — reporter configuration\n- [docker.md](docker.md) — container images\n- [gitlab.md](gitlab.md) — GitLab CI equivalent\n- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins\n","isInternal":false,"tokens":3492,"sizeBytes":14130},{"name":"gitlab.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"# GitLab CI/CD Configuration\n\n## Table of Contents\n\n1. [Key Commands](#key-commands)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n\n> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.\n\n## Key Commands\n\n```bash\nnpx playwright install --with-deps    # install browsers + OS deps\nnpx playwright test --shard=1/4       # run 1 of 4 parallel shards\nnpx playwright merge-reports ./blob-report  # merge shard results\nnpx playwright test --reporter=dot    # minimal output for CI logs\n```\n\n## Patterns\n\n### Basic Pipeline Configuration\n\n**Use when**: Any GitLab project with Playwright tests.\n\n```yaml\n# .gitlab-ci.yml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Sharded Parallel Execution\n\n**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.\n**Avoid when**: Suite runs under 5 minutes.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n  - report\n\nvariables:\n  CI: 'true'\n  npm_config_cache: '$CI_PROJECT_DIR/.npm'\n\ncache:\n  key:\n    files:\n      - package-lock.json\n  paths:\n    - .npm/\n    - node_modules/\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:\n  stage: test\n  needs: [setup]\n  parallel: 4\n  script:\n    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL\n  artifacts:\n    when: always\n    paths:\n      - blob-report/\n    expire_in: 1 hour\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n\ncombine-reports:\n  stage: report\n  needs: [e2e]\n  when: always\n  script:\n    - npx playwright merge-reports --reporter=html ./blob-report\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 14 days\n```\n\n**Config for sharded pipelines:**\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n\n### Environment Variables and Secrets\n\n**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - test\n\nvariables:\n  CI: 'true'\n\ne2e:staging:\n  stage: test\n  variables:\n    BASE_URL: $STAGING_URL\n    TEST_PASSWORD: $TEST_PASSWORD\n    API_KEY: $API_KEY\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n    - when: manual\n      allow_failure: true\n```\n\n**Setting variables in GitLab:**\nNavigate to **Settings > CI/CD > Variables** and add:\n\n- `STAGING_URL` -- not masked, not protected\n- `TEST_PASSWORD` -- masked, protected\n- `API_KEY` -- masked, protected\n\n### Multi-Browser Matrix\n\n**Use when**: Running Chromium on MRs and all browsers on the default branch.\n\n```yaml\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n\nstages:\n  - install\n  - test\n\nvariables:\n  CI: 'true'\n\nsetup:\n  stage: install\n  script:\n    - npm ci\n  artifacts:\n    paths:\n      - node_modules/\n    expire_in: 1 hour\n\ne2e:chromium:\n  stage: test\n  needs: [setup]\n  script:\n    - npx playwright test --project=chromium\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n\ne2e:all-browsers:\n  stage: test\n  needs: [setup]\n  parallel:\n    matrix:\n      - PROJECT: [chromium, firefox, webkit]\n  script:\n    - npx playwright test --project=$PROJECT\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services Integration (Database, Cache)\n\n**Use when**: Tests need the application running alongside Playwright, or you need external services.\n\n```yaml\nstages:\n  - test\n\ne2e:integration:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  services:\n    - name: postgres:latest\n      alias: db\n    - name: redis:latest\n      alias: cache\n  variables:\n    CI: 'true'\n    DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n    REDIS_URL: 'redis://cache:6379'\n    POSTGRES_PASSWORD: 'postgres'\n    POSTGRES_DB: 'testdb'\n  before_script:\n    - npm ci\n    - npx prisma db push\n    - npx prisma db seed\n  script:\n    - npx playwright test\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n      - test-results/\n    expire_in: 14 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Scheduled Nightly Regression\n\n**Use when**: Full regression is too slow for every MR.\n\n```yaml\ne2e:nightly:\n  stage: test\n  image: mcr.microsoft.com/playwright:v1.48.0-noble\n  before_script:\n    - npm ci\n  script:\n    - npx playwright test --grep @regression\n  artifacts:\n    when: always\n    paths:\n      - playwright-report/\n    expire_in: 30 days\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"schedule\"\n```\n\nSet up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).\n\n## Decision Guide\n\n| Scenario                             | Approach                                               | Why                                                 |\n| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |\n| Simple project, < 5 min suite        | Single `test` job using Playwright Docker image        | No sharding overhead; artifacts capture report      |\n| Suite > 10 min                       | `parallel: N` with `--shard`                           | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |\n| Merge request fast feedback          | Chromium only on MRs; all browsers on main             | 3x fewer pipeline minutes on MRs                    |\n| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images         | GitLab manages service lifecycle                    |\n| Secrets for staging environment      | GitLab CI/CD Variables (masked + protected)            | Never hardcode secrets in `.gitlab-ci.yml`          |\n| Full nightly regression              | Pipeline schedule (`CI_PIPELINE_SOURCE == \"schedule\"`) | Avoids blocking MR pipelines                        |\n| Report browsing                      | `artifacts:` with `paths: [playwright-report/]`        | Browse directly in GitLab job artifacts UI          |\n\n## Anti-Patterns\n\n| Anti-Pattern                                         | Problem                                                            | Do This Instead                                                           |\n| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |\n| Not using the Playwright Docker image                | Installing browsers every run adds 1-2 minutes                     | Use `mcr.microsoft.com/playwright:v1.48.0-noble` as base image            |\n| `artifacts: when: on_failure` only                   | No report when tests pass; can't verify results                    | Use `when: always` to capture reports regardless                          |\n| No `expire_in` on artifacts                          | Artifacts accumulate and consume storage                           | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |\n| Hardcoding `CI_NODE_TOTAL` in shard flag             | Breaks when you change `parallel:` value                           | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL`                               |\n| Skipping `needs:` between stages                     | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs                                |\n| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts                               | Key cache on `package-lock.json` hash                                     |\n\n## Troubleshooting\n\n### Browser launch fails: \"Failed to launch browser\"\n\n**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.\n\n**Fix**: Match the Docker image tag to your Playwright version:\n\n```yaml\n# Check your version: npm ls @playwright/test\nimage: mcr.microsoft.com/playwright:v1.48.0-noble\n```\n\n### Tests hang in GitLab runner: \"Navigation timeout exceeded\"\n\n**Cause**: GitLab shared runners may have limited resources.\n\n**Fix**: Reduce workers and increase timeouts:\n\n```typescript\nexport default defineConfig({\n  workers: process.env.CI ? 2 : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n  },\n})\n```\n\n### Pipeline runs on every push, not just merge requests\n\n**Cause**: Missing `rules:` configuration.\n\n**Fix**: Add explicit rules:\n\n```yaml\nrules:\n  - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\n### Services (Postgres/Redis) not reachable from tests\n\n**Cause**: Using `localhost` instead of the service alias.\n\n**Fix**: Use the service alias as hostname:\n\n```yaml\nservices:\n  - name: postgres:latest\n    alias: db\n\nvariables:\n  DATABASE_URL: 'postgresql://postgres:postgres@db:5432/testdb'\n```\n\n### Merged report is empty after sharded run\n\n**Cause**: Each shard job needs the `blob` reporter, not `html`.\n\n**Fix**: Configure blob reporter for CI:\n\n```typescript\nexport default defineConfig({\n  reporter: process.env.CI ? [['blob'], ['dot']] : [['html', {open: 'on-failure'}]],\n})\n```\n","isInternal":false,"tokens":2589,"sizeBytes":10617},{"name":"other-providers.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fother-providers.md -->","isInternal":false,"tokens":2959,"sizeBytes":13689},{"name":"parallel-sharding.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fparallel-sharding.md -->","isInternal":false,"tokens":2818,"sizeBytes":11482},{"name":"performance.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Fperformance.md -->","isInternal":false,"tokens":2819,"sizeBytes":11662},{"name":"reporting.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Freporting.md -->","isInternal":false,"tokens":2840,"sizeBytes":11839},{"name":"test-coverage.md","path":".agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md","title":"Infrastructure-ci-cd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Finfrastructure-ci-cd%2Ftest-coverage.md -->","isInternal":false,"tokens":3045,"sizeBytes":12350},{"name":"LICENSE.md","path":".agents/skills/playwright-best-practices/LICENSE.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/LICENSE.md","title":"Playwright-best-practices Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FLICENSE.md -->","isInternal":false,"tokens":202,"sizeBytes":1073},{"name":"README.md","path":".agents/skills/playwright-best-practices/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/README.md","title":"Playwright-best-practices Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FREADME.md -->","isInternal":false,"tokens":2328,"sizeBytes":10141},{"name":"SKILL.md","path":".agents/skills/playwright-best-practices/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/SKILL.md","title":"playwright-best-practices","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2FSKILL.md -->","frontmatter":{"name":"playwright-best-practices","description":"Use when writing Playwright tests, fixing flaky tests, debugging failures, implementing Page Object Model, configuring CI/CD, optimizing performance, mocking APIs, handling authentication or OAuth, testing accessibility (axe-core), file uploads/downloads, date/time mocking, WebSockets, geolocation, permissions, multi-tab/popup flows, mobile/responsive layouts, touch gestures, GraphQL, error handling, offline mode, multi-user collaboration, third-party services (payments, email verification), console error monitoring, global setup/teardown, test annotations (skip, fixme, slow), test tags (@smoke, @fast, @critical, filtering with --grep), project dependencies, security testing (XSS, CSRF, auth), performance budgets (Web Vitals, Lighthouse), iframes, component testing, canvas/WebGL, service workers/PWA, test coverage, i18n/localization, Electron apps, or browser extension testing. Covers E2E, component, API, visual, accessibility, security, Electron, and extension testing.","license":"MIT","metadata":{"author":"currents.dev","version":"1.1"}},"isInternal":false,"tokens":4818,"sizeBytes":26306},{"name":"accessibility.md","path":".agents/skills/playwright-best-practices/testing-patterns/accessibility.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/accessibility.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Faccessibility.md -->","isInternal":false,"tokens":2149,"sizeBytes":8917},{"name":"api-testing.md","path":".agents/skills/playwright-best-practices/testing-patterns/api-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/api-testing.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fapi-testing.md -->","isInternal":false,"tokens":5439,"sizeBytes":24523},{"name":"browser-extensions.md","path":".agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fbrowser-extensions.md -->","isInternal":false,"tokens":3054,"sizeBytes":13519},{"name":"canvas-webgl.md","path":".agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcanvas-webgl.md -->","isInternal":false,"tokens":3181,"sizeBytes":12893},{"name":"component-testing.md","path":".agents/skills/playwright-best-practices/testing-patterns/component-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/component-testing.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fcomponent-testing.md -->","isInternal":false,"tokens":2753,"sizeBytes":11409},{"name":"drag-drop.md","path":".agents/skills/playwright-best-practices/testing-patterns/drag-drop.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/drag-drop.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fdrag-drop.md -->","isInternal":false,"tokens":4480,"sizeBytes":17704},{"name":"electron.md","path":".agents/skills/playwright-best-practices/testing-patterns/electron.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/electron.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Felectron.md -->","isInternal":false,"tokens":3042,"sizeBytes":13194},{"name":"file-operations.md","path":".agents/skills/playwright-best-practices/testing-patterns/file-operations.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/file-operations.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-operations.md -->","isInternal":false,"tokens":2339,"sizeBytes":10197},{"name":"file-upload-download.md","path":".agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Ffile-upload-download.md -->","isInternal":false,"tokens":4102,"sizeBytes":16906},{"name":"forms-validation.md","path":".agents/skills/playwright-best-practices/testing-patterns/forms-validation.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/forms-validation.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fforms-validation.md -->","isInternal":false,"tokens":4850,"sizeBytes":21713},{"name":"graphql-testing.md","path":".agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fgraphql-testing.md -->","isInternal":false,"tokens":2091,"sizeBytes":9027},{"name":"i18n.md","path":".agents/skills/playwright-best-practices/testing-patterns/i18n.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/i18n.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fi18n.md -->","isInternal":false,"tokens":3228,"sizeBytes":12812},{"name":"performance-testing.md","path":".agents/skills/playwright-best-practices/testing-patterns/performance-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/performance-testing.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fperformance-testing.md -->","isInternal":false,"tokens":3120,"sizeBytes":12219},{"name":"security-testing.md","path":".agents/skills/playwright-best-practices/testing-patterns/security-testing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/security-testing.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fsecurity-testing.md -->","isInternal":false,"tokens":2847,"sizeBytes":11903},{"name":"visual-regression.md","path":".agents/skills/playwright-best-practices/testing-patterns/visual-regression.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-best-practices/testing-patterns/visual-regression.md","title":"Testing-patterns Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-best-practices%2Ftesting-patterns%2Fvisual-regression.md -->","isInternal":false,"tokens":4217,"sizeBytes":18991},{"name":"request-mocking.md","path":".agents/skills/playwright-cli/references/request-mocking.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/request-mocking.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frequest-mocking.md -->","isInternal":false,"tokens":549,"sizeBytes":2182},{"name":"running-code.md","path":".agents/skills/playwright-cli/references/running-code.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/running-code.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Frunning-code.md -->","isInternal":false,"tokens":1263,"sizeBytes":5273},{"name":"session-management.md","path":".agents/skills/playwright-cli/references/session-management.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/session-management.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fsession-management.md -->","isInternal":false,"tokens":939,"sizeBytes":3999},{"name":"storage-state.md","path":".agents/skills/playwright-cli/references/storage-state.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/storage-state.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fstorage-state.md -->","isInternal":false,"tokens":1245,"sizeBytes":5194},{"name":"test-generation.md","path":".agents/skills/playwright-cli/references/test-generation.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/test-generation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftest-generation.md -->","isInternal":false,"tokens":557,"sizeBytes":2386},{"name":"tracing.md","path":".agents/skills/playwright-cli/references/tracing.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/tracing.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Ftracing.md -->","isInternal":false,"tokens":859,"sizeBytes":3852},{"name":"video-recording.md","path":".agents/skills/playwright-cli/references/video-recording.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/references/video-recording.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2Freferences%2Fvideo-recording.md -->","isInternal":false,"tokens":265,"sizeBytes":1253},{"name":"SKILL.md","path":".agents/skills/playwright-cli/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/playwright-cli/SKILL.md","title":"playwright-cli","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fplaywright-cli%2FSKILL.md -->","frontmatter":{"name":"playwright-cli","description":"Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.","allowed-tools":"Bash(playwright-cli:*)","allowedTools":["Bash(playwright-cli:*)"]},"allowedTools":["Bash(playwright-cli:*)"],"isInternal":false,"tokens":1750,"sizeBytes":7451},{"name":"SKILL.md","path":".agents/skills/pr-description/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/pr-description/SKILL.md","title":"pr-description","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fpr-description%2FSKILL.md -->","frontmatter":{"name":"pr-description","description":"Write PR descriptions and release notes for the Sanity monorepo. Follows the repo's PR template with Description, What to review, Testing, and Notes for release sections. Auto-triggers when creating PRs via `gh pr create`. Use when creating pull requests, writing PR descriptions, drafting release notes, or when user mentions PR, pull request, or release notes."},"isInternal":false,"tokens":1146,"sizeBytes":4814},{"name":"commands.md","path":".agents/skills/react-devtools/references/commands.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/react-devtools/references/commands.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fcommands.md -->","isInternal":false,"tokens":1402,"sizeBytes":6046},{"name":"profiling-guide.md","path":".agents/skills/react-devtools/references/profiling-guide.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/react-devtools/references/profiling-guide.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fprofiling-guide.md -->","isInternal":false,"tokens":1190,"sizeBytes":5622},{"name":"setup.md","path":".agents/skills/react-devtools/references/setup.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/react-devtools/references/setup.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2Freferences%2Fsetup.md -->","isInternal":false,"tokens":668,"sizeBytes":2794},{"name":"SKILL.md","path":".agents/skills/react-devtools/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/react-devtools/SKILL.md","title":"react-devtools","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-devtools%2FSKILL.md -->","frontmatter":{"name":"react-devtools","description":"React DevTools CLI for AI agents. Use when the user asks you to debug a React or React Native app at runtime, inspect component props/state/hooks, diagnose render performance, profile re-renders, find slow components, or understand why something re-renders. Triggers include \"why does this re-render\", \"inspect the component\", \"what props does X have\", \"profile the app\", \"find slow components\", \"debug the UI\", \"check component state\", \"the app feels slow\", or any React runtime debugging task.","allowed-tools":"Bash(agent-react-devtools:*)","allowedTools":["Bash(agent-react-devtools:*)"]},"allowedTools":["Bash(agent-react-devtools:*)"],"isInternal":false,"tokens":1957,"sizeBytes":8321},{"name":"SKILL.md","path":".agents/skills/react-rx-hook-audit/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/react-rx-hook-audit/SKILL.md","title":"react-rx-hook-audit","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Freact-rx-hook-audit%2FSKILL.md -->","frontmatter":{"name":"react-rx-hook-audit","description":"Find react-rx hook call sites (useObservable, useSyncObservable, useObservablePromise) whose observable identity churns or that re-render more than needed, verify the suspicion at runtime, and refactor them to the useValuePreview pattern. Use when reviewing or writing a component or hook that calls these hooks, or when a re-render, loading flash or resubscribe storm is suspected."},"isInternal":false,"tokens":2856,"sizeBytes":11616},{"name":"custom-operators.md","path":".agents/skills/rxjs-like-a-pro/references/custom-operators.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/rxjs-like-a-pro/references/custom-operators.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fcustom-operators.md -->","isInternal":false,"tokens":576,"sizeBytes":2543},{"name":"inner-observable-chains.md","path":".agents/skills/rxjs-like-a-pro/references/inner-observable-chains.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/rxjs-like-a-pro/references/inner-observable-chains.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Finner-observable-chains.md -->","isInternal":false,"tokens":551,"sizeBytes":2302},{"name":"loading-state-patterns.md","path":".agents/skills/rxjs-like-a-pro/references/loading-state-patterns.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/rxjs-like-a-pro/references/loading-state-patterns.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Floading-state-patterns.md -->","isInternal":false,"tokens":699,"sizeBytes":3060},{"name":"massive-observable.md","path":".agents/skills/rxjs-like-a-pro/references/massive-observable.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/rxjs-like-a-pro/references/massive-observable.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2Freferences%2Fmassive-observable.md -->","isInternal":false,"tokens":810,"sizeBytes":3647},{"name":"SKILL.md","path":".agents/skills/rxjs-like-a-pro/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/rxjs-like-a-pro/SKILL.md","title":"rxjs-like-a-pro","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Frxjs-like-a-pro%2FSKILL.md -->","frontmatter":{"name":"rxjs-like-a-pro","description":"How to write idiomatic, efficient RxJS code. Use this skill whenever the user is writing, refactoring, reviewing, or debugging code that uses RxJS — including any file that imports from 'rxjs' or 'rxjs/operators'. Trigger on mentions of observables, subscriptions, RxJS operators, or reactive streams. Even if the user doesn't say \"RxJS\" explicitly, activate when you see patterns like `.pipe()`, `.subscribe()`, `Observable`, `Subject`, `BehaviorSubject`, `switchMap`, `mergeMap`, or similar.\n"},"isInternal":false,"tokens":2625,"sizeBytes":12088},{"name":"SKILL.md","path":".agents/skills/sanity-bench/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-bench/SKILL.md","title":"sanity-bench","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-bench%2FSKILL.md -->","frontmatter":{"name":"sanity-bench","description":"Run, dispatch and read the hermetic Studio performance benchmark suite in perf/bench — local runs and modes, the trigger:perf-bench PR label, the Studio Bench workflow_dispatch inputs (run_suite, self_test, backfill_sha, release_tag, ab_from/ab_to) and which combine, and how to read 🔴🟢✅⚪ verdicts. Use when asked to benchmark a PR or commit, compare two commits, backfill or re-run the daily main series, measure a release, add a bench scenario, extend the mock API, or interpret a bench PR comment or a failed Studio Bench run."},"isInternal":false,"tokens":2035,"sizeBytes":9984},{"name":"SKILL.md","path":".agents/skills/sanity-config-reducers/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-config-reducers/SKILL.md","title":"sanity-config-reducers","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-config-reducers%2FSKILL.md -->","frontmatter":{"name":"sanity-config-reducers","description":"Add and review Sanity config properties that are reduced across root config and plugins. Use when adding beta flags, feature config, workspace/source options, default plugin gates, or resolved Source fields in packages/sanity/src/core/config."},"isInternal":false,"tokens":789,"sizeBytes":3827},{"name":"SKILL.md","path":".agents/skills/sanity-default-plugins/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-default-plugins/SKILL.md","title":"sanity-default-plugins","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-default-plugins%2FSKILL.md -->","frontmatter":{"name":"sanity-default-plugins","description":"Create and wire Sanity core plugins using the monorepo's default plugin conventions. Use when adding, modifying, or reviewing plugins under packages/sanity/src/core, especially plugins added through resolveDefaultPlugins or studio.components middleware."},"isInternal":false,"tokens":942,"sizeBytes":4363},{"name":"SKILL.md","path":".agents/skills/sanity-i18n-translate/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-i18n-translate/SKILL.md","title":"sanity-i18n-translate","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-i18n-translate%2FSKILL.md -->","frontmatter":{"name":"sanity-i18n-translate","description":"Use the <Translate> i18n component correctly, especially its components map and componentProps. Use when rendering locale strings that contain markup or embedded components, when adding or reviewing <Translate> usages, or when the @repo/i18n/no-inline-translate-components lint rule fires."},"isInternal":false,"tokens":1076,"sizeBytes":4508},{"name":"SKILL.md","path":".agents/skills/sanity-plugin-authoring/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-plugin-authoring/SKILL.md","title":"sanity-plugin-authoring","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-plugin-authoring%2FSKILL.md -->","frontmatter":{"name":"sanity-plugin-authoring","description":"Explain and create Sanity Studio plugins using the public plugin and tool APIs. Use when creating user-facing plugins, adding tools through plugins, or when an agent needs to understand what a Sanity plugin can configure before applying monorepo-specific default plugin wiring."},"isInternal":false,"tokens":1118,"sizeBytes":5066},{"name":"SKILL.md","path":".agents/skills/sanity-radar-investigate/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-radar-investigate/SKILL.md","title":"sanity-radar-investigate","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar-investigate%2FSKILL.md -->","frontmatter":{"name":"sanity-radar-investigate","description":"Investigate a suspected Studio performance regression surfaced by Studio Radar or the bench suite — confirm the signal against host calibration, narrow the commit range, confirm with an A/B dispatch, bisect, and name the culprit or call it noise. Use when handed a Radar \"investigation prompt\" (starts with \"Investigate a suspected performance regression in the sanity-io/sanity monorepo\"), a drift flag, a red 🔴 verdict in a bench PR comment, or asked whether commit X, PR Y or release Z regressed a studio metric."},"isInternal":false,"tokens":1434,"sizeBytes":5952},{"name":"groq-recipes.md","path":".agents/skills/sanity-radar/references/groq-recipes.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-radar/references/groq-recipes.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2Freferences%2Fgroq-recipes.md -->","isInternal":false,"tokens":1533,"sizeBytes":5364},{"name":"SKILL.md","path":".agents/skills/sanity-radar/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-radar/SKILL.md","title":"sanity-radar","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-radar%2FSKILL.md -->","frontmatter":{"name":"sanity-radar","description":"Studio Radar, the repo-health dashboard at radar.sanity.dev (source in dev/radar) — what each tool shows, where the data lives, how to query benchRun / gitCommit / gitTag / bisectSession documents with GROQ (read token required), and how to run, test and change the dashboard code. Use when asked about studio performance trends, drift flags, the bench dataset, release markers, the Bisect or Comparisons tools, \"what did the daily bench say\", or when editing anything under dev/radar."},"isInternal":false,"tokens":2376,"sizeBytes":14607},{"name":"SKILL.md","path":".agents/skills/sanity-tsdown-config/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-tsdown-config/SKILL.md","title":"sanity-tsdown-config","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-tsdown-config%2FSKILL.md -->","frontmatter":{"name":"sanity-tsdown-config","description":"Configure Sanity libraries with @sanity/tsdown-config, including its publishing defaults, React transforms, CSS extraction, TSDoc checks, and advanced tsdown composition. Use when creating or editing tsdown.config.ts for a Sanity package, or when deciding between PackageOptions and stock tsdown options.","license":"MIT","metadata":{"author":"sanity-io","version":"0.28"}},"isInternal":false,"tokens":1964,"sizeBytes":9308},{"name":"SKILL.md","path":".agents/skills/sanity-ui-migration-progress/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-ui-migration-progress/SKILL.md","title":"sanity-ui-migration-progress","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-ui-migration-progress%2FSKILL.md -->","frontmatter":{"name":"sanity-ui-migration-progress","description":"Measures @sanity/ui v5 side-by-side migration progress — per-component import file counts (v5 alias vs @sanity/ui), JSX instance counts, and styled() instance counts with alias resolution. Use when reporting migration status or tracking progress over time."},"isInternal":false,"tokens":1738,"sizeBytes":8354},{"name":"SKILL.md","path":".agents/skills/sanity-visual-coverage/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-visual-coverage/SKILL.md","title":"sanity-visual-coverage","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-coverage%2FSKILL.md -->","frontmatter":{"name":"sanity-visual-coverage","description":"Check whether Studio UI is covered by Chromatic visual regression, for a PR's changed files or for the whole tree, and decide between \"already covered\", \"a story is pending in an open PR\", and \"needs a story\". Use when reviewing a PR that touches packages/**/src/**/*.tsx or *.css.ts, when asked \"is this covered by Chromatic\", when planning ui5 or vanilla-extract migration work, or before opening a PR that adds stories."},"isInternal":false,"tokens":1888,"sizeBytes":7771},{"name":"REFERENCE.md","path":".agents/skills/sanity-visual-regression/REFERENCE.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-visual-regression/REFERENCE.md","title":"Sanity-visual-regression Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FREFERENCE.md -->","isInternal":false,"tokens":3963,"sizeBytes":15928},{"name":"SKILL.md","path":".agents/skills/sanity-visual-regression/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/sanity-visual-regression/SKILL.md","title":"sanity-visual-regression","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fsanity-visual-regression%2FSKILL.md -->","frontmatter":{"name":"sanity-visual-regression","description":"Add, review, and maintain Chromatic visual regression coverage in the Sanity monorepo via dev/storybook stories, the vitest browser-mode suite, and Playwright e2e snapshots. Use when adding visual test coverage, writing stories, reviewing Chromatic diffs, working on the styled-components to vanilla-extract or @sanity/ui to ui5 migrations, or when a PR needs visual regression protection."},"isInternal":false,"tokens":3106,"sizeBytes":13458},{"name":"SKILL.md","path":".agents/skills/stories/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/stories/SKILL.md","title":"stories","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstories%2FSKILL.md -->","frontmatter":{"name":"stories","description":"Invoke FIRST, before creating, editing, or deleting components, stories, styles, CSS, themes, colors, or design tokens — anything that changes how the UI looks, no exceptions. Also for starting or previewing Storybook to verify UI, requests to show, browse, or list components, stories, or UI states, and docs, props, or usage lookups."},"isInternal":false,"tokens":561,"sizeBytes":2488},{"name":"SKILL.md","path":".agents/skills/storybook-init/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/storybook-init/SKILL.md","title":"storybook-init","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-init%2FSKILL.md -->","frontmatter":{"name":"storybook-init","description":"Use when adding Storybook to a project that does not have Storybook configured yet."},"isInternal":false,"tokens":133,"sizeBytes":598},{"name":"SKILL.md","path":".agents/skills/storybook-setup/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/storybook-setup/SKILL.md","title":"storybook-setup","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-setup%2FSKILL.md -->","frontmatter":{"name":"storybook-setup","description":"Use this skill when Storybook is already installed and the user wants a working `preview` file and stories for real components."},"isInternal":false,"tokens":148,"sizeBytes":619},{"name":"SKILL.md","path":".agents/skills/storybook-upgrade/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/storybook-upgrade/SKILL.md","title":"storybook-upgrade","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fstorybook-upgrade%2FSKILL.md -->","frontmatter":{"name":"storybook-upgrade","description":"Use this skill when Storybook exists but needs an upgrade."},"isInternal":false,"tokens":107,"sizeBytes":444},{"name":"deep-modules.md","path":".agents/skills/tdd/deep-modules.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/deep-modules.md","title":"Tdd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fdeep-modules.md -->","isInternal":false,"tokens":200,"sizeBytes":1239},{"name":"interface-design.md","path":".agents/skills/tdd/interface-design.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/interface-design.md","title":"Tdd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Finterface-design.md -->","isInternal":false,"tokens":150,"sizeBytes":651},{"name":"mocking.md","path":".agents/skills/tdd/mocking.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/mocking.md","title":"Tdd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Fmocking.md -->","isInternal":false,"tokens":331,"sizeBytes":1475},{"name":"refactoring.md","path":".agents/skills/tdd/refactoring.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/refactoring.md","title":"Tdd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Frefactoring.md -->","isInternal":false,"tokens":87,"sizeBytes":387},{"name":"SKILL.md","path":".agents/skills/tdd/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/SKILL.md","title":"tdd","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2FSKILL.md -->","frontmatter":{"name":"tdd","description":"Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions \"red-green-refactor\", wants integration tests, or asks for test-first development."},"isInternal":false,"tokens":930,"sizeBytes":4211},{"name":"tests.md","path":".agents/skills/tdd/tests.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/tdd/tests.md","title":"Tdd Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Ftdd%2Ftests.md -->","isInternal":false,"tokens":357,"sizeBytes":1619},{"name":"AGENTS.md","path":".agents/skills/vercel-react-best-practices/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/AGENTS.md","title":"Vercel-react-best-practices Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FAGENTS.md -->","isInternal":false,"tokens":12011,"sizeBytes":50070},{"name":"advanced-event-handler-refs.md","path":".agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-event-handler-refs.md -->","frontmatter":{"title":"Store Event Handlers in Refs","impact":"LOW","impactDescription":"stable subscriptions","tags":"advanced, hooks, refs, event-handlers, optimization"},"isInternal":false,"tokens":338,"sizeBytes":1481},{"name":"advanced-init-once.md","path":".agents/skills/vercel-react-best-practices/rules/advanced-init-once.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-init-once.md -->","frontmatter":{"title":"Initialize App Once, Not Per Mount","impact":"LOW-MEDIUM","impactDescription":"avoids duplicate init in development","tags":"initialization, useEffect, app-startup, side-effects"},"isInternal":false,"tokens":230,"sizeBytes":958},{"name":"advanced-use-latest.md","path":".agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fadvanced-use-latest.md -->","frontmatter":{"title":"useEffectEvent for Stable Callback Refs","impact":"LOW","impactDescription":"prevents effect re-runs","tags":"advanced, hooks, useEffectEvent, refs, optimization"},"isInternal":false,"tokens":255,"sizeBytes":1061},{"name":"async-api-routes.md","path":".agents/skills/vercel-react-best-practices/rules/async-api-routes.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-api-routes.md -->","frontmatter":{"title":"Prevent Waterfall Chains in API Routes","impact":"CRITICAL","impactDescription":"2-10× improvement","tags":"api-routes, server-actions, waterfalls, parallelization"},"isInternal":false,"tokens":237,"sizeBytes":1109},{"name":"async-defer-await.md","path":".agents/skills/vercel-react-best-practices/rules/async-defer-await.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-defer-await.md -->","frontmatter":{"title":"Defer Await Until Needed","impact":"HIGH","impactDescription":"avoids blocking unused code paths","tags":"async, await, conditional, optimization"},"isInternal":false,"tokens":425,"sizeBytes":1996},{"name":"async-dependencies.md","path":".agents/skills/vercel-react-best-practices/rules/async-dependencies.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-dependencies.md -->","frontmatter":{"title":"Dependency-Based Parallelization","impact":"CRITICAL","impactDescription":"2-10× improvement","tags":"async, parallelization, dependencies, better-all"},"isInternal":false,"tokens":290,"sizeBytes":1290},{"name":"async-parallel.md","path":".agents/skills/vercel-react-best-practices/rules/async-parallel.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/async-parallel.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-parallel.md -->","frontmatter":{"title":"Promise.all() for Independent Operations","impact":"CRITICAL","impactDescription":"2-10× improvement","tags":"async, parallelization, promises, waterfalls"},"isInternal":false,"tokens":140,"sizeBytes":646},{"name":"async-suspense-boundaries.md","path":".agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fasync-suspense-boundaries.md -->","frontmatter":{"title":"Strategic Suspense Boundaries","impact":"HIGH","impactDescription":"faster initial paint","tags":"async, suspense, streaming, layout-shift"},"isInternal":false,"tokens":590,"sizeBytes":2498},{"name":"bundle-barrel-imports.md","path":".agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-barrel-imports.md -->","frontmatter":{"title":"Avoid Barrel File Imports","impact":"CRITICAL","impactDescription":"200-800ms import cost, slow builds","tags":"bundle, imports, tree-shaking, barrel-files, performance"},"isInternal":false,"tokens":595,"sizeBytes":2364},{"name":"bundle-conditional.md","path":".agents/skills/vercel-react-best-practices/rules/bundle-conditional.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-conditional.md -->","frontmatter":{"title":"Conditional Module Loading","impact":"HIGH","impactDescription":"loads large data only when needed","tags":"bundle, conditional-loading, lazy-loading"},"isInternal":false,"tokens":220,"sizeBytes":959},{"name":"bundle-defer-third-party.md","path":".agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-defer-third-party.md -->","frontmatter":{"title":"Defer Non-Critical Third-Party Libraries","impact":"MEDIUM","impactDescription":"loads after hydration","tags":"bundle, third-party, analytics, defer"},"isInternal":false,"tokens":221,"sizeBytes":913},{"name":"bundle-dynamic-imports.md","path":".agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-dynamic-imports.md -->","frontmatter":{"title":"Dynamic Imports for Heavy Components","impact":"CRITICAL","impactDescription":"directly affects TTI and LCP","tags":"bundle, dynamic-import, code-splitting, next-dynamic"},"isInternal":false,"tokens":198,"sizeBytes":780},{"name":"bundle-preload.md","path":".agents/skills/vercel-react-best-practices/rules/bundle-preload.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fbundle-preload.md -->","frontmatter":{"title":"Preload Based on User Intent","impact":"MEDIUM","impactDescription":"reduces perceived latency","tags":"bundle, preload, user-intent, hover"},"isInternal":false,"tokens":259,"sizeBytes":1114},{"name":"client-event-listeners.md","path":".agents/skills/vercel-react-best-practices/rules/client-event-listeners.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-event-listeners.md -->","frontmatter":{"title":"Deduplicate Global Event Listeners","impact":"LOW","impactDescription":"single listener for N components","tags":"client, swr, event-listeners, subscription"},"isInternal":false,"tokens":471,"sizeBytes":1982},{"name":"client-localstorage-schema.md","path":".agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-localstorage-schema.md -->","frontmatter":{"title":"Version and Minimize localStorage Data","impact":"MEDIUM","impactDescription":"prevents schema conflicts, reduces storage size","tags":"client, localStorage, storage, versioning, data-minimization"},"isInternal":false,"tokens":442,"sizeBytes":1972},{"name":"client-passive-event-listeners.md","path":".agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-passive-event-listeners.md -->","frontmatter":{"title":"Use Passive Event Listeners for Scrolling Performance","impact":"MEDIUM","impactDescription":"eliminates scroll delay caused by event listeners","tags":"client, event-listeners, scrolling, performance, touch, wheel"},"isInternal":false,"tokens":359,"sizeBytes":1632},{"name":"client-swr-dedup.md","path":".agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fclient-swr-dedup.md -->","frontmatter":{"title":"Use SWR for Automatic Deduplication","impact":"MEDIUM-HIGH","impactDescription":"automatic deduplication","tags":"client, swr, deduplication, data-fetching"},"isInternal":false,"tokens":310,"sizeBytes":1151},{"name":"js-batch-dom-css.md","path":".agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-batch-dom-css.md -->","frontmatter":{"title":"Avoid Layout Thrashing","impact":"MEDIUM","impactDescription":"prevents forced synchronous layouts and reduces performance bottlenecks","tags":"javascript, dom, css, performance, reflow, layout-thrashing"},"isInternal":false,"tokens":744,"sizeBytes":3227},{"name":"js-cache-function-results.md","path":".agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-function-results.md -->","frontmatter":{"title":"Cache Repeated Function Calls","impact":"MEDIUM","impactDescription":"avoid redundant computation","tags":"javascript, cache, memoization, performance"},"isInternal":false,"tokens":445,"sizeBytes":1931},{"name":"js-cache-property-access.md","path":".agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-property-access.md -->","frontmatter":{"title":"Cache Property Access in Loops","impact":"LOW-MEDIUM","impactDescription":"reduces lookups","tags":"javascript, loops, optimization, caching"},"isInternal":false,"tokens":138,"sizeBytes":532},{"name":"js-cache-storage.md","path":".agents/skills/vercel-react-best-practices/rules/js-cache-storage.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-cache-storage.md -->","frontmatter":{"title":"Cache Storage API Calls","impact":"LOW-MEDIUM","impactDescription":"reduces expensive I/O","tags":"javascript, localStorage, storage, caching, performance"},"isInternal":false,"tokens":374,"sizeBytes":1640},{"name":"js-combine-iterations.md","path":".agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-combine-iterations.md -->","frontmatter":{"title":"Combine Multiple Array Iterations","impact":"LOW-MEDIUM","impactDescription":"reduces iterations","tags":"javascript, arrays, loops, performance"},"isInternal":false,"tokens":182,"sizeBytes":759},{"name":"js-early-exit.md","path":".agents/skills/vercel-react-best-practices/rules/js-early-exit.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-early-exit.md -->","frontmatter":{"title":"Early Return from Functions","impact":"LOW-MEDIUM","impactDescription":"avoids unnecessary computation","tags":"javascript, functions, optimization, early-return"},"isInternal":false,"tokens":262,"sizeBytes":1119},{"name":"js-hoist-regexp.md","path":".agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-hoist-regexp.md -->","frontmatter":{"title":"Hoist RegExp Creation","impact":"LOW-MEDIUM","impactDescription":"avoids recreation","tags":"javascript, regexp, optimization, memoization"},"isInternal":false,"tokens":281,"sizeBytes":1026},{"name":"js-index-maps.md","path":".agents/skills/vercel-react-best-practices/rules/js-index-maps.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-index-maps.md -->","frontmatter":{"title":"Build Index Maps for Repeated Lookups","impact":"LOW-MEDIUM","impactDescription":"1M ops to 2K ops","tags":"javascript, map, indexing, optimization, performance"},"isInternal":false,"tokens":237,"sizeBytes":847},{"name":"js-length-check-first.md","path":".agents/skills/vercel-react-best-practices/rules/js-length-check-first.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-length-check-first.md -->","frontmatter":{"title":"Early Length Check for Array Comparisons","impact":"MEDIUM-HIGH","impactDescription":"avoids expensive operations when lengths differ","tags":"javascript, arrays, performance, optimization, comparison"},"isInternal":false,"tokens":373,"sizeBytes":1748},{"name":"js-min-max-loop.md","path":".agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-min-max-loop.md -->","frontmatter":{"title":"Use Loop for Min/Max Instead of Sort","impact":"LOW","impactDescription":"O(n) instead of O(n log n)","tags":"javascript, arrays, performance, sorting, algorithms"},"isInternal":false,"tokens":599,"sizeBytes":2272},{"name":"js-set-map-lookups.md","path":".agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-set-map-lookups.md -->","frontmatter":{"title":"Use Set/Map for O(1) Lookups","impact":"LOW-MEDIUM","impactDescription":"O(n) to O(1)","tags":"javascript, set, map, data-structures, performance"},"isInternal":false,"tokens":154,"sizeBytes":532},{"name":"js-tosorted-immutable.md","path":".agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fjs-tosorted-immutable.md -->","frontmatter":{"title":"Use toSorted() Instead of sort() for Immutability","impact":"MEDIUM-HIGH","impactDescription":"prevents mutation bugs in React state","tags":"javascript, arrays, immutability, react, state, mutation"},"isInternal":false,"tokens":436,"sizeBytes":1782},{"name":"rendering-activity.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-activity.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-activity.md -->","frontmatter":{"title":"Use Activity Component for Show/Hide","impact":"MEDIUM","impactDescription":"preserves state/DOM","tags":"rendering, activity, visibility, state-preservation"},"isInternal":false,"tokens":131,"sizeBytes":560},{"name":"rendering-animate-svg-wrapper.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-animate-svg-wrapper.md -->","frontmatter":{"title":"Animate SVG Wrapper Instead of SVG Element","impact":"LOW","impactDescription":"enables hardware acceleration","tags":"rendering, svg, css, animation, performance"},"isInternal":false,"tokens":274,"sizeBytes":1119},{"name":"rendering-conditional-render.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-conditional-render.md -->","frontmatter":{"title":"Use Explicit Conditional Rendering","impact":"LOW","impactDescription":"prevents rendering 0 or NaN","tags":"rendering, conditional, jsx, falsy-values"},"isInternal":false,"tokens":257,"sizeBytes":928},{"name":"rendering-content-visibility.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-content-visibility.md -->","frontmatter":{"title":"CSS content-visibility for Long Lists","impact":"HIGH","impactDescription":"faster initial render","tags":"rendering, css, content-visibility, long-lists"},"isInternal":false,"tokens":205,"sizeBytes":813},{"name":"rendering-hoist-jsx.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hoist-jsx.md -->","frontmatter":{"title":"Hoist Static JSX Elements","impact":"LOW","impactDescription":"avoids re-creation","tags":"rendering, jsx, static, optimization"},"isInternal":false,"tokens":226,"sizeBytes":989},{"name":"rendering-hydration-no-flicker.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-no-flicker.md -->","frontmatter":{"title":"Prevent Hydration Mismatch Without Flickering","impact":"MEDIUM","impactDescription":"avoids visual flicker and hydration errors","tags":"rendering, ssr, hydration, localStorage, flicker"},"isInternal":false,"tokens":467,"sizeBytes":2230},{"name":"rendering-hydration-suppress-warning.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-hydration-suppress-warning.md -->","frontmatter":{"title":"Suppress Expected Hydration Mismatches","impact":"LOW-MEDIUM","impactDescription":"avoids noisy hydration warnings for known differences","tags":"rendering, hydration, ssr, nextjs"},"isInternal":false,"tokens":189,"sizeBytes":850},{"name":"rendering-svg-precision.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-svg-precision.md -->","frontmatter":{"title":"Optimize SVG Precision","impact":"LOW","impactDescription":"reduces file size","tags":"rendering, svg, optimization, svgo"},"isInternal":false,"tokens":167,"sizeBytes":588},{"name":"rendering-usetransition-loading.md","path":".agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frendering-usetransition-loading.md -->","frontmatter":{"title":"Use useTransition Over Manual Loading States","impact":"LOW","impactDescription":"reduces re-renders and improves code clarity","tags":"rendering, transitions, useTransition, loading, state"},"isInternal":false,"tokens":457,"sizeBytes":2068},{"name":"rerender-defer-reads.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-defer-reads.md -->","frontmatter":{"title":"Defer State Reads to Usage Point","impact":"MEDIUM","impactDescription":"avoids unnecessary subscriptions","tags":"rerender, searchParams, localStorage, optimization"},"isInternal":false,"tokens":231,"sizeBytes":961},{"name":"rerender-dependencies.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-dependencies.md -->","frontmatter":{"title":"Narrow Effect Dependencies","impact":"LOW","impactDescription":"minimizes effect re-runs","tags":"rerender, useEffect, dependencies, optimization"},"isInternal":false,"tokens":214,"sizeBytes":824},{"name":"rerender-derived-state-no-effect.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state-no-effect.md -->","frontmatter":{"title":"Calculate Derived State During Rendering","impact":"MEDIUM","impactDescription":"avoids redundant renders and state drift","tags":"rerender, derived-state, useEffect, state"},"isInternal":false,"tokens":283,"sizeBytes":1201},{"name":"rerender-derived-state.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-derived-state.md -->","frontmatter":{"title":"Subscribe to Derived State","impact":"MEDIUM","impactDescription":"reduces re-render frequency","tags":"rerender, derived-state, media-query, optimization"},"isInternal":false,"tokens":174,"sizeBytes":727},{"name":"rerender-functional-setstate.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-functional-setstate.md -->","frontmatter":{"title":"Use Functional setState Updates","impact":"MEDIUM","impactDescription":"prevents stale closures and unnecessary callback recreations","tags":"react, hooks, useState, useCallback, callbacks, closures"},"isInternal":false,"tokens":660,"sizeBytes":2977},{"name":"rerender-lazy-state-init.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-lazy-state-init.md -->","frontmatter":{"title":"Use Lazy State Initialization","impact":"MEDIUM","impactDescription":"wasted computation on every render","tags":"react, hooks, useState, performance, initialization"},"isInternal":false,"tokens":432,"sizeBytes":1992},{"name":"rerender-memo-with-default-value.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo-with-default-value.md -->","frontmatter":{"title":"Extract Default Non-primitive Parameter Value from Memoized Component to Constant","impact":"MEDIUM","impactDescription":"restores memoization by using a constant for default value","tags":"rerender, memo, optimization"},"isInternal":false,"tokens":248,"sizeBytes":1171},{"name":"rerender-memo.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-memo.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-memo.md -->","frontmatter":{"title":"Extract to Memoized Components","impact":"MEDIUM","impactDescription":"enables early returns","tags":"rerender, memo, useMemo, optimization"},"isInternal":false,"tokens":278,"sizeBytes":1140},{"name":"rerender-move-effect-to-event.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-move-effect-to-event.md -->","frontmatter":{"title":"Put Interaction Logic in Event Handlers","impact":"MEDIUM","impactDescription":"avoids effect re-runs and duplicate side effects","tags":"rerender, useEffect, events, side-effects, dependencies"},"isInternal":false,"tokens":285,"sizeBytes":1268},{"name":"rerender-simple-expression-in-memo.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-simple-expression-in-memo.md -->","frontmatter":{"title":"Do not wrap a simple expression with a primitive result type in useMemo","impact":"LOW-MEDIUM","impactDescription":"wasted computation on every render","tags":"rerender, useMemo, optimization"},"isInternal":false,"tokens":217,"sizeBytes":1014},{"name":"rerender-transitions.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-transitions.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-transitions.md -->","frontmatter":{"title":"Use Transitions for Non-Urgent Updates","impact":"MEDIUM","impactDescription":"maintains UI responsiveness","tags":"rerender, transitions, startTransition, performance"},"isInternal":false,"tokens":243,"sizeBytes":1049},{"name":"rerender-use-ref-transient-values.md","path":".agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Frerender-use-ref-transient-values.md -->","frontmatter":{"title":"Use useRef for Transient Values","impact":"MEDIUM","impactDescription":"avoids unnecessary re-renders on frequent updates","tags":"rerender, useref, state, performance"},"isInternal":false,"tokens":436,"sizeBytes":1742},{"name":"server-after-nonblocking.md","path":".agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-after-nonblocking.md -->","frontmatter":{"title":"Use after() for Non-Blocking Operations","impact":"MEDIUM","impactDescription":"faster response times","tags":"server, async, logging, analytics, side-effects"},"isInternal":false,"tokens":450,"sizeBytes":1983},{"name":"server-auth-actions.md","path":".agents/skills/vercel-react-best-practices/rules/server-auth-actions.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-auth-actions.md -->","frontmatter":{"title":"Authenticate Server Actions Like API Routes","impact":"CRITICAL","impactDescription":"prevents unauthorized access to server mutations","tags":"server, server-actions, authentication, security, authorization"},"isInternal":false,"tokens":577,"sizeBytes":2615},{"name":"server-cache-lru.md","path":".agents/skills/vercel-react-best-practices/rules/server-cache-lru.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-lru.md -->","frontmatter":{"title":"Cross-Request LRU Caching","impact":"HIGH","impactDescription":"caches across requests","tags":"server, cache, lru, cross-request"},"isInternal":false,"tokens":325,"sizeBytes":1347},{"name":"server-cache-react.md","path":".agents/skills/vercel-react-best-practices/rules/server-cache-react.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-cache-react.md -->","frontmatter":{"title":"Per-Request Deduplication with React.cache()","impact":"MEDIUM","impactDescription":"deduplicates within request","tags":"server, cache, react-cache, deduplication"},"isInternal":false,"tokens":507,"sizeBytes":2205},{"name":"server-dedup-props.md","path":".agents/skills/vercel-react-best-practices/rules/server-dedup-props.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-dedup-props.md -->","frontmatter":{"title":"Avoid Duplicate Serialization in RSC Props","impact":"LOW","impactDescription":"reduces network payload by avoiding duplicate serialization","tags":"server, rsc, serialization, props, client-components"},"isInternal":false,"tokens":502,"sizeBytes":2064},{"name":"server-parallel-fetching.md","path":".agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-parallel-fetching.md -->","frontmatter":{"title":"Parallel Data Fetching with Component Composition","impact":"CRITICAL","impactDescription":"eliminates server-side waterfalls","tags":"server, rsc, parallel-fetching, composition"},"isInternal":false,"tokens":365,"sizeBytes":1550},{"name":"server-serialization.md","path":".agents/skills/vercel-react-best-practices/rules/server-serialization.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/rules/server-serialization.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2Frules%2Fserver-serialization.md -->","frontmatter":{"title":"Minimize Serialization at RSC Boundaries","impact":"HIGH","impactDescription":"reduces data transfer size","tags":"server, rsc, serialization, props"},"isInternal":false,"tokens":244,"sizeBytes":992},{"name":"SKILL.md","path":".agents/skills/vercel-react-best-practices/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/vercel-react-best-practices/SKILL.md","title":"vercel-react-best-practices","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fvercel-react-best-practices%2FSKILL.md -->","frontmatter":{"name":"vercel-react-best-practices","description":"React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.","license":"MIT","metadata":{"author":"vercel","version":"1.0.0"}},"isInternal":false,"tokens":1521,"sizeBytes":6357},{"name":"SKILL.md","path":".agents/skills/write-a-skill/SKILL.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/.agents/skills/write-a-skill/SKILL.md","title":"write-a-skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=.agents%2Fskills%2Fwrite-a-skill%2FSKILL.md -->","frontmatter":{"name":"write-a-skill","description":"Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill."},"isInternal":false,"tokens":670,"sizeBytes":3056},{"name":"AGENTS.md","path":"packages/sanity/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/AGENTS.md","title":"Sanity Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FAGENTS.md -->","isInternal":false,"tokens":1939,"sizeBytes":12368},{"name":"README.md","path":"packages/@repo/ailf/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@repo/ailf/README.md","title":"ailf Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Failf%2FREADME.md -->","isInternal":false,"tokens":57,"sizeBytes":235},{"name":"README.md","path":"packages/@repo/debug-proxy/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@repo/debug-proxy/README.md","title":"debug-proxy Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fdebug-proxy%2FREADME.md -->","isInternal":false,"tokens":3175,"sizeBytes":12911},{"name":"README.md","path":"packages/@repo/pandacss/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@repo/pandacss/README.md","title":"pandacss Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Fpandacss%2FREADME.md -->","isInternal":false,"tokens":848,"sizeBytes":3317},{"name":"README.md","path":"packages/@repo/release-notes/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@repo/release-notes/README.md","title":"release-notes Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40repo%2Frelease-notes%2FREADME.md -->","isInternal":false,"tokens":349,"sizeBytes":1494},{"name":"README.md","path":"packages/@sanity/diff/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/diff/README.md","title":"diff Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fdiff%2FREADME.md -->","isInternal":false,"tokens":378,"sizeBytes":1638},{"name":"README.md","path":"packages/@sanity/mutator/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/mutator/README.md","title":"mutator Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fmutator%2FREADME.md -->","isInternal":false,"tokens":36,"sizeBytes":117},{"name":"README.md","path":"packages/@sanity/schema/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/schema/README.md","title":"schema Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2FREADME.md -->","isInternal":false,"tokens":590,"sizeBytes":2385},{"name":"README.md","path":"packages/@sanity/schema/src/descriptors/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/schema/src/descriptors/README.md","title":"descriptors Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fschema%2Fsrc%2Fdescriptors%2FREADME.md -->","isInternal":false,"tokens":959,"sizeBytes":4036},{"name":"README.md","path":"packages/@sanity/util/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/util/README.md","title":"util Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Futil%2FREADME.md -->","isInternal":false,"tokens":13,"sizeBytes":59},{"name":"README.md","path":"packages/@sanity/validation/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/validation/README.md","title":"validation Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvalidation%2FREADME.md -->","isInternal":false,"tokens":599,"sizeBytes":2897},{"name":"README.md","path":"packages/@sanity/vision/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/@sanity/vision/README.md","title":"vision Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2F%40sanity%2Fvision%2FREADME.md -->","isInternal":false,"tokens":534,"sizeBytes":2144},{"name":"README.md","path":"packages/groq/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/groq/README.md","title":"groq Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fgroq%2FREADME.md -->","isInternal":false,"tokens":382,"sizeBytes":1543},{"name":"README.md","path":"packages/sanity/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/README.md","title":"sanity Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2FREADME.md -->","isInternal":false,"tokens":1694,"sizeBytes":6702},{"name":"README.md","path":"packages/sanity/src/core/error/sentry/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/core/error/sentry/README.md","title":"sentry Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Ferror%2Fsentry%2FREADME.md -->","isInternal":false,"tokens":48,"sizeBytes":224},{"name":"README.md","path":"packages/sanity/src/core/scheduled-publishing/components/dateInputs/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/core/scheduled-publishing/components/dateInputs/README.md","title":"dateInputs Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2Fcomponents%2FdateInputs%2FREADME.md -->","isInternal":false,"tokens":449,"sizeBytes":2060},{"name":"README.md","path":"packages/sanity/src/core/scheduled-publishing/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/core/scheduled-publishing/README.md","title":"scheduled-publishing Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fscheduled-publishing%2FREADME.md -->","isInternal":false,"tokens":1465,"sizeBytes":6663},{"name":"README.md","path":"packages/sanity/src/core/store/events/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/core/store/events/README.md","title":"events Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fstore%2Fevents%2FREADME.md -->","isInternal":false,"tokens":1209,"sizeBytes":6091},{"name":"README.md","path":"packages/sanity/src/core/variants/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/core/variants/README.md","title":"variants Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Fcore%2Fvariants%2FREADME.md -->","isInternal":false,"tokens":2580,"sizeBytes":12188},{"name":"README.md","path":"packages/sanity/src/router/README.md","rawUrl":"https://raw.githubusercontent.com/sanity-io/sanity/HEAD/packages/sanity/src/router/README.md","title":"router Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/sanity-io/sanity?path=packages%2Fsanity%2Fsrc%2Frouter%2FREADME.md -->","isInternal":false,"tokens":2228,"sizeBytes":8816}],"systemPromptSnippet":"<yakaai_skills repo=\"sanity-io/sanity\">\n<!-- File: AGENTS.md (Tokens: ~11916 | Category: root-instruction) -->\n# AGENTS.md - AI Agent Guidelines for Sanity Monorepo\n\nThis document helps AI agents work successfully with the Sanity monorepo.\n\n> **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.\n\n## Prerequisites\n\n- **Node.js**: v24 or latest LTS. Published packages must declare `\"engines\": { \"node\": \">=22.12\" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing).\n- **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json)\n\n## Quick Reference\n\n```bash\n# Install dependencies (pnpm ONLY - enforced)\npnpm install\n\n# Build all packages (required before testing)\npnpm build\n\n# Run dev studio (requires auth, see below)\npnpm dev\n\n# Format code (MUST pass CI)\npnpm chore:format:fix\n\n# Fix all lint issues (MUST pass CI) — includes TypeScript type checking via oxlint\npnpm lint:fix\n\n# Run tests\npnpm test\n\n# Update snapshots if tests fail due to expected changes\npnpm test -- -u\n\n# Lint + type check (oxlint typeAware + typeCheck; no separate tsc step)\npnpm check:oxlint\n```\n\n## CI Checks - What Must Pass\n\nThese checks run on every PR and **must pass**:\n\n| Check            | Command               | Notes                                                                                                                                                            |\n| ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Format**       | `pnpm check:format`   | Uses oxfmt. Fix with `pnpm chore:format:fix`                                                                                                                     |\n| **Oxlint**       | `pnpm check:oxlint`   | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix`                              |\n| **Unit Tests**   | `pnpm test`           | Vitest, sharded in CI                                                                                                                                            |\n| **Export Tests** | `pnpm test:exports`   | Ensures ESM/CJS/DTS work                                                                                                                                         |\n| **Dep Check**    | `pnpm depcheck`       | Finds unused/missing deps                                                                                                                                        |\n| **Zizmor**       | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. |\n| **PR Title**     | Conventional commits  | e.g., `feat(scope): description`                                                                                                                                 |\n\n### Before Committing\n\nRun these commands to avoid CI failures:\n\n```bash\n# Fix all formatting and lint issues\npnpm lint:fix\n\n# Verify tests pass (build first if needed)\npnpm build && pnpm test\n```\n\nIf tests fail due to **expected snapshot changes**, update them:\n\n```bash\npnpm test -- -u\n```\n\nSnapshot files are located in `__snapshots__` directories alongside test files.\n\n## Project Structure\n\n```\nsanity/\n├── packages/\n│   ├── sanity/           # Main Sanity studio package\n│   ├── @sanity/          # Scoped packages (cli, types, schema, etc.)\n│   └── @repo/            # Internal tooling (test-config, tsconfig, etc.)\n├── dev/                  # Development studios for testing\n│   ├── test-studio/      # Primary dev studio (pnpm dev runs this)\n│   ├── studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON\n│   └── preview-iframe/   # Presentation preview iframe (vanilla Vite, port 3334)\n├── e2e/                  # End-to-end Playwright tests\n├── perf/                 # Performance testing\n└── examples/             # Example studios\n```\n\n### Key Packages\n\n- **`packages/sanity`** - Core studio package with all UI components\n- **`packages/@sanity/types`** - TypeScript type definitions\n- **`packages/@sanity/schema`** - Schema compilation\n- **`packages/@sanity/mutator`** - Document mutation logic\n\n## Build System\n\n- **Package Manager**: pnpm (version 10.x, enforced via `preinstall`)\n- **Build Orchestration**: Turbo (caches builds)\n- **Versioning**: Lerna-lite with conventional commits\n\n### Build Commands\n\n```bash\npnpm build              # Build all packages\npnpm watch              # Watch mode for development\n```\n\n### Running the Dev Studio\n\n```bash\npnpm dev                # Starts dev studio at http://localhost:3333\n```\n\n**Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio.\n\n## Local Development\n\nThis section clarifies what requires authentication and what doesn't—critical for AI agents to avoid getting stuck on auth flows.\n\n### Running Tests (No Auth Required)\n\nUnit tests run in jsdom with mocks and **do not require any authentication**:\n\n```bash\n# Build first (required), then run all tests\npnpm build && pnpm test\n\n# Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests)\npnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts\n\n# Run a single test file with verbose output\npnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts\n\n# Watch mode for iterative development\npnpm test -- --watch\n\n# Run tests for a specific package\npnpm test -- --project=sanity\n```\n\n**Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file — it runs all tests across all projects. Use `pnpm vitest run --project=<project> <path>` instead.\n\nThe headless validation package is registered as `@sanity/validation`: run its full suite with\n`pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's\ncompilation path too: call `inferFromSchema` on the built-in schema before compiling custom types,\nthen call it on the compiled custom schema. Studio inherits already-normalized built-in rules.\n\nComponents that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes.\n\n### Running the Dev Studio (Auth Required)\n\n```bash\npnpm dev  # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334\n```\n\n- **Requires browser authentication** on first visit—you'll be prompted to log in with a Sanity account\n- Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`)\n- Uses staging API by default (`api.sanity.work`)\n- Session persists in browser, so subsequent visits won't require re-authentication\n- `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`.\n- Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`)\n\nUse the dev studio when you need to:\n\n- Visually verify UI changes\n- Test real document editing workflows\n- Debug issues that only appear with real data\n- Exercise Presentation / visual editing against the local preview iframe\n\n### Inspecting Production Builds with Vite DevTools\n\nThe test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` server—no restart needed.\n\n```bash\n# Builds the test studio with devtools enabled, then starts the dev server\n# (so there's a build session to inspect right away)\npnpm devtools:test-studio\n```\n\nOpen `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels.\n\nTo inspect a **new** build after making changes—while `pnpm devtools:test-studio` is still running—run in a second terminal:\n\n```bash\n# Creates a fresh build session that shows up in the running DevTools dock\npnpm devtools:test-studio:build\n```\n\nSessions can be compared against each other in the DevTools UI to diff bundle changes.\n\nHow it works:\n\n- Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools`\n- Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored)\n- The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes\n- Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag\n\n### Debugging presence with fake users\n\n**Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking).\n\n**URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: \"presence-debug\"`) and `show_own` (also show your own session).\n\n### Profiling studio re-renders with React DevTools (agent-react-devtools)\n\nThe test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI — made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling.\n\nFor `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern.\n\n```bash\n# 1. Start the daemon (port 8097)\npnpm --filter sanity-test-studio exec agent-react-devtools start\n\n# 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true)\npnpm react-devtools:test-studio\n\n# 3. Open http://localhost:3333 in a browser, then verify the app registered\npnpm --filter sanity-test-studio exec agent-react-devtools status\n\n# 4. Profile an interaction\npnpm --filter sanity-test-studio exec agent-react-devtools profile start\n# ... interact with the studio ...\npnpm --filter sanity-test-studio exec agent-react-devtools profile stop\npnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10\n```\n\nHow it works and gotchas:\n\n- `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) — it can never reach `sanity build` output.\n- **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs).\n- Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations — production studios don't run StrictMode.\n- Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas).\n\n### Analyzing the `sanity` package bundle\n\nThe `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`:\n\n```bash\npnpm analyze:sanity\n```\n\nThe report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown).\n\n### Auto-updating studio CSS (Lightning CSS `light-dark()`)\n\nThe CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance ≠ Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native — same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo.\n\n### Panda CSS\n\nThe static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in\n`.css.ts` modules next to the components, imported from the generated `#styled-system/css`\nruntime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on\n`pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the\n**only Panda project in the repository** — the `} satisfies Config)` pattern, no presets,\ncontent-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues`\n— and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity`\nwrites Panda CSS. The `migrate-styled-components-to-panda-css` skill\n(`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the\n`&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the\npackage, vanilla-extract stays where it is for now — the `.css.ts` modules of `@sanity/vision`\n(with its `vanillaExtract` tsdown option) and the test studio's\n`plugins/style-outline/styleOutline.css.ts` — until\n[#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps\nthat build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's\n`sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live\nin the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the\nVite apps that build the package from source get their Panda wiring from `@repo/pandacss`\n(`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those\nthemselves.\n\nHow the CSS reaches a page:\n\n- **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts`\n  (the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the\n  diff between two published versions stays readable, see below for the layers) rewrites every\n  `css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`,\n  injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional\n  `./bundle.css` export (`browser`/`style` → the stylesheet, `node`/`default` → a no-op shim).\n  The published code carries no Panda runtime, which is why the manifest resets `imports`\n  under `publishConfig`. A `css()` call the build cannot resolve statically (computed values,\n  loops) makes it warn and bundle the runtime — keep the style objects literal.\n- **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`,\n  Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace\n  packages to their sources (the `monorepo` export condition), so they bundle the package's\n  `.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite`\n  (`packages/@repo/pandacss`): `pandacss({cwd: <packages/sanity>, transform: true})` from\n  `@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its\n  `include` globs and the codegen `outdir` there), and\n  `removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from\n  `@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css`\n  (below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares\n  Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the\n  `@layer reset, base, tokens, recipes, utilities;` statement — the dev counterpart of the\n  `sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and\n  `packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts`\n  (`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import\n  injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin,\n  registered for dev only. A `sanity build` (and the viewer's build) resolves the built package\n  and its `bundle.css`, so nothing Panda runs there — which is also why a studio that installs\n  `sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS\n  support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and\n  keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from\n  every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the\n  test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's\n  modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is\n  wrapped so that its transform's hook filter excludes `packages/sanity/src/` — it would otherwise\n  compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css`\n  import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite`\n  directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test\n  asserts on styles.\n- **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`,\n  `recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats\n  every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and\n  selected states to any unlayered rule that also matches — styled-components output, a reset\n  like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's\n  write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after\n  Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before\n  its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and\n  the studio's rules cascade like the vanilla-extract output did: by specificity, then source\n  order. The same pass runs on the dev stylesheets and the CDN bundle through\n  `removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS\n  module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own\n  `panda.css`), and leaves other layers alone — which is why that file declares Panda's default\n  layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a\n  single `@layer`. Panda's `polyfill` option is\n  not an alternative: it emulates the layer order with `:not(#\\#…)` selector chains that inflate\n  every rule's specificity. The studio declares no `@layer` statement of its own either: with\n  `bundle.css` unlayered and `@sanity/ui` ≥ 4.4.8 (the catalog version) shipping its static styles\n  unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a\n  studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing\n  to order it against — the same situation as before the migration. What this means when\n  authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order\n  alone — a plain class beats a styled-components base rule that loads later only when it is more\n  specific, which is what the `&&` doubling is for.\n- **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct\n  `property: value` pair once, grouped by property with the values sorted; a modifier class that\n  must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`)\n  instead of relying on being declared later, and parent-state selectors key on data attributes\n  the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in\n  `ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted.\n\n### Studio performance benchmarks (perf/bench — No Auth Required)\n\nThe `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API — fully hermetic, no tokens, no network:\n\n```bash\npnpm build:bench                                   # build packages + bench studio (required first)\npnpm bench help                                    # list all bench CLI commands\npnpm bench run --scenario singleString             # absolute interaction benchmark\npnpm bench run --mode pageload --scenario singleString  # load vitals + bundle size\npnpm bench:unit                                    # mock-contract + stats unit tests\npnpm bench dev                                     # mock + `sanity dev` for interactive debugging\n```\n\nSee `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.)\n\nThree skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit).\n\n### Engine confidence score (No Auth Required locally)\n\nThe Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail (\"X of N known bugs caught\"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail.\n\n```bash\npnpm engine-confidence stryker --target mutator --full   # also: --target sanity; omit --full for an incremental run\npnpm engine-confidence catalogue                         # --suites unit:mutator,unit:engine skips fuzz; --only <ids>; --check-only\npnpm engine-confidence score                             # writes engine-confidence/reports/engine-confidence-run.json\npnpm engine-confidence store <file> --dry-run            # prints the document id and body, needs no token\npnpm engine-confidence pin                               # recomputes config.json's pinnedHash and bumps version when it changed\npnpm engine-confidence unit                              # the package's unit tests\n```\n\n- **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once.\n- **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards.\n- **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed.\n- **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check.\n- **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules.\n- **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only <id>` to confirm the verdict. `engine-confidence/README.md` has the steps.\n- **Where it shows:** the Radar \"Engine confidence\" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week.\n\n### E2E Tests (Token Required)\n\nE2E tests require authentication tokens. Add these to `.env.local` in the repo root:\n\n```bash\nSANITY_E2E_SESSION_TOKEN=<your-token>\nSANITY_E2E_PROJECT_ID=<project-id>\nSANITY_E2E_DATASET=<dataset-name>\n```\n\n**How to get a token:**\n\n```bash\n# Option 1: Use your CLI token\nsanity login\nsanity debug --secrets  # Look for \"Auth token\"\n\n# Option 2: Create a project token at https://sanity.io/manage\n# Navigate to: Project Settings → API → Tokens → Add API token\n```\n\nThen run E2E tests:\n\n```bash\npnpm e2e:build              # Build E2E studio\npnpm test:e2e               # Run E2E tests\npnpm test:e2e --ui          # Interactive mode\n```\n\n**Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests.\n\nWhen CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `<report-url>/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt).\n\n#### Diagnosing e2e flake: failure diagnostics and the flake report\n\nEvery failed or timed-out e2e test attempt attaches `studio-diagnostics.json` — the same JSON the Studio Diagnostics dialog's \"Copy output\" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog (\"Too many requests\", server error, network error) when it is showing — the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`.\n\nTo turn those captures into numbers (\"how many failed runs were platform-caused?\"), run the flake report:\n\n```bash\npnpm e2e:flake-report --days 7 --out flake-report.md   # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login`\npnpm e2e:flake-report --help\npnpm --filter e2e test:unit                              # classifier unit tests\n```\n\nIt lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact.\n\nStaging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does.\n\n### Important Note for AI Agents\n\n**What requires authentication:**\n\n- Running the dev studio (`pnpm dev`)\n- E2E tests (`pnpm test:e2e`)\n- Any command that connects to Sanity APIs\n\n**What does NOT require authentication:**\n\n- Building packages (`pnpm build`)\n- Running unit tests (`pnpm test`)\n- Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`)\n\n**Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed.\n\n## Coding Standards\n\nCoding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` — no separate `tsc` step. Check your code with:\n\n```bash\npnpm lint              # Check for issues (oxlint, includes type checking)\npnpm lint:fix          # Auto-fix issues (oxfmt + oxlint --fix)\n```\n\nAll packages use **ESM** (`\"type\": \"module\"`). TypeScript strict mode is enabled.\n\nRules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide — run `pnpm lint` and follow the reported messages, which explain the expected pattern.\n\n### Do Not Weaken the Linter\n\nFix the reported problem instead of silencing it. In order of preference:\n\n1. **Fix the code** so the rule passes. This is almost always the right answer.\n2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line <rule> -- <why>`. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green.\n3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative — an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description.\n\nFile-wide `/* oxlint-disable <rule> */` is reserved for files that are an exception as a whole — vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors.\n\n`options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI — drop suppressions when the code underneath them changes.\n\n### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n\nEvery declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the\n`@internal` TSDoc release tag is also exported from\n`sanity/_dangerously_use_private_internals_that_do_not_follow_semver`\n(`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`),\nan entry whose name spells out its contract: anything on it can change or disappear in any\nrelease. The public entries still export those symbols today; the entry is the migration target\nfor consumers that depend on them, ahead of the public entries dropping them in a future major.\n`packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of\n`pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry,\nwhen the two entries export different declarations under one name, or when the entry exports\nsomething that is not tagged `@internal`.\n\nRules that follow from this:\n\n- A new `@internal` export on a public barrel needs a matching line in the internals barrel\n  (grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity`\n  (`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from\n  `sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing\n  relatively: the entry re-exports modules from all three areas, so importing it from any of them\n  is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in\n  with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the\n  three areas.\n- The entry is generated from the tags, not curated: whether a symbol belongs there is decided by\n  its `@internal` tag. Retagging a symbol (`@internal` → `@beta`/`@public`, or the other way) is\n  the API decision; check sanity.io/docs and the published first-party plugins before demoting a\n  symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the\n  studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure\n  `renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`,\n  `generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one\n  deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its\n  `@internal @deprecated` tags because those tell customers to stop using it, it stays off the\n  entry so the deprecated `sanity` import remains the only one, and it is listed in\n  `PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by\n  the internals moving. Do not retag it.\n- The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly;\n  a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a\n  404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports`\n  regenerates the d.ts fixture for the entry after its export list changes.\n- A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`,\n  a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the\n  entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u`\n  regenerates it without a build.\n\n### An entry's type surface is what its barrel exports, nothing more\n\nA bundled `.d.ts` can only name declarations the entry barrel actually exports. Until\n`rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {…}` clause\nhit TypeScript's rule that every top-level declaration of such a declaration file is implicitly\nexported, so private helpers pulled in for a signature became part of the published type surface —\nincluding `const`s and `function`s that the emitted `.js` never exported, so importing them\ntype-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead\n([\"prevent implicit exports of private declarations\"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)),\nso only explicit exports are public.\n\nThat makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a\nname no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check\neach dropped name before regenerating:\n\n- A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as\n  a type-only re-export (`export {type Foo} from '…'`), which leaves the emitted `.js` untouched.\n- A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup\n  artifact that was never API — `sanity/media-library` emitted one because `VideoDefinition` is\n  declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared\n  twice, pick which one is public by convention, not by which one the old output left unsuffixed:\n  that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like\n  `ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`,\n  while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation.\n- A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported\n  it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost\n  more than it looks — re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into\n  `sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away.\n- Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from\n  `createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and\n  `lib-check.test.ts` runs with `skipLibCheck: false`.\n\nRegenerating needs a full `pnpm build` first: the generator reads each package's built\n`lib/**/*.d.ts`.\n\n### Effect events: use `use-effect-event`, not React's native hook\n\nImport `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook\nreturns first-render values when the calling component is wrapped in `forwardRef` or `memo`\n([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries).\n`eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that\nwraps the native hook, so check the implementation before trusting one.\n\n### react-rx: stable observables, explicit initial values\n\n`react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the\n`initialValue` (required — pass `undefined` explicitly when there is nothing better) until the\nsubscription started on commit delivers a value, and a synchronous emission arrives one pass later.\nTwo rules follow:\n\n- **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a\n  store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than\n  on arrays or objects recreated every render. An observable rebuilt each render is torn down and\n  re-subscribed each commit; when it synchronously replays a value that differs from the\n  `initialValue`, React aborts with \"Maximum update depth exceeded\"\n  (`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case).\n- **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a\n  first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's\n  exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render\n  when it depends on the observable's parameters (`useEditState`), since react-rx captures\n  `initialValue` once per hook instance.\n\n### Translate: never define `components` inline\n\nComponents passed to `<Translate>`'s `components` map must be stable, module-scope components —\ndefining them inline during render creates a new component identity every render and remounts\nthe subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain\nHTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule\n`@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces\nthis for object literals in the JSX attribute; maps built during render via `useMemo` or factory\nfunctions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate`\nskill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns.\n\n### Locale namespaces: plugin strings stay inside the plugin\n\nOnly `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always\nincluded) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`,\n`structure`, ...) is registered by a plugin that a studio can leave out, and a component that\nreads it while that plugin is not loaded renders raw keys. Strings for code that renders outside\na plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two\nin-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and\n`@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace\nthey are read from). Both read the namespace table in\n`packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or\nchanging which plugin registers one.\n\n### `<Activity mode=\"hidden\">`: effects are torn down, DOM and state stay\n\nReact's `<Activity>` is used for closed `@sanity/ui` overlays, collapsed document lists, and the\ntop-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs\nevery effect cleanup in the subtree and showing runs the effects again, without a remount, so:\n\n- Anything that lives only in an effect-created object is lost on reveal unless the effect can\n  rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup\n  and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for\n  that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal.\n- Loading states come back on reveal unless the code remembers it already loaded. A comlink\n  channel recreated by an effect handshakes as a brand new connection (the presentation machine\n  tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a\n  `startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes\n  it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context\n  populated by registering in an effect empties out while hidden, unmounting whatever renders\n  from it (`PresentationDocumentProvider` lists its own options at render time).\n- Never reorder keyed siblings that hold an `<iframe>`. React moves the DOM node when the key\n  order changes, and a re-inserted iframe reloads. `useMountedTools` renders tools in workspace\n  order for this reason even though eviction is least-recently-used.\n- `@sanity/ui`'s `useElementSize` (4.2.4) stops reporting after a reveal. Its shared\n  `_elementSizeObserver` keeps one `ResizeObserver` per element and disconnects it when the last\n  subscriber leaves, but never forgets the element, so the effect re-run after a reveal finds the\n  cached entry and creates no new observer: the hook keeps the size from before hiding, also for\n  later resizes. Any layout derived from it inside a tool (Vision's column/stacked breakpoints,\n  `PaneLayout`, `DocumentLayout`, the PTE toolbar) freezes. Vision's redesign measures with its\n  own `useContentSize`, which observes afresh on every effect run; the fix belongs in `@sanity/ui`.\n- Hidden content keeps `display: none` on its topmost host nodes only, and portals under the\n  boundary are hidden too (`hideOrUnhideNearestPortals`); descendants report their own `display`.\n\n### Refs: use `props.ref`, not `forwardRef`\n\nReact 19 passes `ref` as a regular prop. Do not use `forwardRef` — destructure `ref` from props\n(so it is not left in a `...rest` spread) and forward it like any other prop.\n`eslint/no-restricted-imports` bans importing `forwardRef` from `react`.\n\nPrefer a named function declaration over `const X = function …` / arrow wrappers:\n\n```ts\n// preferred\nexport function MyComponent(props: Props & RefAttributes<HTMLDivElement>) {\n  const {ref, ...rest} = props\n  return <div ref={ref} {...rest} />\n}\n\n// avoid\nexport const MyComponent = function MyComponent(props: …) { … }\nexport const MyComponent = (props: …) => { … }\n```\n\nWhen wrapping with `memo`, declare the component as a function first, then memoize:\n\n```ts\nfunction MyComponent(props: …) { … }\nexport const MyComponentMemo = memo(MyComponent)\n```\n\nDo not assign `.displayName` on components, HOCs, styled components, or `createContext`\nresults. That property write is a module-level side effect and keeps the export from being\ntree-shaken. Named function declarations already give React DevTools a name via\n`function.name`. Exception (temporary): document/badge hooks prefixed with `use` and the\nHookState collection still set `displayName` — leave those until that API is reworked.\n\nFor typings, include `ref` on the props type: stop omitting `'ref'` from `HTMLProps` /\n`ComponentProps`, or intersect with `RefAttributes<T>`. Avoid `PropsWithRef` — in `@types/react`\n19 it is a deprecated identity alias and trips `typescript/no-deprecated`.\n\n## Testing\n\n### Unit Tests (Vitest)\n\n```bash\npnpm test                    # Run all tests\npnpm test -- --watch        # Watch mode\npnpm test -- -u             # Update snapshots\npnpm test -- --project=sanity  # Run specific project\n```\n\nTests require a build first because some tests use compiled output:\n\n```bash\npnpm build && pnpm test\n```\n\nOn macOS, use `TMPDIR=/private/tmp pnpm test` if the E2E summary reporter test fails with a\n`/var` versus `/private/var` path mismatch. The test changes its working directory, which resolves\nthe symlink; using a canonical temporary path keeps its expected and actual paths consistent.\n\n#### Types in tests\n\nApplies to every package's `*.test.ts` / `*.test.tsx` (and `*.browser.test.tsx`) files only.\nDo not use `as unknown as T` to force a value into a narrower public type. If a helper's\nreturn type is not in the union the function accepts, cover that behaviour through a surface\nthat already accepts the value, or drop that case from the typed call. Do not widen a public\nunion solely so a test type-checks. A single `as T` on a deliberately incomplete object is\nfine when the test is the error path for a missing field. This rule does not apply to\nproduction source.\n\n#### Prefer real implementations over test-file doubles\n\nDrive the unit through its production API and the same factories production uses.\nDo not add mock functions, stub components, or a parallel compiler to the test file\njust to reach a branch.\n\n- Compile schemas with Studio `createSchema` plus `defineType` / `defineField`\n  (`packages/sanity/src/core/schema/createSchema.ts`). That path validates, parents\n  the builtin schema, and runs `inferValidation`. Do not hand-roll\n  `@sanity/schema` `Schema.compile({types: [...]})` unless the test is about that\n  compiler.\n- Call the production function (`createPrepareFormState()`, an exported mapper)\n  without `vi.fn` decorator spies unless the suite is about call counts.\n- Typed lookups of a production union (`fieldMember`, `errorMember`) are accessors,\n  not doubles. A schema-construction helper for a depth fixture is fine. A second\n  fake form tree, or a local `vi.mock` of the module under test, is not.\n- A harness that needs a mock client and a page of `vi.fn` callbacks is the wrong\n  seam when the unit is a pure functio\n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: CLAUDE.md (Tokens: ~5 | Category: claude-rule) -->\n@AGENTS.md\n\n<!-- File: .agents/skills/before-and-after/SKILL.md (Tokens: ~1747 | Category: anthropic-skill) -->\n---\nname: before-and-after\ndescription: Add existing screenshots or screen recordings to a GitHub pull request as a before/after or preview block. Use when a PR needs visual media attached to its description. Browser navigation and capture belong to agent-browser.\n---\n\n# Add visual media to a PR\n\nUse `agent-browser` to create the screenshots or recordings. This skill only owns the GitHub PR attachment workflow.\n\n## Capture\n\n1. Load the version-matched core instructions with `agent-browser skills get core --full` and follow them for sessions, navigation, page state, screenshots, and recordings.\n2. If a Vercel URL is protected, load `agent-browser skills get protected-vercel-deployments --full`. Do not reproduce its authentication workflow here.\n3. Save media under the repository with paths that contain no whitespace, for example:\n\n   ```text\n   captures/desktop-before.png\n   captures/desktop-after.png\n   captures/mobile-before.png\n   captures/mobile-after.png\n   ```\n\nThe formatter supports PNG, JPEG, GIF, WebP, MP4, MOV, and WebM files. Use an `--after` file without a matching `--before` file for a net-new preview.\n\n### Screen recordings\n\n`agent-browser record start` creates a fresh browser context. It preserves cookies and local storage, but an origin-scoped header used to open a protected Vercel Preview may not carry into that new context. Before recording a protected Preview:\n\n1. Load `agent-browser skills get protected-vercel-deployments --full`.\n2. Start the recording context on a blank page.\n3. Apply the authentication method from that skill inside the recording context.\n4. Navigate to the Preview only after authentication is active, then trim the navigation lead-in if necessary.\n\nDo not assume that a page which worked before `record start` will remain authenticated after it.\n\nAgent-browser `0.35.2` and `0.36.0` capture at a hardcoded 10 fps with no CLI or environment override. Inspect the installed version and its recording reference rather than assuming this stays true in later releases. Preserve the source cadence when transcoding: changing the container to 30 or 60 fps only duplicates frames and does not make motion smoother. For animation evidence that requires a higher real frame rate, use a genuinely higher-cadence capture path instead of upsampling agent-browser output.\n\n### Equal-height image pairs\n\nGitHub vertically centers a shorter image inside a Markdown table cell. For full-page before/after screenshots, make both files the same pixel height so their top edges align:\n\n1. Open both pages at the same viewport and state.\n2. Read `document.documentElement.scrollHeight` in both sessions.\n3. Append bottom-only space to the shorter page until both scroll heights match, then take both `--full` screenshots.\n\nThe padding may be transparent or use the capture tool's default canvas. Never add space above the page. For component or section comparisons, capture the same scoped region instead of padding unrelated page content.\n\nUse raw `agent-browser eval` for this DOM-only adjustment; do not add an image-processing dependency to this skill. Confirm the resulting files have equal pixel dimensions before publishing.\n\n## Format\n\nPass one `--before` and `--after` pair for each comparison. Repeat `--label` to identify multiple pairs:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  --before captures/mobile-before.png \\\n  --after captures/mobile-after.png \\\n  --label Desktop \\\n  --label Mobile \\\n  > /tmp/before-and-after.md\n```\n\nFor an after-only preview:\n\n```bash\nnode skill/scripts/format.mjs \\\n  --after captures/new-page.png \\\n  > /tmp/before-and-after.md\n```\n\nAdd `--attribution \"<name>\"` to prefix the block with a `> Before/after by <name>` line when the PR should credit who produced the evidence.\n\nImages render in tables. Local videos initially render on their own lines so `gh --attach` can upload them and expose their final attachment URLs. Before/after video tables use the two-step workflow below because `gh --attach` does not rewrite local references inside `<video src>` attributes.\n\n## Place the evidence\n\nRead the existing PR description before inserting a new marked block. Put visual evidence near the top, after the short opening context and an existing Preview or deployment-link section when present, but before implementation-heavy sections such as Details, Changes, Testing, or Notes.\n\nUse this reading order inside the visual evidence:\n\n1. Put the real before/after or Preview evidence that proves the PR first.\n2. Put supplemental formats, alternate states, or demonstrations after the primary evidence.\n3. Label anything that demonstrates this skill rather than the PR itself as a demo, and state material limitations beside it. For example, note the installed agent-browser recorder's current 10 fps limit when motion smoothness matters.\n\nHeadings are semantic hints, not required names. Never invent or rewrite prose merely to create an anchor, and never split a paragraph, list, table, code block, or other Markdown structure. If no safe anchor is clear, append the block rather than risking damage. If a marked block already exists, move or replace that whole block only; preserve every byte of unrelated prose.\n\nAfter publishing, open the rendered PR and confirm the primary evidence appears before supplemental demos and before the implementation details.\n\n## Publish\n\nPreserve the existing PR description and replace only this skill's marked block:\n\n```bash\nPR=123\ngh pr view \"$PR\" --json body --jq .body > /tmp/pr-body.md\n\nnode skill/scripts/format.mjs \\\n  --body-file /tmp/pr-body.md \\\n  --before captures/desktop-before.png \\\n  --after captures/desktop-after.png \\\n  > /tmp/pr-body-next.md\n\nATTACH_ARGS=()\nwhile IFS= read -r file; do\n  ATTACH_ARGS+=(--attach \"$file\")\ndone < <(\n  node skill/scripts/format.mjs \\\n    --attach-list \\\n    --before captures/desktop-before.png \\\n    --after captures/desktop-after.png\n)\n\ngh pr edit \"$PR\" --body-file /tmp/pr-body-next.md \"${ATTACH_ARGS[@]}\"\n```\n\nRun the formatter and `gh` from the same directory. `gh --attach` uploads the local files to GitHub and rewrites their matching local references in the PR body.\n\nAfter publishing, fetch or open the PR description and confirm that no `./captures/...` references remain inside the marked block and that the evidence appears in the intended reading order.\n\n### Publish a video table\n\nVideo comparisons are a first-class two-step publish operation:\n\n1. Upload the local videos in a temporary PR comment using the normal own-line output and `gh pr comment --attach`.\n2. Fetch that comment through `gh api` and collect the stable `https://github.com/user-attachments/assets/...` URLs in before/after order.\n3. Generate the final HTML table from those URLs and replace the marked PR block:\n\n   ```bash\n   node skill/scripts/format.mjs \\\n     --body-file /tmp/pr-body.md \\\n     --before-video-url https://github.com/user-attachments/assets/BEFORE_ID \\\n     --after-video-url https://github.com/user-attachments/assets/AFTER_ID \\\n     --label \"Desktop hero\" \\\n     > /tmp/pr-body-next.md\n\n   gh pr edit \"$PR\" --body-file /tmp/pr-body-next.md\n   ```\n\n4. Fetch the edited PR body before deleting the temporary comment. Confirm both final URLs are present and no local video paths remain, then delete the comment.\n5. Open the rendered PR and confirm both `<video>` elements are inside the comparison table, reach a playable ready state, and show controls.\n\nIf URL extraction, formatting, or PR verification fails, keep the temporary comment so its uploaded attachments remain recoverable and retry from the last successful phase. Use own-line videos as the simple fallback.\n\nDo not publish captures containing Vercel OIDC tokens, bypass secrets, authenticated query parameters, or browser state files.\n\n## Script contract\n\n`scripts/format.mjs` is intentionally the only bundled script. It:\n\n- formats existing local media;\n- formats final GitHub video attachment URLs as HTML comparison tables;\n- labels after-only media as `Preview`;\n- emits the exact attachment path list;\n- inserts or replaces `<!-- before-and-after:start/end -->` without changing other PR prose.\n\nIts arguments version with this skill and are not a public library API.\n\n<!-- File: .agents/skills/code-review-and-quality/SKILL.md (Tokens: ~3121 | Category: anthropic-skill) -->\n---\nname: code-review-and-quality\ndescription: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.\n---\n\n# Code Review and Quality\n\n## Overview\n\nMulti-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.\n\n**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.\n\n## When to Use\n\n- Before merging any PR or change\n- After completing a feature implementation\n- When another agent or model produced code you need to evaluate\n- When refactoring existing code\n- After any bug fix (review both the fix and the regression test)\n\n## The Five-Axis Review\n\nEvery review evaluates code across these dimensions:\n\n### 1. Correctness\n\nDoes the code do what it claims to do?\n\n- Does it match the spec or task requirements?\n- Are edge cases handled (null, empty, boundary values)?\n- Are error paths handled (not just the happy path)?\n- Does it pass all tests? Are the tests actually testing the right things?\n- Are there off-by-one errors, race conditions, or state inconsistencies?\n\n### 2. Readability & Simplicity\n\nCan another engineer (or agent) understand this code without the author explaining it?\n\n- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)\n- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?\n- Is the code organized logically (related code grouped, clear module boundaries)?\n- Are there any \"clever\" tricks that should be simplified?\n- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)\n- **Are abstractions earning their complexity?** (Don't generalize until the third use case)\n- Would comments help clarify non-obvious intent? (But don't comment obvious code.)\n- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?\n\n### 3. Architecture\n\nDoes the change fit the system's design?\n\n- Does it follow existing patterns or introduce a new one? If new, is it justified?\n- Does it maintain clean module boundaries?\n- Is there code duplication that should be shared?\n- Are dependencies flowing in the right direction (no circular dependencies)?\n- Is the abstraction level appropriate (not over-engineered, not too coupled)?\n\n### 4. Security\n\nFor detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?\n\n- Is user input validated and sanitized?\n- Are secrets kept out of code, logs, and version control?\n- Is authentication/authorization checked where needed?\n- Are SQL queries parameterized (no string concatenation)?\n- Are outputs encoded to prevent XSS?\n- Are dependencies from trusted sources with no known vulnerabilities?\n- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?\n- Are external data flows validated at system boundaries before use in logic or rendering?\n\n### 5. Performance\n\nFor detailed profiling and optimization, see `performance-optimization`. Does the change introduce performance problems?\n\n- Any N+1 query patterns?\n- Any unbounded loops or unconstrained data fetching?\n- Any synchronous operations that should be async?\n- Any unnecessary re-renders in UI components?\n- Any missing pagination on list endpoints?\n- Any large objects created in hot paths?\n\n## Change Sizing\n\nSmall, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:\n\n```\n~100 lines changed   → Good. Reviewable in one sitting.\n~300 lines changed   → Acceptable if it's a single logical change.\n~1000 lines changed  → Too large. Split it.\n```\n\n**What counts as \"one change\":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.\n\n**Splitting strategies when a change is too large:**\n\n| Strategy          | How                                                     | When                    |\n| ----------------- | ------------------------------------------------------- | ----------------------- |\n| **Stack**         | Submit a small change, start the next one based on it   | Sequential dependencies |\n| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns  |\n| **Horizontal**    | Create shared code/stubs first, then consumers          | Layered architecture    |\n| **Vertical**      | Break into smaller full-stack slices of the feature     | Feature work            |\n\n**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.\n\n**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.\n\n## Change Descriptions\n\nEvery change needs a description that stands alone in version control history.\n\n**First line:** Short, imperative, standalone. \"Delete the FizzBuzz RPC\" not \"Deleting the FizzBuzz RPC.\" Must be informative enough that someone searching history can understand the change without reading the diff.\n\n**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.\n\n**Anti-patterns:** \"Fix bug,\" \"Fix build,\" \"Add patch,\" \"Moving code from A to B,\" \"Phase 1,\" \"Add convenience functions.\"\n\n## Review Process\n\n### Step 1: Understand the Context\n\nBefore looking at code, understand the intent:\n\n```\n- What is this change trying to accomplish?\n- What spec or task does it implement?\n- What is the expected behavior change?\n```\n\n### Step 2: Review the Tests First\n\nTests reveal intent and coverage:\n\n```\n- Do tests exist for the change?\n- Do they test behavior (not implementation details)?\n- Are edge cases covered?\n- Do tests have descriptive names?\n- Would the tests catch a regression if the code changed?\n```\n\n### Step 3: Review the Implementation\n\nWalk through the code with the five axes in mind:\n\n```\nFor each file changed:\n1. Correctness: Does this code do what the test says it should?\n2. Readability: Can I understand this without help?\n3. Architecture: Does this fit the system?\n4. Security: Any vulnerabilities?\n5. Performance: Any bottlenecks?\n```\n\n### Step 4: Categorize Findings\n\nLabel every comment with its severity so the author knows what's required vs optional:\n\n| Prefix                        | Meaning            | Author Action                                           |\n| ----------------------------- | ------------------ | ------------------------------------------------------- |\n| _(no prefix)_                 | Required change    | Must address before merge                               |\n| **Critical:**                 | Blocks merge       | Security vulnerability, data loss, broken functionality |\n| **Nit:**                      | Minor, optional    | Author may ignore — formatting, style preferences       |\n| **Optional:** / **Consider:** | Suggestion         | Worth considering but not required                      |\n| **FYI**                       | Informational only | No action needed — context for future reference         |\n\nThis prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.\n\n### Step 5: Verify the Verification\n\nCheck the author's verification story:\n\n```\n- What tests were run?\n- Did the build pass?\n- Was the change tested manually?\n- Are there screenshots for UI changes?\n- Is there a before/after comparison?\n```\n\n## Multi-Model Review Pattern\n\nUse different models for different review perspectives:\n\n```\nModel A writes the code\n    │\n    ▼\nModel B reviews for correctness and architecture\n    │\n    ▼\nModel A addresses the feedback\n    │\n    ▼\nHuman makes the final call\n```\n\nThis catches issues that a single model might miss — different models have different blind spots.\n\n**Example prompt for a review agent:**\n\n```\nReview this code change for correctness, security, and adherence to\nour project conventions. The spec says [X]. The change should [Y].\nFlag any issues as Critical, Important, or Suggestion.\n```\n\n## Dead Code Hygiene\n\nAfter any refactoring or implementation change, check for orphaned code:\n\n1. Identify code that is now unreachable or unused\n2. List it explicitly\n3. **Ask before deleting:** \"Should I remove these now-unused elements: [list]?\"\n\nDon't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.\n\n```\nDEAD CODE IDENTIFIED:\n- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()\n- OldTaskCard component in src/components/ — replaced by TaskCard\n- LEGACY_API_URL constant in src/config.ts — no remaining references\n→ Safe to remove these?\n```\n\n## Review Speed\n\nSlow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.\n\n- **Respond within one business day** — this is the maximum, not the target\n- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day\n- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed\n- **Large changes:** Ask the author to split them rather than reviewing one massive changeset\n\n## Handling Disagreements\n\nWhen resolving review disputes, apply this hierarchy:\n\n1. **Technical facts and data** override opinions and preferences\n2. **Style guides** are the absolute authority on style matters\n3. **Software design** must be evaluated on engineering principles, not personal preference\n4. **Codebase consistency** is acceptable if it doesn't degrade overall health\n\n**Don't accept \"I'll clean it up later.\"** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.\n\n## Honesty in Review\n\nWhen reviewing code — whether written by you, another agent, or a human:\n\n- **Don't rubber-stamp.** \"LGTM\" without evidence of review helps no one.\n- **Don't soften real issues.** \"This might be a minor concern\" when it's a bug that will hit production is dishonest.\n- **Quantify problems when possible.** \"This N+1 query will add ~50ms per item in the list\" is better than \"this could be slow.\"\n- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.\n- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.\n\n## Dependency Discipline\n\nPart of code review is dependency review:\n\n**Before adding any dependency:**\n\n1. Does the existing stack solve this? (Often it does.)\n2. How large is the dependency? (Check bundle impact.)\n3. Is it actively maintained? (Check last commit, open issues.)\n4. Does it have known vulnerabilities? (`npm audit`)\n5. What's the license? (Must be compatible with the project.)\n\n**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.\n\n## The Review Checklist\n\n```markdown\n## Review: [PR/Change title]\n\n### Context\n\n- [ ] I understand what this change does and why\n\n### Correctness\n\n- [ ] Change matches spec/task requirements\n- [ ] Edge cases handled\n- [ ] Error paths handled\n- [ ] Tests cover the change adequately\n\n### Readability\n\n- [ ] Names are clear and consistent\n- [ ] Logic is straightforward\n- [ ] No unnecessary complexity\n\n### Architecture\n\n- [ ] Follows existing patterns\n- [ ] No unnecessary coupling or dependencies\n- [ ] Appropriate abstraction level\n\n### Security\n\n- [ ] No secrets in code\n- [ ] Input validated at boundaries\n- [ ] No injection vulnerabilities\n- [ ] Auth checks in place\n- [ ] External data sources treated as untrusted\n\n### Performance\n\n- [ ] No N+1 patterns\n- [ ] No unbounded operations\n- [ ] Pagination on list endpoints\n\n### Verification\n\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] Manual verification done (if applicable)\n\n### Verdict\n\n- [ ] **Approve** — Ready to merge\n- [ ] **Request changes** — Issues must be addressed\n```\n\n## See Also\n\n- For detailed security review guidance, see `references/security-checklist.md`\n- For performance review checks, see `references/performance-checklist.md`\n\n## Common Rationalizations\n\n| Rationalization                      | Reality                                                                                                                   |\n| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |\n| \"It works, that's good enough\"       | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds.                           |\n| \"I wrote it, so I know it's correct\" | Authors are blind to their own assumptions. Every change benefits from another set of eyes.                               |\n| \"We'll clean it up later\"            | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after.                      |\n| \"AI-generated code is probably fine\" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong.                                     |\n| \"The tests pass, so it's good\"       | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |\n\n## Red Flags\n\n- PRs merged without any review\n- Review that only checks if tests pass (ignoring other axes)\n- \"LGTM\" without evidence of actual review\n- Security-sensitive changes without security-focused review\n- Large PRs that are \"too big to review properly\" (split them)\n- No regression tests with bug fix PRs\n- Review comments without severity labels — makes it unclear what's required vs optional\n- Accepting \"I'll fix it later\" — it never happens\n\n## Verification\n\nAfter review is complete:\n\n- [ ] All Critical issues are resolved\n- [ ] All Important issues are resolved or explicitly deferred with justification\n- [ ] Tests pass\n- [ ] Build succeeds\n- [ ] The verification story is documented (what changed, how it was verified)\n\n<!-- File: .agents/skills/code-simplification/SKILL.md (Tokens: ~3094 | Category: anthropic-skill) -->\n---\nname: code-simplification\ndescription: Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity.\n---\n\n# Code Simplification\n\n> Inspired by the [Claude Code Simplifier plugin](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/code-simplifier/agents/code-simplifier.md). Adapted here as a model-agnostic, process-driven skill for any AI coding agent.\n\n## Overview\n\nSimplify code by reducing complexity while preserving exact behavior. The goal is not fewer lines — it's code that is easier to read, understand, modify, and debug. Every simplification must pass a simple test: \"Would a new team member understand this faster than the original?\"\n\n## When to Use\n\n- After a feature is working and tests pass, but the implementation feels heavier than it needs to be\n- During code review when readability or complexity issues are flagged\n- When you encounter deeply nested logic, long functions, or unclear names\n- When refactoring code written under time pressure\n- When consolidating related logic scattered across files\n- After merging changes that introduced duplication or inconsistency\n\n**When NOT to use:**\n\n- Code is already clean and readable — don't simplify for the sake of it\n- You don't understand what the code does yet — comprehend before you simplify\n- The code is performance-critical and the \"simpler\" version would be measurably slower\n- You're about to rewrite the module entirely — simplifying throwaway code wastes effort\n\n## The Five Principles\n\n### 1. Preserve Behavior Exactly\n\nDon't change what the code does — only how it expresses it. All inputs, outputs, side effects, error behavior, and edge cases must remain identical. If you're not sure a simplification preserves behavior, don't make it.\n\n```\nASK BEFORE EVERY CHANGE:\n→ Does this produce the same output for every input?\n→ Does this maintain the same error behavior?\n→ Does this preserve the same side effects and ordering?\n→ Do all existing tests still pass without modification?\n```\n\n### 2. Follow Project Conventions\n\nSimplification means making code more consistent with the codebase, not imposing external preferences. Before simplifying:\n\n```\n1. Read CLAUDE.md / project conventions\n2. Study how neighboring code handles similar patterns\n3. Match the project's style for:\n   - Import ordering and module system\n   - Function declaration style\n   - Naming conventions\n   - Error handling patterns\n   - Type annotation depth\n```\n\nSimplification that breaks project consistency is not simplification — it's churn.\n\n### 3. Prefer Clarity Over Cleverness\n\nExplicit code is better than compact code when the compact version requires a mental pause to parse.\n\n```typescript\n// UNCLEAR: Dense ternary chain\nconst label = isNew ? 'New' : isUpdated ? 'Updated' : isArchived ? 'Archived' : 'Active'\n\n// CLEAR: Readable mapping\nfunction getStatusLabel(item: Item): string {\n  if (item.isNew) return 'New'\n  if (item.isUpdated) return 'Updated'\n  if (item.isArchived) return 'Archived'\n  return 'Active'\n}\n```\n\n```typescript\n// UNCLEAR: Chained reduces with inline logic\nconst result = items.reduce(\n  (acc, item) => ({\n    ...acc,\n    [item.id]: {...acc[item.id], count: (acc[item.id]?.count ?? 0) + 1},\n  }),\n  {},\n)\n\n// CLEAR: Named intermediate step\nconst countById = new Map<string, number>()\nfor (const item of items) {\n  countById.set(item.id, (countById.get(item.id) ?? 0) + 1)\n}\n```\n\n### 4. Maintain Balance\n\nSimplification has a failure mode: over-simplification. Watch for these traps:\n\n- **Inlining too aggressively** — removing a helper that gave a concept a name makes the call site harder to read\n- **Combining unrelated logic** — two simple functions merged into one complex function is not simpler\n- **Removing \"unnecessary\" abstraction** — some abstractions exist for extensibility or testability, not complexity\n- **Optimizing for line count** — fewer lines is not the goal; easier comprehension is\n\n### 5. Scope to What Changed\n\nDefault to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions.\n\n## The Simplification Process\n\n### Step 1: Understand Before Touching (Chesterton's Fence)\n\nBefore changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies.\n\n```\nBEFORE SIMPLIFYING, ANSWER:\n- What is this code's responsibility?\n- What calls it? What does it call?\n- What are the edge cases and error paths?\n- Are there tests that define the expected behavior?\n- Why might it have been written this way? (Performance? Platform constraint? Historical reason?)\n- Check git blame: what was the original context for this code?\n```\n\nIf you can't answer these, you're not ready to simplify. Read more context first.\n\n### Step 2: Identify Simplification Opportunities\n\nScan for these patterns — each one is a concrete signal, not a vague smell:\n\n**Structural complexity:**\n\n| Pattern                    | Signal                             | Simplification                                            |\n| -------------------------- | ---------------------------------- | --------------------------------------------------------- |\n| Deep nesting (3+ levels)   | Hard to follow control flow        | Extract conditions into guard clauses or helper functions |\n| Long functions (50+ lines) | Multiple responsibilities          | Split into focused functions with descriptive names       |\n| Nested ternaries           | Requires mental stack to parse     | Replace with if/else chains, switch, or lookup objects    |\n| Boolean parameter flags    | `doThing(true, false, true)`       | Replace with options objects or separate functions        |\n| Repeated conditionals      | Same `if` check in multiple places | Extract to a well-named predicate function                |\n\n**Naming and readability:**\n\n| Pattern                    | Signal                                         | Simplification                                                           |\n| -------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |\n| Generic names              | `data`, `result`, `temp`, `val`, `item`        | Rename to describe the content: `userProfile`, `validationErrors`        |\n| Abbreviated names          | `usr`, `cfg`, `btn`, `evt`                     | Use full words unless the abbreviation is universal (`id`, `url`, `api`) |\n| Misleading names           | Function named `get` that also mutates state   | Rename to reflect actual behavior                                        |\n| Comments explaining \"what\" | `// increment counter` above `count++`         | Delete the comment — the code is clear enough                            |\n| Comments explaining \"why\"  | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express                    |\n\n**Redundancy:**\n\n| Pattern                   | Signal                                                       | Simplification                                            |\n| ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |\n| Duplicated logic          | Same 5+ lines in multiple places                             | Extract to a shared function                              |\n| Dead code                 | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead)                 |\n| Unnecessary abstractions  | Wrapper that adds no value                                   | Inline the wrapper, call the underlying function directly |\n| Over-engineered patterns  | Factory-for-a-factory, strategy-with-one-strategy            | Replace with the simple direct approach                   |\n| Redundant type assertions | Casting to a type that's already inferred                    | Remove the assertion                                      |\n\n### Step 3: Apply Changes Incrementally\n\nMake one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them.\n\n```\nFOR EACH SIMPLIFICATION:\n1. Make the change\n2. Run the test suite\n3. If tests pass → commit (or continue to next simplification)\n4. If tests fail → revert and reconsider\n```\n\nAvoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it.\n\n**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review.\n\n### Step 4: Verify the Result\n\nAfter all simplifications, step back and evaluate the whole:\n\n```\nCOMPARE BEFORE AND AFTER:\n- Is the simplified version genuinely easier to understand?\n- Did you introduce any new patterns inconsistent with the codebase?\n- Is the diff clean and reviewable?\n- Would a teammate approve this change?\n```\n\nIf the \"simplified\" version is harder to understand or review, revert. Not every simplification attempt succeeds.\n\n## Language-Specific Guidance\n\n### TypeScript / JavaScript\n\n```typescript\n// SIMPLIFY: Unnecessary async wrapper\n// Before\nasync function getUser(id: string): Promise<User> {\n  return await userService.findById(id)\n}\n// After\nfunction getUser(id: string): Promise<User> {\n  return userService.findById(id)\n}\n\n// SIMPLIFY: Verbose conditional assignment\n// Before\nlet displayName: string\nif (user.nickname) {\n  displayName = user.nickname\n} else {\n  displayName = user.fullName\n}\n// After\nconst displayName = user.nickname || user.fullName\n\n// SIMPLIFY: Manual array building\n// Before\nconst activeUsers: User[] = []\nfor (const user of users) {\n  if (user.isActive) {\n    activeUsers.push(user)\n  }\n}\n// After\nconst activeUsers = users.filter((user) => user.isActive)\n\n// SIMPLIFY: Redundant boolean return\n// Before\nfunction isValid(input: string): boolean {\n  if (input.length > 0 && input.length < 100) {\n    return true\n  }\n  return false\n}\n// After\nfunction isValid(input: string): boolean {\n  return input.length > 0 && input.length < 100\n}\n```\n\n### Python\n\n```python\n# SIMPLIFY: Verbose dictionary building\n# Before\nresult = {}\nfor item in items:\n    result[item.id] = item.name\n# After\nresult = {item.id: item.name for item in items}\n\n# SIMPLIFY: Nested conditionals with early return\n# Before\ndef process(data):\n    if data is not None:\n        if data.is_valid():\n            if data.has_permission():\n                return do_work(data)\n            else:\n                raise PermissionError(\"No permission\")\n        else:\n            raise ValueError(\"Invalid data\")\n    else:\n        raise TypeError(\"Data is None\")\n# After\ndef process(data):\n    if data is None:\n        raise TypeError(\"Data is None\")\n    if not data.is_valid():\n        raise ValueError(\"Invalid data\")\n    if not data.has_permission():\n        raise PermissionError(\"No permission\")\n    return do_work(data)\n```\n\n### React / JSX\n\n```tsx\n// SIMPLIFY: Verbose conditional rendering\n// Before\nfunction UserBadge({user}: Props) {\n  if (user.isAdmin) {\n    return <Badge variant=\"admin\">Admin</Badge>\n  } else {\n    return <Badge variant=\"default\">User</Badge>\n  }\n}\n// After\nfunction UserBadge({user}: Props) {\n  const variant = user.isAdmin ? 'admin' : 'default'\n  const label = user.isAdmin ? 'Admin' : 'User'\n  return <Badge variant={variant}>{label}</Badge>\n}\n\n// SIMPLIFY: Prop drilling through intermediate components\n// Before — consider whether context or composition solves this better.\n// This is a judgment call — flag it, don't auto-refactor.\n```\n\n## Common Rationalizations\n\n| Rationalization                                      | Reality                                                                                                                                               |\n| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"It's working, no need to touch it\"                  | Working code that's hard to read will be hard to fix when it breaks. Simplifying now saves time on every future change.                               |\n| \"Fewer lines is always simpler\"                      | A 1-line nested ternary is not simpler than a 5-line if/else. Simplicity is about comprehension speed, not line count.                                |\n| \"I'll just quickly simplify this unrelated code too\" | Unscoped simplification creates noisy diffs and risks regressions in code you didn't intend to change. Stay focused.                                  |\n| \"The types make it self-documenting\"                 | Types document structure, not intent. A well-named function explains _why_ better than a type signature explains _what_.                              |\n| \"This abstraction might be useful later\"             | Don't preserve speculative abstractions. If it's not used now, it's complexity without value. Remove it and re-add when needed.                       |\n| \"The original author must have had a reason\"         | Maybe. Check git blame — apply Chesterton's Fence. But accumulated complexity often has no reason; it's just the residue of iteration under pressure. |\n| \"I'll refactor while adding this feature\"            | Separate refactoring from feature work. Mixed changes are harder to review, revert, and understand in history.                                        |\n\n## Red Flags\n\n- Simplification that requires modifying tests to pass (you likely changed behavior)\n- \"Simplified\" code that is longer and harder to follow than the original\n- Renaming things to match your preferences rather than project conventions\n- Removing error handling because \"it makes the code cleaner\"\n- Simplifying code you don't fully understand\n- Batching many simplifications into one large, hard-to-review commit\n- Refactoring code outside the scope of the current task without being asked\n\n## Verification\n\nAfter completing a simplification pass:\n\n- [ ] All existing tests pass without modification\n- [ ] Build succeeds with no new warnings\n- [ ] Linter/formatter passes (no style regressions)\n- [ ] Each simplification is a reviewable, incremental change\n- [ ] The diff is clean — no unrelated changes mixed in\n- [ ] Simplified code follows project conventions (checked against CLAUDE.md or equivalent)\n- [ ] No error handling was removed or weakened\n- [ ] No dead code was left behind (unused imports, unreachable branches)\n- [ ] A teammate or review agent would approve the change as a net improvement\n\n<!-- File: .agents/skills/deslop/SKILL.md (Tokens: ~152 | Category: anthropic-skill) -->\n---\nname: deslop\ndescription: Remove AI-generated code slop and clean up code style\n---\n\n# Remove AI code slop\n\nCheck the diff against main and remove AI-generated slop introduced in the branch.\n\n## Focus Areas\n\n- Extra comments that are unnecessary or inconsistent with local style\n- Defensive checks or try/catch blocks that are abnormal for trusted code paths\n- Casts to `any` used only to bypass type issues\n- Deeply nested code that should be simplified with early returns\n- Other patterns inconsistent with the file and surrounding codebase\n\n## Guardrails\n\n- Keep behavior unchanged unless fixing a clear bug.\n- Prefer minimal, focused edits over broad rewrites.\n- Keep the final summary concise (1-3 sentences).\n\n<!-- File: .agents/skills/find-skills/SKILL.md (Tokens: ~1110 | Category: anthropic-skill) -->\n---\nname: find-skills\ndescription: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n---\n\n# Find Skills\n\nThis skill helps you discover and install skills from the open agent skills ecosystem.\n\n## When to Use This Skill\n\nUse this skill when the user:\n\n- Asks \"how do I do X\" where X might be a common task with an existing skill\n- Says \"find a skill for X\" or \"is there a skill for X\"\n- Asks \"can you do X\" where X is a specialized capability\n- Expresses interest in extending agent capabilities\n- Wants to search for tools, templates, or workflows\n- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)\n\n## What is the Skills CLI?\n\nThe Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.\n\n**Key commands:**\n\n- `npx skills find [query]` - Search for skills interactively or by keyword\n- `npx skills add <package>` - Install a skill from GitHub or other sources\n- `npx skills check` - Check for skill updates\n- `npx skills update` - Update all installed skills\n\n**Browse skills at:** https://skills.sh/\n\n## How to Help Users Find Skills\n\n### Step 1: Understand What They Need\n\nWhen a user asks for help with something, identify:\n\n1. The domain (e.g., React, testing, design, deployment)\n2. The specific task (e.g., writing tests, creating animations, reviewing PRs)\n3. Whether this is a common enough task that a skill likely exists\n\n### Step 2: Search for Skills\n\nRun the find command with a relevant query:\n\n```bash\nnpx skills find [query]\n```\n\nFor example:\n\n- User asks \"how do I make my React app faster?\" → `npx skills find react performance`\n- User asks \"can you help me with PR reviews?\" → `npx skills find pr review`\n- User asks \"I need to create a changelog\" → `npx skills find changelog`\n\nThe command will return results like:\n\n```\nInstall with npx skills add <owner/repo@skill>\n\nvercel-labs/agent-skills@vercel-react-best-practices\n└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 3: Present Options to the User\n\nWhen you find relevant skills, present them to the user with:\n\n1. The skill name and what it does\n2. The install command they can run\n3. A link to learn more at skills.sh\n\nExample response:\n\n```\nI found a skill that might help! The \"vercel-react-best-practices\" skill provides\nReact and Next.js performance optimization guidelines from Vercel Engineering.\n\nTo install it:\nnpx skills add vercel-labs/agent-skills@vercel-react-best-practices\n\nLearn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices\n```\n\n### Step 4: Offer to Install\n\nIf the user wants to proceed, you can install the skill for them:\n\n```bash\nnpx skills add <owner/repo@skill> -g -y\n```\n\nThe `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.\n\n## Common Skill Categories\n\nWhen searching, consider these common categories:\n\n| Category        | Example Queries                          |\n| --------------- | ---------------------------------------- |\n| Web Development | react, nextjs, typescript, css, tailwind |\n| Testing         | testing, jest, playwright, e2e           |\n| DevOps          | deploy, docker, kubernetes, ci-cd        |\n| Documentation   | docs, readme, changelog, api-docs        |\n| Code Quality    | review, lint, refactor, best-practices   |\n| Design          | ui, ux, design-system, accessibility     |\n| Productivity    | workflow, automation, git                |\n\n## Tips for Effective Searches\n\n1. **Use specific keywords**: \"react testing\" is better than just \"testing\"\n2. **Try alternative terms**: If \"deploy\" doesn't work, try \"deployment\" or \"ci-cd\"\n3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`\n\n## When No Skills Are Found\n\nIf no relevant skills exist:\n\n1. Acknowledge that no existing skill was found\n2. Offer to help with the task directly using your general capabilities\n3. Suggest the user could create their own skill with `npx skills init`\n\nExample:\n\n```\nI searched for skills related to \"xyz\" but didn't find any matches.\nI can still help you with this task directly! Would you like me to proceed?\n\nIf this is something you do often, you could create your own skill:\nnpx skills init my-xyz-skill\n```\n\n<!-- File: .agents/skills/grill-me/SKILL.md (Tokens: ~114 | Category: anthropic-skill) -->\n---\nname: grill-me\ndescription: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions \"grill me\".\n---\n\nInterview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.\n\nIf a question can be answered by exploring the codebase, explore the codebase instead.\n\n<!-- File: .agents/skills/improve-codebase-architecture/REFERENCE.md (Tokens: ~600 | Category: anthropic-skill) -->\n# Reference\n\n## Dependency Categories\n\nWhen assessing a candidate for deepening, classify its dependencies:\n\n### 1. In-process\n\nPure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly.\n\n### 2. Local-substitutable\n\nDependencies that have local test stand-ins (e.g., PGLite for Postgres, in-memory filesystem). Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite.\n\n### 3. Remote but owned (Ports & Adapters)\n\nYour own services across a network boundary (microservices, internal APIs). Define a port (interface) at the module boundary. The deep module owns the logic; the transport is injected. Tests use an in-memory adapter. Production uses the real HTTP/gRPC/queue adapter.\n\nRecommendation shape: \"Define a shared interface (port), implement an HTTP adapter for production and an in-memory adapter for testing, so the logic can be tested as one deep module even though it's deployed across a network boundary.\"\n\n### 4. True external (Mock)\n\nThird-party services (Stripe, Twilio, etc.) you don't control. Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock implementation.\n\n## Testing Strategy\n\nThe core principle: **replace, don't layer.**\n\n- Old unit tests on shallow modules are waste once boundary tests exist — delete them\n- Write new tests at the deepened module's interface boundary\n- Tests assert on observable outcomes through the public interface, not internal state\n- Tests should survive internal refactors — they describe behavior, not implementation\n\n## Issue Template\n\n<issue-template>\n\n## Problem\n\nDescribe the architectural friction:\n\n- Which modules are shallow and tightly coupled\n- What integration risk exists in the seams between them\n- Why this makes the codebase harder to navigate and maintain\n\n## Proposed Interface\n\nThe chosen interface design:\n\n- Interface signature (types, methods, params)\n- Usage example showing how callers use it\n- What complexity it hides internally\n\n## Dependency Strategy\n\nWhich category applies and how dependencies are handled:\n\n- **In-process**: merged directly\n- **Local-substitutable**: tested with [specific stand-in]\n- **Ports & adapters**: port definition, production adapter, test adapter\n- **Mock**: mock boundary for external services\n\n## Testing Strategy\n\n- **New boundary tests to write**: describe the behaviors to verify at the interface\n- **Old tests to delete**: list the shallow module tests that become redundant\n- **Test environment needs**: any local stand-ins or adapters required\n\n## Implementation Recommendations\n\nDurable architectural guidance that is NOT coupled to current file paths:\n\n- What the module should own (responsibilities)\n- What it should hide (implementation details)\n- What it should expose (the interface contract)\n- How callers should migrate to the new interface\n\n</issue-template>\n\n<!-- File: .agents/skills/improve-codebase-architecture/SKILL.md (Tokens: ~866 | Category: anthropic-skill) -->\n---\nname: improve-codebase-architecture\ndescription: Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable.\n---\n\n# Improve Codebase Architecture\n\nExplore a codebase like an AI would, surface architectural friction, discover opportunities for improving testability, and propose module-deepening refactors as GitHub issue RFCs.\n\nA **deep module** (John Ousterhout, \"A Philosophy of Software Design\") has a small interface hiding a large implementation. Deep modules are more testable, more AI-navigable, and let you test at the boundary instead of inside.\n\n## Process\n\n### 1. Explore the codebase\n\nUse the Agent tool with subagent_type=Explore to navigate the codebase naturally. Do NOT follow rigid heuristics — explore organically and note where you experience friction:\n\n- Where does understanding one concept require bouncing between many small files?\n- Where are modules so shallow that the interface is nearly as complex as the implementation?\n- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called?\n- Where do tightly-coupled modules create integration risk in the seams between them?\n- Which parts of the codebase are untested, or hard to test?\n\nThe friction you encounter IS the signal.\n\n### 2. Present candidates\n\nPresent a numbered list of deepening opportunities. For each candidate, show:\n\n- **Cluster**: Which modules/concepts are involved\n- **Why they're coupled**: Shared types, call patterns, co-ownership of a concept\n- **Dependency category**: See [REFERENCE.md](REFERENCE.md) for the four categories\n- **Test impact**: What existing tests would be replaced by boundary tests\n\nDo NOT propose interfaces yet. Ask the user: \"Which of these would you like to explore?\"\n\n### 3. User picks a candidate\n\n### 4. Frame the problem space\n\nBefore spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:\n\n- The constraints any new interface would need to satisfy\n- The dependencies it would need to rely on\n- A rough illustrative code sketch to make the constraints concrete — this is not a proposal, just a way to ground the constraints\n\nShow this to the user, then immediately proceed to Step 5. The user reads and thinks about the problem while the sub-agents work in parallel.\n\n### 5. Design multiple interfaces\n\nSpawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.\n\nPrompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category, what's being hidden). This brief is independent of the user-facing explanation in Step 4. Give each agent a different design constraint:\n\n- Agent 1: \"Minimize the interface — aim for 1-3 entry points max\"\n- Agent 2: \"Maximize flexibility — support many use cases and extension\"\n- Agent 3: \"Optimize for the most common caller — make the default case trivial\"\n- Agent 4 (if applicable): \"Design around the ports & adapters pattern for cross-boundary dependencies\"\n\nEach sub-agent outputs:\n\n1. Interface signature (types, methods, params)\n2. Usage example showing how callers use it\n3. What complexity it hides internally\n4. Dependency strategy (how deps are handled — see [REFERENCE.md](REFERENCE.md))\n5. Trade-offs\n\nPresent designs sequentially, then compare them in prose.\n\nAfter comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not just a menu.\n\n### 6. User picks an interface (or accepts recommendation)\n\n### 7. Create GitHub issue\n\nCreate a refactor RFC as a GitHub issue using `gh issue create`. Use the template in [REFERENCE.md](REFERENCE.md). Do NOT ask the user to review before creating — just create it and share the URL.\n\n<!-- File: .agents/skills/migrate-styled-components-to-panda-css/SKILL.md (Tokens: ~5228 | Category: anthropic-skill) -->\n---\nname: migrate-styled-components-to-panda-css\ndescription: Step-by-step procedure for migrating components in the Sanity monorepo off styled-components to Panda CSS (zero-runtime, build-time extracted CSS). Use when converting styled() components to .css.ts files, creating .css.ts files, handling CSS specificity against @sanity/ui, styling child elements with Panda, or when asked to migrate styling to Panda CSS.\n---\n\n# Migrate styled-components to Panda CSS\n\nThe repeatable procedure for converting this monorepo's styling from `styled-components` (the\nStudio's legacy styling library) to [Panda CSS](https://panda-css.com). Adapted from the\n`sanity-io/plugins` skill for vanilla-extract, which this repo used before Panda.\n\n**The infrastructure is already wired — never touch it during a component migration.**\n`packages/sanity` builds its `.css.ts` files into `lib/bundle.css` through the `panda` option of\n`@sanity/tsdown-config` in its `tsdown.config.ts` (`panda.config.ts` next to it), exposes the\n`./bundle.css` export, registers `@pandacss/vite` in its `vitest.config.mts`, and the dev\nstudios, Storybook and the browser suite point their own `@pandacss/vite` instance at the\npackage (`packages/sanity/panda.config.ts` is the repository's only Panda project). A migration\nPR only converts component styles.\n\n## Ground rules\n\n- **Identical visual output is the goal.** This is a refactor, not a redesign — every rule and\n  specificity outcome must be preserved. Copy CSS values verbatim; do not \"improve\" them.\n- **Small PRs.** Migrate one component (or one tightly-coupled cluster) per PR, conventional-commit\n  titled, e.g. `refactor(structure): migrate Scroller to Panda CSS`.\n- **Reference implementations:**\n  `packages/sanity/src/core/changeIndicators/ElementWithChangeBar.*` (data attributes for\n  parent-state selectors), `packages/sanity/src/structure/panes/document/document-layout/DocumentLayout.*`,\n  and for the dynamic shapes `packages/sanity/src/core/components/popoverDialog/PopoverContainer.*`\n  (responsive widths through per-breakpoint variables) and\n  `packages/sanity/src/core/form/inputs/PortableText/text/TextBlock.styles.*` (per-level variants,\n  private custom properties, theme-driven colors).\n\n## Step 1: Inventory\n\nClassify each usage before touching code:\n\n```bash\nrg \"from 'styled-components'\" packages/sanity/src <target-dir>\n```\n\n| Usage                                               | Migration target                                                                                                          |\n| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| Static `styled.div` / `styled(Primitive)`           | `css()` in a colocated `.css.ts` + thin wrapper component (or plain `className`)                                          |\n| Overrides of `@sanity/ui` primitives' own styles    | `css()` with the `&&` specificity trick (see below)                                                                       |\n| Theme reads (`({theme}) => ...`, `getTheme_v2`)     | `--card-*` CSS custom properties if one exists, else a named custom property set through inline `style` + `useTheme_v2()` |\n| Prop-driven variants (`$isInvalid`, `css` branches) | one `css()` per state + conditional `clsx()` composition (the modifier doubled with `&&`), or an object of `css()` calls  |\n| `keyframes` animations                              | Panda `keyframes()` from `#styled-system/css`                                                                             |\n| Descendant selectors (`& img`, third-party classes) | class on the child directly, or a nested `'& img'` / `'& .third-party'` selector on the parent class                      |\n| `createGlobalStyle`                                 | nested selectors under a local class (never leak outside the component)                                                   |\n| `*.styled.tsx` modules                              | `.css.ts` + thin wrappers; keep the `.styled.tsx` file when call sites need it                                            |\n| Computed inline `style={{}}` objects                | static parts into `css()`, changing values through custom properties (Shape C below)                                      |\n\n## Step 2: Migrate the code\n\nCreate a `ComponentName.css.ts` next to the component. Only `.css.ts` files are Panda sources\n(`include` in `panda.config.ts`): a `css()` call anywhere else is neither extracted into\n`bundle.css` nor rewritten to class names, so its styles never render. Import from\n`#styled-system/css` (the generated runtime, mapped by the package's `imports`; it exists after\n`pnpm install`, which runs `panda codegen`). The build rewrites every statically resolvable call\ninto its class names, so the published code carries no Panda runtime — keep the style objects\nliteral (no loops, no values computed from imports) and pass the module's own constants only.\n\n**Shape A — keep the component layer.** When the styled element is used like a component, replace\nit with a `css()` rule plus a thin wrapper keeping the same name and API, so call sites don't\nchange. Type with `ComponentProps<typeof Primitive>` (or `ComponentProps<'div'>`) — never\n`forwardRef`; `ref` is a regular prop on React 19 and flows through the spread. **Merge, don't\nclobber:** a fixed `className={...}` after `{...props}` silently drops a caller's `className` —\nmerge with `clsx` (already a dependency):\n\n```tsx\nimport {Card} from '@sanity/ui'\nimport {clsx} from 'clsx'\nimport {type ComponentProps} from 'react'\n\nimport {floatingCard} from './FloatingCard.css'\n\nexport function FloatingCard(props: ComponentProps<typeof Card>) {\n  const {className, ...rest} = props\n  return <Card {...rest} className={clsx(floatingCard, className)} />\n}\n```\n\nRenaming a `Component.styled.ts` to `.tsx` (or `.ts` → `.tsx`) keeps extension-less import paths\nat call sites unchanged.\n\n**Shape B — flatten single-use wrappers.** When the styled element was an internal, single-use\n`styled.div` with no meaningful API, delete it and put the class directly on the element:\n`<div className={paragraph}>{children}</div>`.\n\n**Shape C — dynamic values through CSS variables.** First check whether `@sanity/ui` already\nexposes the value as a CSS custom property — prefer e.g. `var(--card-bg-color)` over JS theme\nreads (see `ElementWithChangeBar.css.ts`). Otherwise keep static parts in `css()` and bridge only\nchanging values with a named custom property the wrapper sets inline, reading the theme with\n`useTheme_v2()` (alias it `useTheme_v2 as useThemeV2` to satisfy the camelCase lint):\n\n```ts\n// Wrapper.css.ts\nimport {css} from '#styled-system/css'\n\n/** Set inline by `Wrapper` from the theme */\nexport const paddingVar = '--wrapper-padding'\nexport const wrapper = css({paddingBottom: `var(${paddingVar})`})\n```\n\n```tsx\n<div className={wrapper} style={{[paddingVar]: `${space[4]}px`} as CSSProperties} />\n```\n\nName the property after the component and the theme key (`--wrapper-space-4`), since it is\nglobal to the page; one class serves every instance.\n\n### Specificity against @sanity/ui: the `&&` trick\n\n`@sanity/ui` v4 still uses styled-components, which injects its styles at runtime — **after** the\nextracted stylesheet, so an equal-specificity class loses. When overriding a primitive's own\nstyles (e.g. `styled(Card)` overriding Card's background), double the class:\n\n```ts\nexport const transparentCard = css({\n  '&&': {background: 'none'},\n})\n```\n\nWhere styled-components silently won specificity battles via CSSOM insertion order, reach for\n`&&` rather than `!important`. Plain-element styles (`styled.div`) need no trick.\n\n### The cascade model (why order is not yours to rely on)\n\nThree stylesheets meet on a migrated element:\n\n| Sheet                                     | `sanity build`                                      | `sanity dev`                                                                                                |\n| ----------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| Panda (`bundle.css`)                      | one static file, imported first by the entry chunks | one stylesheet (`@repo/pandacss/panda.css`) filled by `@pandacss/vite`, imported first by the runtime entry |\n| `@sanity/ui` (styled-components, runtime) | `<style data-styled>` appended on first render      | same                                                                                                        |\n| `ui5` (`styles.css`)                      | static file                                         | static file                                                                                                 |\n\nPanda writes its rules into cascade layers; the studio ships them without any (`cascadeLayers:\nfalse` for `bundle.css`, the same pass for the dev stylesheet — see the \"Panda CSS\" section of\nAGENTS.md). CSS outside any layer beats every layered rule regardless of specificity, so layers\nwould hand the studio's hover, focus and selected states to any unlayered rule that also matches.\nUnlayered, the studio's rules cascade like the vanilla-extract output did: by specificity, then\nsource order. Do not write `@layer` in a `.css.ts`.\n\nConsequences:\n\n- **Beat `@sanity/ui`'s rules by specificity, not by order.** `@sanity/ui`'s styled-components\n  rules are injected at runtime, after the static stylesheets, and `ui5`'s component classes load\n  as static CSS too; order is theirs, so a rule of yours that must win over one of theirs has to\n  be more specific: `&&` (0,2,0) beats a plain component class (0,1,0). If the primitive rule you\n  override already has two classes or a pseudo-class chain (e.g. Card's\n  `&[data-as='button']:not(:disabled)[data-selected]`), count its specificity and exceed it —\n  `&&&`, or `&&[data-as='button']`. Read the rule in `node_modules/@sanity/ui/dist/*.js` when\n  unsure. `!important` stays banned.\n- **Panda emits one rule per declaration, not one per class.** `css({display: 'flex', opacity: 1})`\n  returns two class names, one per declaration, and `bundle.css` holds every distinct\n  `property: value` pair once, grouped by property with the values **sorted alphabetically**. So\n  when two of your classes set the same property on one element (`clsx(rootBox, muted && rootBoxMuted)`),\n  the value that sorts last wins — `opacity: 1` beats `opacity: 0.8` whatever the declaration\n  order. A modifier that must win doubles its class: `rootBoxMuted = css({'&&': {opacity: 0.8}})`.\n  The same holds for a `styled(LocalStyledThing)` chain, which styled-components ordered for you.\n- **Shorthands come before longhands.** `padding: 0` in one class and `paddingLeft: '4px'` in\n  another on the same element always resolve to the longhand, regardless of order.\n- **Parent-state selectors key on data attributes, never on an empty class.** A Panda class name\n  is a list of atomic names, so `` `${parent}:hover &` `` cannot be built from it, and a class\n  that exists only to be targeted is not an option either. Have the parent render its identity\n  and state as data attributes (`data-change-bar-wrapper=\"\"`, `data-focused={hasFocus ? '' :\n  undefined}`) and write `'[data-change-bar-wrapper][data-focused] &': {...}` or\n  `'[data-change-bar-wrapper]:hover &': {...}` on the child — see `ElementWithChangeBar.css.ts`.\n  Read a state attribute together with the identity attribute: generic names like `data-focused`\n  and `data-disabled` are rendered by other components too, and `:not([data-changed])` on its own\n  matches every ancestor. Attributes a library component already renders (`'[data-ui=\"Card\"] &'`)\n  work the same way.\n- **`@media` blocks are grouped.** Panda emits a file's media queries after the base rules,\n  which is what styled-components effectively did too. Media conditions cannot read custom\n  properties, so theme breakpoints are written as literals from the default theme (table\n  below) with a comment naming the theme key they mirror.\n\n#### What each `@sanity/ui` primitive sets on itself\n\nUse `&&` when the migrated rule overrides one of these; plain `css()` otherwise.\n\n| Primitive                                                                                         | Own declarations (via its props or defaults)                                                                                                                                                                                                                                                                  |\n| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Box`                                                                                             | `display` (block, or the `display` prop), `box-sizing`, `flex`/`flex-shrink`/`flex-grow` props, `margin*`, `padding*`, `height`, `overflow`, `column`/`row` grid props, `list-style: none` for `as=\"ul\"/\"ol\"`                                                                                                 |\n| `Flex`                                                                                            | Box + `display: flex`, `flex-direction`, `flex-wrap`, `align-items`, `justify-content`, `gap`                                                                                                                                                                                                                 |\n| `Stack`                                                                                           | Box + `display: grid`, `grid-template-columns`, `grid-auto-rows`, `gap`                                                                                                                                                                                                                                       |\n| `Grid`, `Inline`                                                                                  | Box + `display`, `gap`, `grid-*`, `align-items`                                                                                                                                                                                                                                                               |\n| `Card`                                                                                            | Box + `background-color`, `color`, `color-scheme`, every `--card-*` variable, `border`/`border-color` (`border` prop), `border-radius` (`radius`), `box-shadow` (`shadow`, focus ring), button/link hover/pressed/selected/disabled variants, `outline`, `font`, `text-align`, `appearance`, `width` (button) |\n| `Container`                                                                                       | Box + `max-width`, `margin: 0 auto`                                                                                                                                                                                                                                                                           |\n| `Text`, `Label`, `Heading`, `Code`                                                                | `font-family`, `font-size`, `font-weight`, `line-height`, `letter-spacing`, `color` (`--card-fg-color` / muted / accent), `text-align`; descendant rules for `& code`, `& a` (color `--card-link-color`, focus ring), `& strong`, `& svg`; the inner `span` handles `text-overflow`                           |\n| `Button`                                                                                          | Card-like colors per `mode`/`tone`, `padding`, `border-radius`, `font`, `box-shadow`, `cursor`, `&:disabled`, `&:not(:disabled):hover`, `& [data-ui='Text']`                                                                                                                                                  |\n| `TextInput`, `TextArea`, `Select`                                                                 | wrapper `position: relative`; the inner `input`/`textarea`/`select` gets font, color, padding, border via presentation `span`; `--input-*` variables                                                                                                                                                          |\n| `Skeleton`, `TextSkeleton`, `LabelSkeleton`                                                       | `background-color`, `border-radius`, `animation`, `width`, `height`                                                                                                                                                                                                                                           |\n| `Popover`, `Tooltip`, `Layer`, `Dialog`                                                           | `position`, `z-index`, `pointer-events`, `max-width`; Popover/Tooltip content Card colors                                                                                                                                                                                                                     |\n| `Badge`, `KBD`, `Spinner`, `Switch`, `Checkbox`, `Radio`, `Avatar`, `Tab`, `TreeItem`, `MenuItem` | fully self-styled — treat every declaration as an override (`&&`)                                                                                                                                                                                                                                             |\n\n#### Theme reads → CSS variables\n\n`getTheme_v2(theme)` inside a styled template and `useTheme_v2()` inside the wrapper read the\nsame context, including the nearest `Card`'s `tone` and `scheme`. Prefer a variable `@sanity/ui`\nalready publishes on the nearest Card (they follow tone and scheme for free):\n\n| Theme read                                                        | CSS variable                                                                           |\n| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `color.bg`, `color.fg`, `color.border`, `color.icon`              | `--card-bg-color`, `--card-fg-color`, `--card-border-color`, `--card-icon-color`       |\n| `color.muted.bg`, `color.muted.fg`, `color.accent.fg`             | `--card-muted-bg-color`, `--card-muted-fg-color`, `--card-accent-fg-color`             |\n| `color.focusRing`, `color.backdrop`                               | `--card-focus-ring-color`, `--card-backdrop-color`                                     |\n| `color.badge.<tone>.{bg,fg,dot,icon}`                             | `--card-badge-<tone>-{bg,fg,dot,icon}-color`                                           |\n| `color.avatar.<hue>.{bg,fg}`                                      | `--card-avatar-<hue>-{bg,fg}-color`                                                    |\n| `color.link.fg`, `color.code.{bg,fg}`, `color.kbd.{bg,fg,border}` | `--card-link-fg-color`, `--card-code-{bg,fg}-color`, `--card-kbd-{bg,fg,border}-color` |\n| `color.shadow.{outline,umbra,penumbra,ambient}`                   | `--card-shadow-{outline,umbra,penumbra,ambient}-color`                                 |\n| `color.skeleton.{from,to}`                                        | `--card-skeleton-color-{from,to}`                                                      |\n| `color.hairline.{soft,hard}`                                      | `--card-hairline-{soft,hard}-color`                                                    |\n| card focus ring box-shadow (`focusRingStyle(...)`)                | `--card-focus-ring-box-shadow` (set by Card/Button on `:focus-visible`)                |\n\nEverything else — `color.selectable.*`, `color.solid.*`, `color.input.*`, `color.button.*`,\n`color.syntax.*`, `color._dark`, `space[n]`, `radius[n]`, `font.*`, `container[n]`,\n`avatar.sizes[n]`, `input.*`, `shadow[n]` — goes through Shape C: a named custom property in the\n`.css.ts`, `useTheme_v2()` + an inline `style` object in the wrapper. Keep the same theme key\nnames in the property names (`--wrapper-space-4`, `--wrapper-radius-2`) so the intent survives. Do\nnot hardcode scale values: a studio may build its own theme with different `space`, `radius`, or\n`font` scales.\n\nOnly `@media` conditions get literals, mirrored from the default theme:\n\n| Key         | Default values                         |\n| ----------- | -------------------------------------- |\n| `media`     | `[360, 600, 900, 1200, 1800, 2400]` px |\n| `container` | `[320, 640, 960, 1280, 1600, 1920]` px |\n\nWrite them as `'@media screen and (min-width: 600px)': {...}` with a comment such as\n`// media[1]`.\n\n### Wrapper contract\n\nA wrapper that replaces an exported styled component must keep call sites working without edits:\n\n- Same export name, same prop names — including transient `$props`, which become regular props\n  the wrapper consumes (do not rename them; callers in other directories still pass them).\n- `className` and `style` **merge** (`clsx`, spread) — never clobber.\n- `ref` flows through props (React 19). No `forwardRef`, no `displayName`.\n- `.attrs({...})` values become JSX props on the primitive inside the wrapper; `forwardedAs`\n  becomes `as`.\n- A prop-driven `css` branch becomes one `css()` per branch plus `clsx(cond && cls)`, the branch\n  doubled with `&&` when it overrides the base class (see the cascade model); a prop-driven\n  **value** becomes a custom property set inline. Prefer an object of `css()` calls for a closed\n  set of values (`root.narrow`, `root.wide`).\n- Shared `css` mixins (an `export const focusRingStyles = css` tagged template) become a\n  `css.raw({...})` object in a `.css.ts`; importers compose it with `css(mixin, {...})`, which\n  merges the objects before emitting the class names.\n- A test that asserted a computed style of a migrated component (`toHaveStyle`) cannot pass in\n  jsdom anymore (no stylesheet is loaded there). Assert the state through a `data-*` attribute the\n  wrapper sets instead; add the attribute if none exists.\n\n### Child and descendant selectors\n\nA nested key with `&` is a selector where `&` stands for the class's own element, in any\nposition: `'& img'`, `'& > span'`, `'& .third-party-class'`, `'[data-ui=\"Card\"] &'`. In order of\npreference:\n\n1. Put a class directly on the child if you render it yourself.\n2. `'[data-parent][data-state] &': {...}` on the child class for parent-state-dependent rules,\n   keyed on data attributes the parent renders (see the cascade model).\n3. `'& img': {...}` on the parent class when you don't control the child (library-rendered DOM) —\n   always scoped under a local class.\n\n### Strict property values\n\n`panda.config.ts` sets `strictPropertyValues`, which restricts enumerable properties (`display`,\n`position`, `scrollbarWidth`, …) to their CSS keywords and catches typos. A `var()` on such a\nproperty needs Panda's escape hatch, square brackets around the value:\n`scrollbarWidth: '[var(--scrollbar-width)]'` emits `scrollbar-width: var(--scrollbar-width)`.\n\n## Step 3: Verify\n\n```bash\npnpm lint:fix                                  # oxfmt + oxlint (includes type checking)\npnpm build && pnpm vitest run --project=sanity <affected test paths>\n```\n\nCheck `packages/sanity/lib/bundle.css` after the build: every migrated rule must appear there.\nThe build warns about a `css()` call it could not resolve statically; such a call keeps Panda's\nruntime in the bundle and must be rewritten with literal values.\n\nVerify **visual fidelity** in the dev studio (`pnpm dev`, see AGENTS.md for cloud-agent auth):\nexercise the migrated component and compare against the pre-migration rendering — theme tones,\nspacing, hover/focus states, stacking. Jsdom tests must not assert on Panda class names or\ncomputed styles (no stylesheet is loaded there — see AGENTS.md); assert on `data-testid`. For\nhigh-traffic components, add a Chromatic story per the `sanity-visual-regression` skill so the\nmigration is sentinel-protected.\n\n## End state\n\nThe monorepo's own code no longer imports `styled-components`; `eslint/no-restricted-imports` in\n`.oxlintrc.json` bans the module everywhere (the global rule and every override that repeats it).\nThe dependency itself stays declared because `@sanity/ui@4` requires it as a peer and still\ninjects its own styles at runtime — which is exactly why the cascade rules above still matter for\nevery new `.css.ts`. Use this skill for new components and for plugin or example code that still\ncarries styled-components.\n\n## Checklist\n\n- [ ] `styled-components` import removed from the migrated component; emptied `.styled.tsx`\n      modules deleted\n- [ ] `.css.ts` colocated; CSS values copied verbatim; component layer preserved where call sites\n      need it; `className` merged, not clobbered\n- [ ] `&&` used where the original relied on styled-components injection order to beat @sanity/ui,\n      and on modifier classes that override a base class on the same element\n- [ ] No build config, `package.json`, or exports changes (infra is already wired)\n- [ ] `pnpm lint:fix`, `pnpm build`, and affected tests pass; rules present in `lib/bundle.css`\n- [ ] Visual fidelity verified against the pre-migration rendering\n\n<!-- File: .agents/skills/performance-optimization/SKILL.md (Tokens: ~2887 | Category: anthropic-skill) -->\n---\nname: performance-optimization\ndescription: Optimizes application performance. Use when performance requirements exist, when you suspect performance regressions, or when Core Web Vitals or load times need improvement. Use when profiling reveals bottlenecks that need fixing.\n---\n\n# Performance Optimization\n\n## Overview\n\nMeasure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.\n\n## When to Use\n\n- Performance requirements exist in the spec (load time budgets, response time SLAs)\n- Users or monitoring report slow behavior\n- Core Web Vitals scores are below thresholds\n- You suspect a change introduced a regression\n- Building features that handle large datasets or high traffic\n\n**When NOT to use:** Don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains.\n\n## Core Web Vitals Targets\n\n| Metric                              | Good    | Needs Improvement | Poor    |\n| ----------------------------------- | ------- | ----------------- | ------- |\n| **LCP** (Largest Contentful Paint)  | ≤ 2.5s  | ≤ 4.0s            | > 4.0s  |\n| **INP** (Interaction to Next Paint) | ≤ 200ms | ≤ 500ms           | > 500ms |\n| **CLS** (Cumulative Layout Shift)   | ≤ 0.1   | ≤ 0.25            | > 0.25  |\n\n## The Optimization Workflow\n\n```\n1. MEASURE  → Establish baseline with real data\n2. IDENTIFY → Find the actual bottleneck (not assumed)\n3. FIX      → Address the specific bottleneck\n4. VERIFY   → Measure again, confirm improvement\n5. GUARD    → Add monitoring or tests to prevent regression\n```\n\n### Step 1: Measure\n\nTwo complementary approaches — use both:\n\n- **Synthetic (Lighthouse, DevTools Performance tab):** Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.\n- **RUM (web-vitals library, CrUX):** Real user data in real conditions. Required to validate that a fix actually improved user experience.\n\n**Frontend:**\n\n```bash\n# Synthetic: Lighthouse in Chrome DevTools (or CI)\n# Chrome DevTools → Performance tab → Record\n# Chrome DevTools MCP → Performance trace\n\n# RUM: Web Vitals library in code\nimport { onLCP, onINP, onCLS } from 'web-vitals';\n\nonLCP(console.log);\nonINP(console.log);\nonCLS(console.log);\n```\n\n**Backend:**\n\n```bash\n# Response time logging\n# Application Performance Monitoring (APM)\n# Database query logging with timing\n\n# Simple timing\nconsole.time('db-query');\nconst result = await db.query(...);\nconsole.timeEnd('db-query');\n```\n\n### Where to Start Measuring\n\nUse the symptom to decide what to measure first:\n\n```\nWhat is slow?\n├── First page load\n│   ├── Large bundle? --> Measure bundle size, check code splitting\n│   ├── Slow server response? --> Measure TTFB in DevTools Network waterfall\n│   │   ├── DNS long? --> Add dns-prefetch / preconnect for known origins\n│   │   ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive\n│   │   └── Waiting (server) long? --> Profile backend, check queries and caching\n│   └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking\n├── Interaction feels sluggish\n│   ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)\n│   ├── Form input lag? --> Check re-renders, controlled component overhead\n│   └── Animation jank? --> Check layout thrashing, forced reflows\n├── Page after navigation\n│   ├── Data loading? --> Measure API response times, check for waterfalls\n│   └── Client rendering? --> Profile component render time, check for N+1 fetches\n└── Backend / API\n    ├── Single endpoint slow? --> Profile database queries, check indexes\n    ├── All endpoints slow? --> Check connection pool, memory, CPU\n    └── Intermittent slowness? --> Check for lock contention, GC pauses, external deps\n```\n\n### Step 2: Identify the Bottleneck\n\nCommon bottlenecks by category:\n\n**Frontend:**\n\n| Symptom           | Likely Cause                                                 | Investigation                         |\n| ----------------- | ------------------------------------------------------------ | ------------------------------------- |\n| Slow LCP          | Large images, render-blocking resources, slow server         | Check network waterfall, image sizes  |\n| High CLS          | Images without dimensions, late-loading content, font shifts | Check layout shift attribution        |\n| Poor INP          | Heavy JavaScript on main thread, large DOM updates           | Check long tasks in Performance trace |\n| Slow initial load | Large bundle, many network requests                          | Check bundle size, code splitting     |\n\n**Backend:**\n\n| Symptom            | Likely Cause                                         | Investigation                    |\n| ------------------ | ---------------------------------------------------- | -------------------------------- |\n| Slow API responses | N+1 queries, missing indexes, unoptimized queries    | Check database query log         |\n| Memory growth      | Leaked references, unbounded caches, large payloads  | Heap snapshot analysis           |\n| CPU spikes         | Synchronous heavy computation, regex backtracking    | CPU profiling                    |\n| High latency       | Missing caching, redundant computation, network hops | Trace requests through the stack |\n\n### Step 3: Fix Common Anti-Patterns\n\n#### N+1 Queries (Backend)\n\n```typescript\n// BAD: N+1 — one query per task for the owner\nconst tasks = await db.tasks.findMany()\nfor (const task of tasks) {\n  task.owner = await db.users.findUnique({where: {id: task.ownerId}})\n}\n\n// GOOD: Single query with join/include\nconst tasks = await db.tasks.findMany({\n  include: {owner: true},\n})\n```\n\n#### Unbounded Data Fetching\n\n```typescript\n// BAD: Fetching all records\nconst allTasks = await db.tasks.findMany()\n\n// GOOD: Paginated with limits\nconst tasks = await db.tasks.findMany({\n  take: 20,\n  skip: (page - 1) * 20,\n  orderBy: {createdAt: 'desc'},\n})\n```\n\n#### Missing Image Optimization (Frontend)\n\n```html\n<!-- BAD: No dimensions, no format optimization -->\n<img src=\"/hero.jpg\" />\n\n<!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority -->\n<!--\n  Two techniques combined:\n  - Art direction (media): different crop/composition per breakpoint\n  - Resolution switching (srcset + sizes): right file size per screen density\n-->\n<picture>\n  <!-- Mobile: portrait crop (8:10) -->\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/avif\"\n  />\n  <source\n    media=\"(max-width: 767px)\"\n    srcset=\"/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w\"\n    sizes=\"100vw\"\n    width=\"800\"\n    height=\"1000\"\n    type=\"image/webp\"\n  />\n  <!-- Desktop: landscape crop (2:1) -->\n  <source\n    srcset=\"/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/avif\"\n  />\n  <source\n    srcset=\"/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w\"\n    sizes=\"(max-width: 1200px) 100vw, 1200px\"\n    width=\"1200\"\n    height=\"600\"\n    type=\"image/webp\"\n  />\n  <img\n    src=\"/hero-desktop.jpg\"\n    width=\"1200\"\n    height=\"600\"\n    fetchpriority=\"high\"\n    alt=\"Hero image description\"\n  />\n</picture>\n\n<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->\n<img\n  src=\"/content.webp\"\n  width=\"800\"\n  height=\"400\"\n  loading=\"lazy\"\n  decoding=\"async\"\n  alt=\"Content image description\"\n/>\n```\n\n#### Unnecessary Re-renders (React)\n\n```tsx\n// BAD: Creates new object on every render, causing children to re-render\nfunction TaskList() {\n  return <TaskFilters options={{sortBy: 'date', order: 'desc'}} />\n}\n\n// GOOD: Stable reference\nconst DEFAULT_OPTIONS = {sortBy: 'date', order: 'desc'} as const\nfunction TaskList() {\n  return <TaskFilters options={DEFAULT_OPTIONS} />\n}\n\n// Use React.memo for expensive components\nconst TaskItem = React.memo(function TaskItem({task}: Props) {\n  return <div>{/* expensive render */}</div>\n})\n\n// Use useMemo for expensive computations\nfunction TaskStats({tasks}: Props) {\n  const stats = useMemo(() => calculateStats(tasks), [tasks])\n  return (\n    <div>\n      {stats.completed} / {stats.total}\n    </div>\n  )\n}\n```\n\n#### Large Bundle Size\n\n```typescript\n// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,\n// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.\n// Profile before changing import styles — the real gains come from splitting and lazy loading.\n\n// GOOD: Dynamic import for heavy, rarely-used features\nconst ChartLibrary = lazy(() => import('./ChartLibrary'));\n\n// GOOD: Route-level code splitting wrapped in Suspense\nconst SettingsPage = lazy(() => import('./pages/Settings'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spinner />}>\n      <SettingsPage />\n    </Suspense>\n  );\n}\n```\n\n#### Missing Caching (Backend)\n\n```typescript\n// Cache frequently-read, rarely-changed data\nconst CACHE_TTL = 5 * 60 * 1000 // 5 minutes\nlet cachedConfig: AppConfig | null = null\nlet cacheExpiry = 0\n\nasync function getAppConfig(): Promise<AppConfig> {\n  if (cachedConfig && Date.now() < cacheExpiry) {\n    return cachedConfig\n  }\n  cachedConfig = await db.config.findFirst()\n  cacheExpiry = Date.now() + CACHE_TTL\n  return cachedConfig\n}\n\n// HTTP caching headers for static assets\napp.use(\n  '/static',\n  express.static('public', {\n    maxAge: '1y', // Cache for 1 year\n    immutable: true, // Never revalidate (use content hashing in filenames)\n  }),\n)\n\n// Cache-Control for API responses\nres.set('Cache-Control', 'public, max-age=300') // 5 minutes\n```\n\n## Performance Budget\n\nSet budgets and enforce them:\n\n```\nJavaScript bundle: < 200KB gzipped (initial load)\nCSS: < 50KB gzipped\nImages: < 200KB per image (above the fold)\nFonts: < 100KB total\nAPI response time: < 200ms (p95)\nTime to Interactive: < 3.5s on 4G\nLighthouse Performance score: ≥ 90\n```\n\n**Enforce in CI:**\n\n```bash\n# Bundle size check\nnpx bundlesize --config bundlesize.config.json\n\n# Lighthouse CI\nnpx lhci autorun\n```\n\n## See Also\n\nFor detailed performance checklists, optimization commands, and anti-pattern reference, see `references/performance-checklist.md`.\n\n## Common Rationalizations\n\n| Rationalization                     | Reality                                                                                |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| \"We'll optimize later\"              | Performance debt compounds. Fix obvious anti-patterns now, defer micro-optimizations.  |\n| \"It's fast on my machine\"           | Your machine isn't the user's. Profile on representative hardware and networks.        |\n| \"This optimization is obvious\"      | If you didn't measure, you don't know. Profile first.                                  |\n| \"Users won't notice 100ms\"          | Research shows 100ms delays impact conversion rates. Users notice more than you think. |\n| \"The framework handles performance\" | Frameworks prevent some issues but can't fix N+1 queries or oversized bundles.         |\n\n## Red Flags\n\n- Optimization without profiling data to justify it\n- N+1 query patterns in data fetching\n- List endpoints without pagination\n- Images without dimensions, lazy loading, or responsive sizes\n- Bundle size growing without review\n- No performance monitoring in production\n- `React.memo` and `useMemo` everywhere (overusing is as bad as underusing)\n\n## Verification\n\nAfter any performance-related change:\n\n- [ ] Before and after measurements exist (specific numbers)\n- [ ] The specific bottleneck is identified and addressed\n- [ ] Core Web Vitals are within \"Good\" thresholds\n- [ ] Bundle size hasn't increased significantly\n- [ ] No N+1 queries in new data fetching code\n- [ ] Performance budget passes in CI (if configured)\n- [ ] Existing tests still pass (optimization didn't break behavior)\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/authentication-flows.md (Tokens: ~2555 | Category: anthropic-skill) -->\n# Complex Authentication Flow Patterns\n\n## Table of Contents\n\n1. [Email Verification Flows](#email-verification-flows)\n2. [Password Reset](#password-reset)\n3. [Session Timeout](#session-timeout)\n4. [Remember Me Persistence](#remember-me-persistence)\n5. [Logout Patterns](#logout-patterns)\n6. [Tips](#tips)\n7. [Related](#related)\n\n> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).\n\n---\n\n## Email Verification Flows\n\n### Capturing Verification Tokens\n\nIntercept API responses to capture verification tokens for testing:\n\n```typescript\ntest('completes registration with email verification', async ({page}) => {\n  let capturedToken = ''\n\n  await page.route('**/api/auth/register', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    capturedToken = body.verificationToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Name').fill('New User')\n  await page.getByLabel('Email').fill('newuser@test.com')\n  await page.getByLabel('Password', {exact: true}).fill('SecurePass!')\n  await page.getByLabel('Confirm password').fill('SecurePass!')\n  await page.getByRole('button', {name: 'Create account'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  expect(capturedToken).toBeTruthy()\n  await page.goto(`/verify?token=${capturedToken}`)\n\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n### Fully Mocked Verification\n\n```typescript\ntest('verifies email with mocked endpoints', async ({page}) => {\n  const mockToken = 'test-verification-abc123'\n\n  await page.route('**/api/auth/register', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Verification sent', verificationToken: mockToken}),\n    })\n  })\n\n  await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({verified: true}),\n    })\n  })\n\n  await page.goto('/register')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByLabel('Password', {exact: true}).fill('Password123!')\n  await page.getByRole('button', {name: 'Sign up'}).click()\n\n  await expect(page.getByText('Check your inbox')).toBeVisible()\n\n  await page.goto(`/verify?token=${mockToken}`)\n  await expect(page.getByText('Email confirmed')).toBeVisible()\n})\n```\n\n---\n\n## Password Reset\n\n### Complete Reset Flow\n\n```typescript\ntest('resets password through email link', async ({page}) => {\n  let resetToken = ''\n\n  await page.route('**/api/auth/forgot-password', async (route) => {\n    const response = await route.fetch()\n    const body = await response.json()\n    resetToken = body.resetToken\n    await route.fulfill({response})\n  })\n\n  await page.goto('/forgot-password')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByRole('button', {name: 'Send link'}).click()\n\n  await expect(page.getByText('Reset email sent')).toBeVisible()\n\n  expect(resetToken).toBeTruthy()\n  await page.goto(`/reset-password?token=${resetToken}`)\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPassword456!')\n  await page.getByLabel('Confirm password').fill('NewPassword456!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText('Password updated')).toBeVisible()\n})\n```\n\n### Expired Token Handling\n\n```typescript\ntest('shows error for expired reset token', async ({page}) => {\n  await page.goto('/reset-password?token=expired-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('NewPass!')\n  await page.getByLabel('Confirm password').fill('NewPass!')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByRole('alert')).toContainText(/expired|invalid/i)\n})\n```\n\n### Password Strength Validation\n\n```typescript\ntest('enforces password requirements on reset', async ({page}) => {\n  await page.goto('/reset-password?token=valid-token')\n\n  await page.getByLabel('New password', {exact: true}).fill('weak')\n  await page.getByLabel('Confirm password').fill('weak')\n  await page.getByRole('button', {name: 'Update password'}).click()\n\n  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()\n})\n```\n\n---\n\n## Session Timeout\n\n### Detecting Expired Sessions\n\n```typescript\ntest('redirects to signin after session expires', async ({page, context}) => {\n  await page.goto('/signin')\n  await page.getByLabel('Email').fill('user@test.com')\n  await page.getByLabel('Password').fill('Password!')\n  await page.getByRole('button', {name: 'Sign in'}).click()\n  await expect(page).toHaveURL('/home')\n\n  const cookies = await context.cookies()\n  const sessionCookie = cookies.find((c) => c.name.includes('session'))\n\n  if (sessionCookie) {\n    await context.clearCookies({name: sessionCookie.name})\n  }\n\n  await page.goto('/profile')\n  await expect(page).toHaveURL(/\\/signin/)\n  await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible()\n})\n```\n\n### Session Extension Warning\n\n```typescript\ntest('shows warning before session expires', async ({page}) => {\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByText(/session.*expir/i)).toBeVisible({timeout: 10000})\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible()\n})\n```\n\n### Session Extension Action\n\n```typescript\ntest('extends session when user clicks extend', async ({page}) => {\n  let sessionExtended = false\n\n  await page.route('**/api/auth/session', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 60}),\n    })\n  })\n\n  await page.route('**/api/auth/refresh', async (route) => {\n    sessionExtended = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({valid: true, expiresIn: 3600}),\n    })\n  })\n\n  await page.goto('/home')\n\n  await expect(page.getByRole('button', {name: /extend|stay signed in/i})).toBeVisible({\n    timeout: 10000,\n  })\n  await page.getByRole('button', {name: /extend|stay signed in/i}).click()\n\n  expect(sessionExtended).toBe(true)\n  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()\n})\n```\n\n---\n\n## Remember Me Persistence\n\n### Persistent Session\n\n```typescript\ntest('persists session with remember me enabled', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  await page1.getByLabel('Keep me signed in').check()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  const state = await ctx1.storageState()\n  await ctx1.close()\n\n  const ctx2 = await browser.newContext({storageState: state})\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n  await expect(page2).toHaveURL('/home')\n  await expect(page2.getByText('Welcome')).toBeVisible()\n\n  await ctx2.close()\n})\n```\n\n### Session-Only Login\n\n```typescript\ntest('session-only login does not persist across browser restarts', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const page1 = await ctx1.newPage()\n\n  await page1.goto('/signin')\n  await page1.getByLabel('Email').fill('user@test.com')\n  await page1.getByLabel('Password').fill('Password!')\n  // Leave \"Remember me\" unchecked\n  await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked()\n  await page1.getByRole('button', {name: 'Sign in'}).click()\n\n  await expect(page1).toHaveURL('/home')\n\n  // Only keep persistent cookies (filter out session cookies)\n  const cookies = await ctx1.cookies()\n  await ctx1.close()\n\n  const persistentCookies = cookies.filter((c) => c.expires > 0)\n  const ctx2 = await browser.newContext()\n  await ctx2.addCookies(persistentCookies)\n  const page2 = await ctx2.newPage()\n\n  await page2.goto('/home')\n\n  // Should redirect to login since session was not persisted\n  await expect(page2).toHaveURL(/\\/signin/)\n\n  await ctx2.close()\n})\n```\n\n---\n\n## Logout Patterns\n\n### Standard Logout with Session Cleanup\n\n```typescript\ntest.use({storageState: '.auth/user.json'})\n\ntest('logs out and clears session', async ({page, context}) => {\n  await page.goto('/home')\n\n  await page.getByRole('button', {name: /account|menu/i}).click()\n  await page.getByRole('menuitem', {name: 'Sign out'}).click()\n\n  await expect(page).toHaveURL('/signin')\n\n  const cookies = await context.cookies()\n  const sessionCookies = cookies.filter(\n    (c) => c.name.includes('session') || c.name.includes('token'),\n  )\n  expect(sessionCookies).toHaveLength(0)\n\n  await page.goto('/home')\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n### Logout from All Devices\n\n```typescript\ntest('logs out from all devices', async ({page}) => {\n  let logoutAllCalled = false\n\n  await page.route('**/api/auth/logout-all', async (route) => {\n    logoutAllCalled = true\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({message: 'Logged out everywhere'}),\n    })\n  })\n\n  await page.goto('/settings/security')\n\n  await page.getByRole('button', {name: 'Sign out everywhere'}).click()\n  await page.getByRole('dialog').getByRole('button', {name: 'Confirm'}).click()\n\n  expect(logoutAllCalled).toBe(true)\n  await expect(page).toHaveURL(/\\/signin/)\n})\n```\n\n---\n\n## Tips\n\n1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests\n2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens\n3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared\n4. **Test the full flow end-to-end** — Password reset should verify login with new password works\n\n---\n\n## Related\n\n- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures\n- [third-party.md](./third-party.md) — Mocking external auth providers\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/authentication.md (Tokens: ~6660 | Category: anthropic-skill) -->\n# Authentication Testing\n\n## Table of Contents\n\n1. [Quick Reference](#quick-reference)\n2. [Patterns](#patterns)\n3. [Decision Guide](#decision-guide)\n4. [Anti-Patterns](#anti-patterns)\n5. [Troubleshooting](#troubleshooting)\n6. [Related](#related)\n\n> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.\n\n## Quick Reference\n\n```typescript\n// Storage state reuse — the #1 pattern for fast auth\nawait page.goto('/login')\nawait page.getByLabel('Username').fill('testuser@example.com')\nawait page.getByLabel('Password').fill('secretPass123')\nawait page.getByRole('button', {name: 'Log in'}).click()\nawait page.context().storageState({path: '.auth/session.json'})\n\n// Reuse in config — every test starts authenticated\n{\n  use: {\n    storageState: '.auth/session.json'\n  }\n}\n\n// API login — skip the UI entirely\nconst context = await browser.newContext()\nconst response = await context.request.post('/api/auth/login', {\n  data: {email: 'testuser@example.com', password: 'secretPass123'},\n})\nawait context.storageState({path: '.auth/session.json'})\n```\n\n## Patterns\n\n### Storage State Reuse\n\n**Use when**: You need authenticated tests and want to avoid logging in before every test.\n**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.\n\n`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.\n\n```typescript\n// scripts/generate-auth.ts — run once to generate the state file\nimport {chromium} from '@playwright/test'\n\nasync function generateAuthState() {\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto('http://localhost:4000/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\ngenerateAuthState()\n```\n\n```typescript\n// playwright.config.ts — load saved state for all tests\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\n```typescript\n// tests/home.spec.ts — test starts already logged in\nimport {test, expect} from '@playwright/test'\n\ntest('authenticated user sees home page', async ({page}) => {\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n### Global Setup Authentication\n\n**Use when**: You want to authenticate once before the entire test suite runs.\n**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.\n\n```typescript\n// global-setup.ts\nimport {chromium, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n  const browser = await chromium.launch()\n  const context = await browser.newContext()\n  const page = await context.newPage()\n\n  await page.goto(`${baseURL}/login`)\n  await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await page.waitForURL('**/home')\n\n  await context.storageState({path: '.auth/session.json'})\n  await browser.close()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  use: {\n    baseURL: 'http://localhost:4000',\n    storageState: '.auth/session.json',\n  },\n})\n```\n\nAdd `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.\n\n### Per-Worker Authentication\n\n**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.\n**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.\n\n> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.\n\n```typescript\n// fixtures/auth.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedContext: BrowserContext\n}\n\nexport const test = base.extend<{}, AuthFixtures>({\n  authenticatedContext: [\n    async ({browser}, use) => {\n      const context = await browser.newContext()\n      const page = await context.newPage()\n\n      await page.goto('/login')\n      await page.getByLabel('Username').fill(`worker-${test.info().parallelIndex}@example.com`)\n      await page.getByLabel('Password').fill('secretPass123')\n      await page.getByRole('button', {name: 'Log in'}).click()\n      await page.waitForURL('/home')\n      await page.close()\n\n      await use(context)\n      await context.close()\n    },\n    {scope: 'worker'},\n  ],\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/settings.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('update display name', async ({authenticatedContext}) => {\n  const page = await authenticatedContext.newPage()\n  await page.goto('/settings/profile')\n  await page.getByLabel('Display name').fill('Updated Name')\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expect(page.getByText('Profile saved')).toBeVisible()\n})\n```\n\n### Multiple Roles\n\n**Use when**: Your app has role-based access control and you need to test different permission levels.\n**Avoid when**: Your app has a single user role.\n\n```typescript\n// global-setup.ts — authenticate all roles\nimport {chromium, type FullConfig} from '@playwright/test'\n\nconst accounts = [\n  {\n    role: 'admin',\n    email: 'admin@example.com',\n    password: process.env.ADMIN_PASSWORD!,\n  },\n  {\n    role: 'member',\n    email: 'member@example.com',\n    password: process.env.MEMBER_PASSWORD!,\n  },\n  {\n    role: 'guest',\n    email: 'guest@example.com',\n    password: process.env.GUEST_PASSWORD!,\n  },\n]\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  for (const {role, email, password} of accounts) {\n    const browser = await chromium.launch()\n    const context = await browser.newContext()\n    const page = await context.newPage()\n\n    await page.goto(`${baseURL}/login`)\n    await page.getByLabel('Username').fill(email)\n    await page.getByLabel('Password').fill(password)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('**/home')\n\n    await context.storageState({path: `.auth/${role}.json`})\n    await browser.close()\n  }\n}\n\nexport default globalSetup\n```\n\n```typescript\n// playwright.config.ts — one project per role\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  globalSetup: require.resolve('./global-setup'),\n  projects: [\n    {\n      name: 'admin',\n      use: {storageState: '.auth/admin.json'},\n      testMatch: '**/*.admin.spec.ts',\n    },\n    {\n      name: 'member',\n      use: {storageState: '.auth/member.json'},\n      testMatch: '**/*.member.spec.ts',\n    },\n    {\n      name: 'guest',\n      use: {storageState: '.auth/guest.json'},\n      testMatch: '**/*.guest.spec.ts',\n    },\n    {\n      name: 'anonymous',\n      use: {storageState: {cookies: [], origins: []}},\n      testMatch: '**/*.anon.spec.ts',\n    },\n  ],\n})\n```\n\n```typescript\n// tests/admin-panel.admin.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('admin can access user management', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByRole('heading', {name: 'User Management'})).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Remove user'})).toBeEnabled()\n})\n```\n\n```typescript\n// tests/admin-panel.guest.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest('guest cannot access admin panel', async ({page}) => {\n  await page.goto('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n})\n```\n\n**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth.ts — role-based fixture\nimport {test as base, type Page} from '@playwright/test'\n\ntype RoleFixtures = {\n  loginAs: (role: 'admin' | 'member' | 'guest') => Promise<Page>\n}\n\nexport const test = base.extend<RoleFixtures>({\n  loginAs: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (role) => {\n      const statePath = `.auth/${role}.json`\n      if (!fs.existsSync(statePath)) {\n        throw new Error(`Auth state for role \"${role}\" not found at ${statePath}`)\n      }\n      const context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/role-comparison.spec.ts\nimport {test, expect} from '../fixtures/auth'\n\ntest('admin sees remove button, guest does not', async ({loginAs}) => {\n  const adminPage = await loginAs('admin')\n  await adminPage.goto('/admin/users')\n  await expect(adminPage.getByRole('button', {name: 'Remove user'})).toBeVisible()\n\n  const guestPage = await loginAs('guest')\n  await guestPage.goto('/admin/users')\n  await expect(guestPage.getByText('Access denied')).toBeVisible()\n})\n```\n\n### OAuth/SSO Mocking\n\n**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.\n**Avoid when**: You have a dedicated test tenant on the OAuth provider.\n\nA typical OAuth flow works like this:\n\n1. User clicks \"Sign in with Provider\" → browser navigates to `https://accounts.provider.com/authorize?...`\n2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)\n3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page\n\nIn tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.\n\nFor cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.\n\n```typescript\n// tests/oauth-login.spec.ts — mock the callback route\nimport {test, expect} from '@playwright/test'\n\ntest('login via mocked OAuth flow', async ({page}) => {\n  await page.route('https://accounts.provider.com/**', async (route) => {\n    const callbackUrl = new URL('http://localhost:4000/auth/callback')\n    callbackUrl.searchParams.set('code', 'mock-auth-code-xyz')\n    callbackUrl.searchParams.set('state', 'expected-state-value')\n    await route.fulfill({\n      status: 302,\n      headers: {location: callbackUrl.toString()},\n    })\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Provider'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n```typescript\n// tests/oauth-login.spec.ts — API-based session injection\nimport {test, expect} from '@playwright/test'\n\ntest('bypass OAuth entirely via API session injection', async ({page}) => {\n  // Call a test-only endpoint that creates a session without OAuth\n  const response = await page.request.post('/api/test/create-session', {\n    data: {\n      email: 'oauth-user@example.com',\n      provider: 'provider',\n      role: 'member',\n    },\n  })\n  expect(response.ok()).toBeTruthy()\n\n  await page.context().storageState({path: '.auth/oauth-user.json'})\n  await page.goto('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.\n\n### MFA Handling\n\n**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).\n**Avoid when**: MFA is optional and you can disable it for test accounts.\n\n**Strategy 1**: Generate real TOTP codes from a shared secret.\n\n```typescript\n// helpers/totp.ts\nimport * as OTPAuth from 'otpauth'\n\nexport function generateTOTP(secret: string): string {\n  const totp = new OTPAuth.TOTP({\n    secret: OTPAuth.Secret.fromBase32(secret),\n    digits: 6,\n    period: 30,\n    algorithm: 'SHA1',\n  })\n  return totp.generate()\n}\n```\n\n```typescript\n// tests/mfa-login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {generateTOTP} from '../helpers/totp'\n\ntest('login with TOTP two-factor auth', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('mfa-user@example.com')\n  await page.getByLabel('Password').fill('secretPass123')\n  await page.getByRole('button', {name: 'Log in'}).click()\n\n  await expect(page.getByText('Enter your authentication code')).toBeVisible()\n\n  const code = generateTOTP(process.env.MFA_TOTP_SECRET!)\n  await page.getByLabel('Authentication code').fill(code)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await page.waitForURL('/home')\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n})\n```\n\n**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.\n\n**Strategy 3**: Disable MFA for test accounts at the infrastructure level.\n\n### Session Refresh\n\n**Use when**: Your tokens expire during long test runs.\n**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.\n\n```typescript\nimport fs from 'fs'\n\n// fixtures/auth-with-refresh.ts\nimport {test as base, type BrowserContext} from '@playwright/test'\n\ntype AuthFixtures = {\n  authenticatedPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<AuthFixtures>({\n  authenticatedPage: async ({browser}, use) => {\n    const statePath = '.auth/session.json'\n\n    let context: BrowserContext\n    if (fs.existsSync(statePath)) {\n      context = await browser.newContext({storageState: statePath})\n      const page = await context.newPage()\n\n      const response = await page.request.get('/api/auth/me')\n      if (response.ok()) {\n        await use(page)\n        await context.close()\n        return\n      }\n      await context.close()\n    }\n\n    context = await browser.newContext()\n    const page = await context.newPage()\n    await page.goto('/login')\n    await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)\n    await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)\n    await page.getByRole('button', {name: 'Log in'}).click()\n    await page.waitForURL('/home')\n\n    await context.storageState({path: statePath})\n\n    await use(page)\n    await context.close()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Login Page Object\n\n**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.\n**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.\n\n```typescript\n// page-objects/LoginPage.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class LoginPage {\n  readonly page: Page\n  readonly usernameInput: Locator\n  readonly passwordInput: Locator\n  readonly loginButton: Locator\n  readonly errorMessage: Locator\n  readonly forgotPasswordLink: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.usernameInput = page.getByLabel('Username')\n    this.passwordInput = page.getByLabel('Password')\n    this.loginButton = page.getByRole('button', {name: 'Log in'})\n    this.errorMessage = page.getByRole('alert')\n    this.forgotPasswordLink = page.getByRole('link', {\n      name: 'Forgot password',\n    })\n  }\n\n  async goto() {\n    await this.page.goto('/login')\n    await expect(this.loginButton).toBeVisible()\n  }\n\n  async login(username: string, password: string) {\n    await this.usernameInput.fill(username)\n    await this.passwordInput.fill(password)\n    await this.loginButton.click()\n  }\n\n  async loginAndWaitForHome(username: string, password: string) {\n    await this.login(username, password)\n    await this.page.waitForURL('/home')\n  }\n\n  async expectError(message: string | RegExp) {\n    await expect(this.errorMessage).toContainText(message)\n  }\n\n  async expectFieldError(field: 'username' | 'password', message: string) {\n    const input = field === 'username' ? this.usernameInput : this.passwordInput\n    await expect(input).toHaveAttribute('aria-invalid', 'true')\n    const errorId = await input.getAttribute('aria-describedby')\n    if (errorId) {\n      await expect(this.page.locator(`#${errorId}`)).toContainText(message)\n    }\n  }\n}\n```\n\n```typescript\n// tests/login.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {LoginPage} from '../page-objects/LoginPage'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('login page', () => {\n  let loginPage: LoginPage\n\n  test.beforeEach(async ({page}) => {\n    loginPage = new LoginPage(page)\n    await loginPage.goto()\n  })\n\n  test('successful login redirects to home', async ({page}) => {\n    await loginPage.loginAndWaitForHome('testuser@example.com', 'secretPass123')\n    await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  })\n\n  test('wrong password shows error', async () => {\n    await loginPage.login('testuser@example.com', 'wrong-password')\n    await loginPage.expectError('Invalid username or password')\n  })\n\n  test('empty fields show validation errors', async () => {\n    await loginPage.loginButton.click()\n    await loginPage.expectFieldError('username', 'Username is required')\n  })\n\n  test('forgot password link navigates correctly', async ({page}) => {\n    await loginPage.forgotPasswordLink.click()\n    await page.waitForURL('/forgot-password')\n    await expect(page.getByRole('heading', {name: 'Reset password'})).toBeVisible()\n  })\n})\n```\n\n### API-Based Login\n\n**Use when**: You want the fastest possible authentication without any browser interaction.\n**Avoid when**: You are specifically testing the login UI.\n\nAPI login is typically 5-10x faster than UI login.\n\n```typescript\n// global-setup.ts — API-based login (fastest)\nimport {request, type FullConfig} from '@playwright/test'\n\nasync function globalSetup(config: FullConfig) {\n  const {baseURL} = config.projects[0].use\n\n  const requestContext = await request.newContext({baseURL})\n\n  const response = await requestContext.post('/api/auth/login', {\n    data: {\n      email: process.env.TEST_USER_EMAIL!,\n      password: process.env.TEST_USER_PASSWORD!,\n    },\n  })\n\n  if (!response.ok()) {\n    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)\n  }\n\n  await requestContext.storageState({path: '.auth/session.json'})\n  await requestContext.dispose()\n}\n\nexport default globalSetup\n```\n\n```typescript\n// fixtures/api-auth.ts — fixture version for per-test authentication\nimport {test as base} from '@playwright/test'\n\nexport const test = base.extend({\n  authenticatedPage: async ({browser, playwright}, use) => {\n    const apiContext = await playwright.request.newContext({\n      baseURL: 'http://localhost:4000',\n    })\n\n    await apiContext.post('/api/auth/login', {\n      data: {\n        email: 'testuser@example.com',\n        password: 'secretPass123',\n      },\n    })\n\n    const state = await apiContext.storageState()\n    const context = await browser.newContext({storageState: state})\n    const page = await context.newPage()\n\n    await use(page)\n\n    await context.close()\n    await apiContext.dispose()\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n### Unauthenticated Tests\n\n**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.\n**Avoid when**: The test requires a logged-in user.\n\nWhen your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.\n\n```typescript\n// tests/public-pages.spec.ts\nimport {test, expect} from '@playwright/test'\n\ntest.use({storageState: {cookies: [], origins: []}})\n\ntest.describe('unauthenticated access', () => {\n  test('homepage is accessible without login', async ({page}) => {\n    await page.goto('/')\n    await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()\n    await expect(page.getByRole('link', {name: 'Log in'})).toBeVisible()\n  })\n\n  test('protected route redirects to login', async ({page}) => {\n    await page.goto('/home')\n    await page.waitForURL('**/login**')\n    expect(page.url()).toContain('redirect=%2Fhome')\n  })\n\n  test('expired session shows re-login prompt', async ({page, context}) => {\n    await page.goto('/home')\n    await context.clearCookies()\n\n    await page.goto('/settings')\n    await page.waitForURL('**/login**')\n    await expect(page.getByText('Your session has expired')).toBeVisible()\n  })\n\n  test('signup flow creates account', async ({page}) => {\n    await page.goto('/signup')\n    await page.getByLabel('Name').fill('New User')\n    await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`)\n    await page.getByLabel('Password', {exact: true}).fill('secretPass123')\n    await page.getByLabel('Confirm password').fill('secretPass123')\n    await page.getByRole('button', {name: 'Create account'}).click()\n\n    await page.waitForURL('/onboarding')\n    await expect(page.getByText('Welcome, New User')).toBeVisible()\n  })\n})\n```\n\n## Decision Guide\n\n| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                 |\n| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |\n| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project                               |\n| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data          |\n| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/member/guest roles                               |\n| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` |\n| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI                           |\n| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes or use a test-mode bypass             |\n| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use                       |\n| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles                                 |\n| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth                                     |\n\n### UI Login vs API Login vs Storage State\n\n```text\nNeed to test the login page itself?\n├── Yes → UI login with LoginPage POM, no storageState\n└── No → Do you have a login API endpoint?\n    ├── Yes → API login in global setup, save storageState (fastest)\n    └── No → UI login in global setup, save storageState\n              └── Tokens expire quickly?\n                  ├── Yes → Add session refresh fixture\n                  └── No → Standard storageState reuse is fine\n```\n\n## Anti-Patterns\n\n| Don't Do This                                                             | Problem                                     | Do This Instead                                                           |\n| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |\n| Log in via UI before every test                                           | Adds 2-5 seconds per test                   | Use `storageState` to skip login entirely                                 |\n| Share a single auth state file across parallel workers that mutate state  | Race conditions                             | Use per-worker fixtures with `{ scope: 'worker' }`                        |\n| Hardcode credentials in test files                                        | Security risk                               | Use environment variables and `.env` files                                |\n| Ignore token expiration                                                   | Tests fail intermittently with 401 errors   | Add a session validity check in your auth fixture                         |\n| Hit real OAuth providers in CI                                            | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection                      |\n| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay                             | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |\n| Store `.auth/*.json` files in git                                         | Tokens in version control                   | Add `.auth/` to `.gitignore`                                              |\n| Create one \"god\" test account with all permissions                        | Cannot test role-based access control       | Create separate accounts per role                                         |\n| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated        | Pass `storageState` when creating the context                             |\n| Test MFA by disabling it everywhere                                       | You never test the MFA flow                 | Use TOTP generation for at least one test                                 |\n\n## Troubleshooting\n\n### Global setup fails with \"Target page, context or browser has been closed\"\n\n**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.\n\n**Fix**:\n\n- Add `await page.waitForURL()` after the login action\n- Check that `baseURL` in your config matches the actual server URL and protocol\n- Add error handling to global setup:\n\n```typescript\nconst response = await page.waitForResponse('**/api/auth/**')\nif (!response.ok()) {\n  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)\n}\n```\n\n### Tests fail with 401 Unauthorized after running for a while\n\n**Cause**: The session token saved in `storageState` has expired.\n\n**Fix**:\n\n- Use the session refresh fixture pattern\n- Increase token expiry in test environment configuration\n- Switch to API-based login in a worker-scoped fixture\n\n### `storageState` file is empty or contains no cookies\n\n**Cause**: `storageState()` was called before the login response set cookies.\n\n**Fix**:\n\n- Wait for the post-login page to load: `await page.waitForURL('/home')`\n- Verify cookies exist before saving:\n\n```typescript\nconst cookies = await context.cookies()\nif (cookies.length === 0) {\n  throw new Error('No cookies found after login')\n}\nawait context.storageState({path: '.auth/session.json'})\n```\n\n### Different browsers get different cookies\n\n**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.\n\n**Fix**:\n\n- Generate separate auth state files per browser project\n- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:\n\n```typescript\nprojects: [\n  {\n    name: 'chromium',\n    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },\n  },\n  {\n    name: 'firefox',\n    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },\n  },\n],\n```\n\n### Parallel tests interfere with each other's sessions\n\n**Cause**: Multiple workers share the same test account and one worker's actions affect others.\n\n**Fix**:\n\n- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`\n- Use the per-worker authentication fixture pattern\n- Make tests idempotent\n\n### OAuth mock does not work — still redirects to real provider\n\n**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.\n\n**Fix**:\n\n- Register route handlers before any navigation: call `page.route()` before `page.goto()`\n- Log the actual redirect URL to verify the pattern:\n\n```typescript\npage.on('request', (req) => {\n  if (req.url().includes('oauth') || req.url().includes('accounts.provider')) {\n    console.log('OAuth request:', req.url())\n  }\n})\n```\n\n## Related\n\n- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown\n- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration\n- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies\n- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking\n- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login\n- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/clock-mocking.md (Tokens: ~2502 | Category: anthropic-skill) -->\n# Date, Time & Clock Mocking\n\n## Table of Contents\n\n1. [Clock API Basics](#clock-api-basics)\n2. [Fixed Time Testing](#fixed-time-testing)\n3. [Time Advancement](#time-advancement)\n4. [Timezone Testing](#timezone-testing)\n5. [Timer Mocking](#timer-mocking)\n\n## Clock API Basics\n\n### Install Clock\n\n```typescript\ntest('mock current time', async ({page}) => {\n  // Install clock before navigating\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  await page.goto('/dashboard')\n\n  // Page sees January 15, 2025 as current date\n  await expect(page.getByText('January 15, 2025')).toBeVisible()\n})\n```\n\n### Clock with Fixture\n\n```typescript\n// fixtures/clock.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClockFixtures = {\n  mockTime: (date: Date | string) => Promise<void>\n}\n\nexport const test = base.extend<ClockFixtures>({\n  mockTime: async ({page}, use) => {\n    await use(async (date) => {\n      const time = typeof date === 'string' ? new Date(date) : date\n      await page.clock.install({time})\n    })\n  },\n})\n\n// Usage\ntest('subscription expiry', async ({page, mockTime}) => {\n  await mockTime('2025-12-31T23:59:00')\n  await page.goto('/subscription')\n\n  await expect(page.getByText('Expires today')).toBeVisible()\n})\n```\n\n## Fixed Time Testing\n\n### Test Date-Dependent Features\n\n```typescript\ntest('show holiday banner in December', async ({page}) => {\n  await page.clock.install({time: new Date('2025-12-20T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeVisible()\n})\n\ntest('no holiday banner in January', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n\n  await page.goto('/')\n\n  await expect(page.getByRole('banner', {name: /holiday/i})).toBeHidden()\n})\n```\n\n### Test Relative Time Display\n\n```typescript\ntest('shows relative time correctly', async ({page}) => {\n  // Fix time to control \"posted 2 hours ago\" text\n  await page.clock.install({time: new Date('2025-06-15T14:00:00')})\n\n  // Mock API to return post with known timestamp\n  await page.route('**/api/posts/1', (route) =>\n    route.fulfill({\n      json: {\n        id: 1,\n        title: 'Test Post',\n        createdAt: '2025-06-15T12:00:00Z', // 2 hours before mock time\n      },\n    }),\n  )\n\n  await page.goto('/posts/1')\n\n  await expect(page.getByText('2 hours ago')).toBeVisible()\n})\n```\n\n### Test Date Boundaries\n\n```typescript\ntest.describe('end of month billing', () => {\n  test('shows billing on last day of month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-31T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('Payment due today')).toBeVisible()\n  })\n\n  test('shows days remaining mid-month', async ({page}) => {\n    await page.clock.install({time: new Date('2025-01-15T10:00:00')})\n    await page.goto('/billing')\n\n    await expect(page.getByText('16 days until payment')).toBeVisible()\n  })\n})\n```\n\n## Time Advancement\n\n### Advance Time Manually\n\n```typescript\ntest('session timeout warning', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/dashboard')\n\n  // Advance 25 minutes (session timeout at 30 min)\n  await page.clock.fastForward('25:00')\n\n  await expect(page.getByText('Session expires in 5 minutes')).toBeVisible()\n\n  // Advance 5 more minutes\n  await page.clock.fastForward('05:00')\n\n  await expect(page.getByText('Session expired')).toBeVisible()\n})\n```\n\n### Pause and Resume Time\n\n```typescript\ntest('countdown timer', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/sale')\n\n  // Initial state\n  await expect(page.getByText('Sale ends in 2:00:00')).toBeVisible()\n\n  // Advance 1 hour\n  await page.clock.fastForward('01:00:00')\n\n  await expect(page.getByText('Sale ends in 1:00:00')).toBeVisible()\n\n  // Advance past end\n  await page.clock.fastForward('01:00:01')\n\n  await expect(page.getByText('Sale ended')).toBeVisible()\n})\n```\n\n### Run Pending Timers\n\n```typescript\ntest('debounced search', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/search')\n\n  await page.getByLabel('Search').fill('playwright')\n\n  // Search is debounced by 300ms, won't fire yet\n  await expect(page.getByTestId('search-results')).toBeHidden()\n\n  // Fast forward past debounce\n  await page.clock.fastForward(300)\n\n  // Now search should execute\n  await expect(page.getByTestId('search-results')).toBeVisible()\n})\n```\n\n## Timezone Testing\n\n### Test Different Timezones\n\n```typescript\ntest.describe('timezone display', () => {\n  test('shows correct time in PST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'America/Los_Angeles',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 9 AM PST\n    await expect(page.getByText('9:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n\n  test('shows correct time in JST', async ({browser}) => {\n    const context = await browser.newContext({\n      timezoneId: 'Asia/Tokyo',\n    })\n    const page = await context.newPage()\n\n    await page.clock.install({time: new Date('2025-01-15T17:00:00Z')}) // 5 PM UTC\n\n    await page.goto('/schedule')\n\n    // Should show 2 AM next day JST\n    await expect(page.getByText('2:00 AM')).toBeVisible()\n\n    await context.close()\n  })\n})\n```\n\n### Timezone Fixture\n\n```typescript\n// fixtures/timezone.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype TimezoneFixtures = {\n  pageInTimezone: (timezone: string) => Promise<Page>\n}\n\nexport const test = base.extend<TimezoneFixtures>({\n  pageInTimezone: async ({browser}, use) => {\n    const pages: Page[] = []\n\n    await use(async (timezone) => {\n      const context = await browser.newContext({timezoneId: timezone})\n      const page = await context.newPage()\n      pages.push(page)\n      return page\n    })\n\n    // Cleanup\n    for (const page of pages) {\n      await page.context().close()\n    }\n  },\n})\n```\n\n## Timer Mocking\n\n### Mock setInterval\n\n```typescript\ntest('auto-refresh data', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n\n  let apiCalls = 0\n  await page.route('**/api/data', (route) => {\n    apiCalls++\n    route.fulfill({json: {value: apiCalls}})\n  })\n\n  await page.goto('/live-data') // Sets up 30s refresh interval\n\n  expect(apiCalls).toBe(1) // Initial load\n\n  // Advance 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(2) // First refresh\n\n  // Advance another 30 seconds\n  await page.clock.fastForward('00:30')\n  expect(apiCalls).toBe(3) // Second refresh\n})\n```\n\n### Mock setTimeout Chains\n\n```typescript\ntest('notification queue', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/notifications')\n\n  // Trigger 3 notifications that show sequentially\n  await page.getByRole('button', {name: 'Show All'}).click()\n\n  // First notification appears immediately\n  await expect(page.getByText('Notification 1')).toBeVisible()\n\n  // Second appears after 2 seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 2')).toBeVisible()\n\n  // Third appears after 2 more seconds\n  await page.clock.fastForward('00:02')\n  await expect(page.getByText('Notification 3')).toBeVisible()\n})\n```\n\n### Test Animation Frames\n\n```typescript\ntest('animation completes', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15T09:00:00')})\n  await page.goto('/animation-demo')\n\n  await page.getByRole('button', {name: 'Animate'}).click()\n\n  // Animation runs for 500ms\n  const element = page.getByTestId('animated-box')\n  await expect(element).toHaveCSS('opacity', '0')\n\n  // Fast forward through animation\n  await page.clock.fastForward(500)\n\n  await expect(element).toHaveCSS('opacity', '1')\n})\n```\n\n## Best Practices\n\n### Always Install Clock Before Navigation\n\n```typescript\n// Good\ntest('date test', async ({page}) => {\n  await page.clock.install({time: new Date('2025-01-15')})\n  await page.goto('/') // Page loads with mocked time\n})\n\n// Bad - time already captured by page\ntest('date test', async ({page}) => {\n  await page.goto('/')\n  await page.clock.install({time: new Date('2025-01-15')}) // Too late!\n})\n```\n\n### Use ISO Strings for Clarity\n\n```typescript\n// Good - explicit timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00Z')})\n\n// Ambiguous - uses local timezone\nawait page.clock.install({time: new Date('2025-01-15T09:00:00')})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                             | Problem                         | Solution                               |\n| ---------------------------------------- | ------------------------------- | -------------------------------------- |\n| Installing clock after navigation        | Page already captured real time | Install clock before `goto()`          |\n| Hardcoded relative dates                 | Tests break over time           | Use fixed dates with clock mock        |\n| Not accounting for timezone              | Tests fail in different regions | Use explicit UTC times or set timezone |\n| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers    | Use `fastForward` instead              |\n\n## Related References\n\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/mobile-testing.md (Tokens: ~2732 | Category: anthropic-skill) -->\n# Mobile & Responsive Testing\n\n## Table of Contents\n\n1. [Device Emulation](#device-emulation)\n2. [Touch Gestures](#touch-gestures)\n3. [Viewport Testing](#viewport-testing)\n4. [Mobile-Specific UI](#mobile-specific-ui)\n5. [Responsive Breakpoints](#responsive-breakpoints)\n\n## Device Emulation\n\n### Use Built-in Devices\n\n```typescript\nimport {test, devices} from '@playwright/test'\n\n// Configure in playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {name: 'Desktop Chrome', use: {...devices['Desktop Chrome']}},\n    {name: 'Mobile Safari', use: {...devices['iPhone 14']}},\n    {name: 'Mobile Chrome', use: {...devices['Pixel 7']}},\n    {name: 'Tablet', use: {...devices['iPad Pro 11']}},\n  ],\n})\n```\n\n### Custom Device Configuration\n\n```typescript\ntest.use({\n  viewport: {width: 390, height: 844},\n  deviceScaleFactor: 3,\n  isMobile: true,\n  hasTouch: true,\n  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',\n})\n\ntest('custom mobile device', async ({page}) => {\n  await page.goto('/')\n  // Test runs with custom device settings\n})\n```\n\n### Test Across Multiple Devices\n\n```typescript\nconst mobileDevices = ['iPhone 14', 'Pixel 7', 'Galaxy S21']\n\nfor (const deviceName of mobileDevices) {\n  test(`checkout on ${deviceName}`, async ({browser}) => {\n    const device = devices[deviceName]\n    const context = await browser.newContext({...device})\n    const page = await context.newPage()\n\n    await page.goto('/checkout')\n    await expect(page.getByRole('button', {name: 'Pay'})).toBeVisible()\n\n    await context.close()\n  })\n}\n```\n\n## Touch Gestures\n\n### Tap\n\n```typescript\ntest.use({hasTouch: true})\n\ntest('tap to interact', async ({page}) => {\n  await page.goto('/gallery')\n\n  // Tap is like click but for touch devices\n  await page.getByRole('img', {name: 'Photo 1'}).tap()\n\n  await expect(page.getByRole('dialog')).toBeVisible()\n})\n```\n\n### Swipe\n\n```typescript\ntest('swipe carousel', async ({page}) => {\n  await page.goto('/carousel')\n\n  const carousel = page.getByTestId('carousel')\n  const box = await carousel.boundingBox()\n\n  if (box) {\n    // Swipe left\n    await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2)\n    await page.mouse.move(box.x + 50, box.y + box.height / 2)\n\n    // Or use drag\n    await carousel.dragTo(carousel, {\n      sourcePosition: {x: box.width - 50, y: box.height / 2},\n      targetPosition: {x: 50, y: box.height / 2},\n    })\n  }\n\n  await expect(page.getByText('Slide 2')).toBeVisible()\n})\n```\n\n### Swipe Fixture\n\n```typescript\n// fixtures/touch.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype TouchFixtures = {\n  swipe: (element: Locator, direction: 'left' | 'right' | 'up' | 'down') => Promise<void>\n}\n\nexport const test = base.extend<TouchFixtures>({\n  swipe: async ({page}, use) => {\n    await use(async (element, direction) => {\n      const box = await element.boundingBox()\n      if (!box) throw new Error('Element not visible')\n\n      const centerX = box.x + box.width / 2\n      const centerY = box.y + box.height / 2\n      const distance = 100\n\n      const moves = {\n        left: {\n          startX: centerX + distance,\n          endX: centerX - distance,\n          y: centerY,\n        },\n        right: {\n          startX: centerX - distance,\n          endX: centerX + distance,\n          y: centerY,\n        },\n        up: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY + distance,\n          endY: centerY - distance,\n        },\n        down: {\n          startX: centerX,\n          endX: centerX,\n          startY: centerY - distance,\n          endY: centerY + distance,\n        },\n      }\n\n      const move = moves[direction]\n      await page.touchscreen.tap(move.startX, move.startY ?? move.y)\n      await page.mouse.move(move.endX, move.endY ?? move.y, {steps: 10})\n      await page.mouse.up()\n    })\n  },\n})\n\n// Usage\ntest('swipe to delete', async ({page, swipe}) => {\n  await page.goto('/inbox')\n\n  const message = page.getByTestId('message-1')\n  await swipe(message, 'left')\n\n  await expect(page.getByRole('button', {name: 'Delete'})).toBeVisible()\n})\n```\n\n### Long Press\n\n```typescript\ntest('long press for context menu', async ({page}) => {\n  await page.goto('/files')\n\n  const file = page.getByText('document.pdf')\n  const box = await file.boundingBox()\n\n  if (box) {\n    // Touch down\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2)\n\n    // Hold for 500ms\n    await page.waitForTimeout(500)\n\n    // Context menu should appear\n    await expect(page.getByRole('menu')).toBeVisible()\n  }\n})\n```\n\n### Pinch Zoom\n\n```typescript\ntest('pinch to zoom image', async ({page}) => {\n  await page.goto('/map')\n\n  // Pinch zoom requires two touch points\n  // Playwright doesn't have native pinch support, so we simulate via evaluate\n  await page.evaluate(() => {\n    const element = document.querySelector('#map')\n    if (element) {\n      // Simulate wheel event as fallback for zoom\n      element.dispatchEvent(\n        new WheelEvent('wheel', {\n          deltaY: -100, // Negative = zoom in\n          ctrlKey: true, // Ctrl+wheel = pinch on many apps\n        }),\n      )\n    }\n  })\n\n  // Or trigger the app's zoom function directly\n  await page.evaluate(() => {\n    ;(window as any).mapInstance?.setZoom(15)\n  })\n})\n```\n\n## Viewport Testing\n\n### Test Different Sizes\n\n```typescript\nconst viewports = [\n  {name: 'mobile', width: 375, height: 667},\n  {name: 'tablet', width: 768, height: 1024},\n  {name: 'desktop', width: 1920, height: 1080},\n]\n\nfor (const {name, width, height} of viewports) {\n  test(`navigation on ${name}`, async ({page}) => {\n    await page.setViewportSize({width, height})\n    await page.goto('/')\n\n    if (width < 768) {\n      // Mobile: should have hamburger menu\n      await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n    } else {\n      // Desktop: should have visible nav links\n      await expect(page.getByRole('link', {name: 'Products'})).toBeVisible()\n    }\n  })\n}\n```\n\n### Dynamic Viewport Changes\n\n```typescript\ntest('responsive layout change', async ({page}) => {\n  await page.setViewportSize({width: 1200, height: 800})\n  await page.goto('/dashboard')\n\n  // Desktop: sidebar visible\n  await expect(page.getByRole('complementary')).toBeVisible()\n\n  // Resize to mobile\n  await page.setViewportSize({width: 375, height: 667})\n\n  // Mobile: sidebar hidden, hamburger visible\n  await expect(page.getByRole('complementary')).toBeHidden()\n  await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()\n})\n```\n\n## Mobile-Specific UI\n\n### Hamburger Menu\n\n```typescript\ntest('mobile navigation', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/')\n\n  // Open hamburger menu\n  await page.getByRole('button', {name: 'Menu'}).click()\n\n  // Navigation drawer should appear\n  const nav = page.getByRole('navigation')\n  await expect(nav).toBeVisible()\n\n  // Navigate via mobile menu\n  await nav.getByRole('link', {name: 'Products'}).click()\n\n  await expect(page).toHaveURL('/products')\n  // Menu should close after navigation\n  await expect(nav).toBeHidden()\n})\n```\n\n### Bottom Sheet\n\n```typescript\ntest('bottom sheet interaction', async ({page}) => {\n  await page.setViewportSize({width: 375, height: 667})\n  await page.goto('/product/123')\n\n  await page.getByRole('button', {name: 'Add to Cart'}).click()\n\n  // Bottom sheet appears\n  const sheet = page.getByRole('dialog')\n  await expect(sheet).toBeVisible()\n\n  // Select options\n  await sheet.getByRole('combobox', {name: 'Size'}).selectOption('Large')\n  await sheet.getByRole('button', {name: 'Confirm'}).click()\n\n  await expect(page.getByText('Added to cart')).toBeVisible()\n})\n```\n\n### Pull to Refresh\n\n```typescript\ntest('pull to refresh', async ({page}) => {\n  await page.goto('/feed')\n\n  const feed = page.getByTestId('feed')\n  const initialFirstItem = await feed.locator('> *').first().textContent()\n\n  // Simulate pull down\n  const box = await feed.boundingBox()\n  if (box) {\n    await page.touchscreen.tap(box.x + box.width / 2, box.y + 50)\n    await page.mouse.move(box.x + box.width / 2, box.y + 200, {steps: 20})\n    await page.mouse.up()\n  }\n\n  // Wait for refresh\n  await expect(page.getByTestId('loading')).toBeVisible()\n  await expect(page.getByTestId('loading')).toBeHidden()\n\n  // Content should be updated (in a real app)\n})\n```\n\n## Responsive Breakpoints\n\n### Test All Breakpoints\n\n```typescript\nconst breakpoints = {\n  'xs': 320,\n  'sm': 640,\n  'md': 768,\n  'lg': 1024,\n  'xl': 1280,\n  '2xl': 1536,\n}\n\ntest.describe('responsive header', () => {\n  for (const [name, width] of Object.entries(breakpoints)) {\n    test(`header at ${name} (${width}px)`, async ({page}) => {\n      await page.setViewportSize({width, height: 800})\n      await page.goto('/')\n\n      if (width < 768) {\n        await expect(page.getByTestId('mobile-menu-button')).toBeVisible()\n        await expect(page.getByTestId('desktop-nav')).toBeHidden()\n      } else {\n        await expect(page.getByTestId('mobile-menu-button')).toBeHidden()\n        await expect(page.getByTestId('desktop-nav')).toBeVisible()\n      }\n    })\n  }\n})\n```\n\n### Visual Regression at Breakpoints\n\n```typescript\ntest.describe('visual regression', () => {\n  const sizes = [\n    {width: 375, height: 667, name: 'mobile'},\n    {width: 768, height: 1024, name: 'tablet'},\n    {width: 1440, height: 900, name: 'desktop'},\n  ]\n\n  for (const {width, height, name} of sizes) {\n    test(`homepage at ${name}`, async ({page}) => {\n      await page.setViewportSize({width, height})\n      await page.goto('/')\n\n      await expect(page).toHaveScreenshot(`homepage-${name}.png`)\n    })\n  }\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                   | Solution                         |\n| --------------------------- | ------------------------- | -------------------------------- |\n| Only testing one viewport   | Misses responsive bugs    | Test multiple breakpoints        |\n| Ignoring touch events       | Features broken on mobile | Test tap, swipe, long press      |\n| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()`     |\n| Not testing orientation     | Landscape bugs missed     | Test both portrait and landscape |\n\n## Related References\n\n- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing\n- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors\n- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)\n- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/multi-context.md (Tokens: ~1967 | Category: anthropic-skill) -->\n# Multi-Tab, Window & Popup Testing\n\nThis file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).\n\n## Table of Contents\n\n1. [Popup Handling](#popup-handling)\n2. [New Tab Navigation](#new-tab-navigation)\n3. [OAuth Flows](#oauth-flows)\n4. [Multiple Windows](#multiple-windows)\n5. [Tab Coordination](#tab-coordination)\n\n## Popup Handling\n\n### Basic Popup\n\n```typescript\ntest('handle popup window', async ({page}) => {\n  await page.goto('/')\n\n  // Start waiting for popup before triggering it\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Open Support Chat'}).click()\n  const popup = await popupPromise\n\n  // Wait for popup to load\n  await popup.waitForLoadState()\n\n  // Interact with popup\n  await popup.getByLabel('Message').fill('Need help')\n  await popup.getByRole('button', {name: 'Send'}).click()\n\n  await expect(popup.getByText('Message sent')).toBeVisible()\n\n  // Close popup\n  await popup.close()\n})\n```\n\n### Popup with Authentication\n\n```typescript\ntest('popup login flow', async ({page}) => {\n  await page.goto('/dashboard')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Connect Account'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Complete login in popup\n  await popup.getByLabel('Email').fill('user@example.com')\n  await popup.getByLabel('Password').fill('password123')\n  await popup.getByRole('button', {name: 'Log In'}).click()\n\n  // Popup should close automatically after auth\n  await popup.waitForEvent('close')\n\n  // Main page should reflect connected state\n  await expect(page.getByText('Account connected')).toBeVisible()\n})\n```\n\n### Handle Blocked Popups\n\n```typescript\ntest('handle popup blocker', async ({page}) => {\n  await page.goto('/share')\n\n  // Listen for console messages about blocked popup\n  page.on('console', (msg) => {\n    if (msg.text().includes('popup blocked')) {\n      console.log('Popup was blocked')\n    }\n  })\n\n  const popupPromise = page.waitForEvent('popup').catch(() => null)\n  await page.getByRole('button', {name: 'Share to Twitter'}).click()\n  const popup = await popupPromise\n\n  if (!popup) {\n    // Popup blocked - app should show fallback\n    await expect(page.getByText('Copy share link instead')).toBeVisible()\n  }\n})\n```\n\n## New Tab Navigation\n\n### Link Opens in New Tab\n\n```typescript\ntest('external link opens in new tab', async ({page, context}) => {\n  await page.goto('/resources')\n\n  // Wait for new page in context\n  const pagePromise = context.waitForEvent('page')\n  await page.getByRole('link', {name: 'Documentation'}).click()\n  const newPage = await pagePromise\n\n  await newPage.waitForLoadState()\n\n  expect(newPage.url()).toContain('docs.example.com')\n  await expect(newPage.getByRole('heading', {level: 1})).toBeVisible()\n\n  // Original page still there\n  expect(page.url()).toContain('/resources')\n\n  await newPage.close()\n})\n```\n\n### Intercept New Tab\n\n```typescript\ntest('prevent new tab for testing', async ({page}) => {\n  await page.goto('/links')\n\n  // Remove target=\"_blank\" to keep navigation in same tab\n  await page.evaluate(() => {\n    document.querySelectorAll('a[target=\"_blank\"]').forEach((a) => {\n      a.removeAttribute('target')\n    })\n  })\n\n  // Now link opens in same tab\n  await page.getByRole('link', {name: 'External Site'}).click()\n\n  // Can test the destination page\n  await expect(page).toHaveURL(/external-site\\.com/)\n})\n```\n\n## OAuth Flows\n\n### Google OAuth Popup\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  await page.goto('/login')\n\n  const popupPromise = page.waitForEvent('popup')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n  const popup = await popupPromise\n\n  await popup.waitForLoadState()\n\n  // Handle Google's OAuth flow\n  await popup.getByLabel('Email or phone').fill('test@gmail.com')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  await popup.getByLabel('Enter your password').fill('password')\n  await popup.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for redirect back and popup close\n  await popup.waitForEvent('close')\n\n  // Verify logged in on main page\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### Mock OAuth (Recommended)\n\n```typescript\ntest('mock OAuth flow', async ({page, context}) => {\n  // Mock the OAuth callback instead of real flow\n  await page.route('**/auth/callback**', async (route) => {\n    // Simulate successful OAuth\n    const url = new URL(route.request().url())\n    url.searchParams.set('code', 'mock-auth-code')\n    await route.fulfill({\n      status: 302,\n      headers: {Location: '/dashboard'},\n    })\n  })\n\n  // Mock token exchange\n  await page.route('**/api/auth/token', (route) =>\n    route.fulfill({\n      json: {\n        access_token: 'mock-token',\n        user: {name: 'Test User', email: 'test@example.com'},\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // Should redirect to dashboard without actual OAuth\n  await expect(page).toHaveURL('/dashboard')\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.\n\n## Multiple Windows\n\n### Test Across Multiple Windows\n\n```typescript\ntest('sync between windows', async ({context}) => {\n  // Open two pages\n  const page1 = await context.newPage()\n  const page2 = await context.newPage()\n\n  await page1.goto('/dashboard')\n  await page2.goto('/dashboard')\n\n  // Make change in first window\n  await page1.getByRole('button', {name: 'Add Item'}).click()\n  await page1.getByLabel('Name').fill('New Item')\n  await page1.getByRole('button', {name: 'Save'}).click()\n\n  // Should sync to second window (if app supports real-time sync)\n  await expect(page2.getByText('New Item')).toBeVisible({timeout: 10000})\n})\n```\n\n### Different Users in Different Windows\n\n> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.\n\n## Tab Coordination\n\n### Switch Between Tabs\n\n```typescript\ntest('manage multiple tabs', async ({context}) => {\n  const page1 = await context.newPage()\n  await page1.goto('/editor')\n\n  const page2 = await context.newPage()\n  await page2.goto('/preview')\n\n  // Edit in first tab\n  await page1.bringToFront()\n  await page1.getByLabel('Content').fill('Hello World')\n\n  // Check preview in second tab\n  await page2.bringToFront()\n  await page2.reload() // If preview needs refresh\n  await expect(page2.getByText('Hello World')).toBeVisible()\n})\n```\n\n### Close All Tabs Except One\n\n```typescript\ntest('cleanup tabs after test', async ({context}) => {\n  const mainPage = await context.newPage()\n  await mainPage.goto('/')\n\n  // Open several popups during test\n  for (let i = 0; i < 3; i++) {\n    const popup = await context.newPage()\n    await popup.goto(`/popup/${i}`)\n  }\n\n  // Close all except main page\n  for (const page of context.pages()) {\n    if (page !== mainPage) {\n      await page.close()\n    }\n  }\n\n  expect(context.pages()).toHaveLength(1)\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern            | Problem                        | Solution                                   |\n| ----------------------- | ------------------------------ | ------------------------------------------ |\n| Not waiting for popup   | Race condition                 | Use `waitForEvent(\"popup\")` before trigger |\n| Testing real OAuth      | Slow, flaky, needs credentials | Mock OAuth endpoints                       |\n| Assuming popup opens    | May be blocked                 | Handle both open and blocked cases         |\n| Not closing extra pages | Resource leak                  | Close pages in cleanup                     |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/multi-user.md (Tokens: ~2816 | Category: anthropic-skill) -->\n# Multi-User & Collaboration Testing\n\n## Table of Contents\n\n1. [Multiple Browser Contexts](#multiple-browser-contexts)\n2. [Real-Time Collaboration](#real-time-collaboration)\n3. [Role-Based Testing](#role-based-testing)\n4. [Concurrent Actions](#concurrent-actions)\n5. [Chat & Messaging](#chat--messaging)\n\n## Multiple Browser Contexts\n\n### Two Users in Same Test\n\n```typescript\ntest(\"two users see each other's changes\", async ({browser}) => {\n  // Create two isolated contexts (like two browsers)\n  const userAContext = await browser.newContext()\n  const userBContext = await browser.newContext()\n\n  const userAPage = await userAContext.newPage()\n  const userBPage = await userBContext.newPage()\n\n  // Both users go to the same document\n  await userAPage.goto('/doc/shared-123')\n  await userBPage.goto('/doc/shared-123')\n\n  // User A types\n  await userAPage.getByLabel('Content').fill('Hello from User A')\n\n  // User B should see the change\n  await expect(userBPage.getByText('Hello from User A')).toBeVisible()\n\n  // Cleanup\n  await userAContext.close()\n  await userBContext.close()\n})\n```\n\n### Multiple Users with Auth States\n\n```typescript\ntest('admin and user interaction', async ({browser}) => {\n  // Load different auth states\n  const adminContext = await browser.newContext({\n    storageState: '.auth/admin.json',\n  })\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n\n  const adminPage = await adminContext.newPage()\n  const userPage = await userContext.newPage()\n\n  // User submits request\n  await userPage.goto('/support')\n  await userPage.getByLabel('Message').fill('Need help!')\n  await userPage.getByRole('button', {name: 'Submit'}).click()\n\n  // Admin sees and responds\n  await adminPage.goto('/admin/tickets')\n  await expect(adminPage.getByText('Need help!')).toBeVisible()\n  await adminPage.getByRole('button', {name: 'Reply'}).click()\n  await adminPage.getByLabel('Response').fill('How can I help?')\n  await adminPage.getByRole('button', {name: 'Send'}).click()\n\n  // User sees response\n  await expect(userPage.getByText('How can I help?')).toBeVisible()\n\n  await adminContext.close()\n  await userContext.close()\n})\n```\n\n### Multi-User Fixture\n\n```typescript\n// fixtures/multi-user.fixture.ts\nimport {test as base, Browser, BrowserContext, Page} from '@playwright/test'\n\ntype UserSession = {\n  context: BrowserContext\n  page: Page\n}\n\ntype MultiUserFixtures = {\n  createUser: (authState?: string) => Promise<UserSession>\n}\n\nexport const test = base.extend<MultiUserFixtures>({\n  createUser: async ({browser}, use) => {\n    const sessions: UserSession[] = []\n\n    await use(async (authState) => {\n      const context = await browser.newContext({\n        storageState: authState,\n      })\n      const page = await context.newPage()\n      sessions.push({context, page})\n      return {context, page}\n    })\n\n    // Cleanup all sessions\n    for (const session of sessions) {\n      await session.context.close()\n    }\n  },\n})\n\n// Usage\ntest('3 users collaborate', async ({createUser}) => {\n  const alice = await createUser('.auth/alice.json')\n  const bob = await createUser('.auth/bob.json')\n  const charlie = await createUser('.auth/charlie.json')\n\n  // All navigate to same room\n  await alice.page.goto('/room/123')\n  await bob.page.goto('/room/123')\n  await charlie.page.goto('/room/123')\n\n  // Test interactions...\n})\n```\n\n## Real-Time Collaboration\n\n### Collaborative Document\n\n```typescript\ntest('real-time collaborative editing', async ({browser}) => {\n  const user1 = await browser.newContext()\n  const user2 = await browser.newContext()\n\n  const page1 = await user1.newPage()\n  const page2 = await user2.newPage()\n\n  await page1.goto('/docs/shared')\n  await page2.goto('/docs/shared')\n\n  // User 1 types at the beginning\n  const editor1 = page1.getByRole('textbox')\n  await editor1.click()\n  await editor1.press('Home')\n  await editor1.type('User 1: ')\n\n  // User 2 types at the end\n  const editor2 = page2.getByRole('textbox')\n  await editor2.click()\n  await editor2.press('End')\n  await editor2.type(' - User 2')\n\n  // Both should see combined result\n  await expect(page1.getByRole('textbox')).toContainText('User 1:')\n  await expect(page1.getByRole('textbox')).toContainText('- User 2')\n  await expect(page2.getByRole('textbox')).toContainText('User 1:')\n  await expect(page2.getByRole('textbox')).toContainText('- User 2')\n\n  await user1.close()\n  await user2.close()\n})\n```\n\n### Cursor Presence\n\n```typescript\ntest('shows other user cursors', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Mock to identify users\n  await page1.route('**/api/me', (route) => route.fulfill({json: {id: 'user-1', name: 'Alice'}}))\n  await page2.route('**/api/me', (route) => route.fulfill({json: {id: 'user-2', name: 'Bob'}}))\n\n  await page1.goto('/whiteboard/123')\n  await page2.goto('/whiteboard/123')\n\n  // Move cursor on page1\n  await page1.mouse.move(200, 200)\n\n  // Page2 should see Alice's cursor\n  await expect(page2.getByTestId('cursor-user-1')).toBeVisible()\n  await expect(page2.getByText('Alice')).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Role-Based Testing\n\n### Test RBAC\n\n```typescript\nconst roles = [\n  {role: 'admin', canDelete: true, canEdit: true, canView: true},\n  {role: 'editor', canDelete: false, canEdit: true, canView: true},\n  {role: 'viewer', canDelete: false, canEdit: false, canView: true},\n]\n\nfor (const {role, canDelete, canEdit, canView} of roles) {\n  test(`${role} permissions`, async ({browser}) => {\n    const context = await browser.newContext({\n      storageState: `.auth/${role}.json`,\n    })\n    const page = await context.newPage()\n\n    await page.goto('/document/123')\n\n    // Check view permission\n    if (canView) {\n      await expect(page.getByTestId('content')).toBeVisible()\n    } else {\n      await expect(page.getByText('Access denied')).toBeVisible()\n    }\n\n    // Check edit permission\n    const editButton = page.getByRole('button', {name: 'Edit'})\n    if (canEdit) {\n      await expect(editButton).toBeEnabled()\n    } else {\n      await expect(editButton).toBeDisabled()\n    }\n\n    // Check delete permission\n    const deleteButton = page.getByRole('button', {name: 'Delete'})\n    if (canDelete) {\n      await expect(deleteButton).toBeVisible()\n    } else {\n      await expect(deleteButton).toBeHidden()\n    }\n\n    await context.close()\n  })\n}\n```\n\n### Permission Escalation Test\n\n```typescript\ntest('cannot access admin routes as user', async ({browser}) => {\n  const userContext = await browser.newContext({\n    storageState: '.auth/user.json',\n  })\n  const page = await userContext.newPage()\n\n  // Try to access admin page directly\n  await page.goto('/admin/users')\n\n  // Should redirect or show error\n  await expect(page).not.toHaveURL('/admin/users')\n  await expect(page.getByText('Access denied')).toBeVisible()\n\n  await userContext.close()\n})\n```\n\n## Concurrent Actions\n\n### Race Condition Testing\n\n```typescript\ntest('handles concurrent edits', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  await page1.goto('/item/123')\n  await page2.goto('/item/123')\n\n  // Both click edit at the same time\n  await Promise.all([\n    page1.getByRole('button', {name: 'Edit'}).click(),\n    page2.getByRole('button', {name: 'Edit'}).click(),\n  ])\n\n  // Both try to save different values\n  await page1.getByLabel('Name').fill('Value from User 1')\n  await page2.getByLabel('Name').fill('Value from User 2')\n\n  await Promise.all([\n    page1.getByRole('button', {name: 'Save'}).click(),\n    page2.getByRole('button', {name: 'Save'}).click(),\n  ])\n\n  // One should succeed, one should get conflict error\n  const page1HasConflict = await page1.getByText('Conflict').isVisible()\n  const page2HasConflict = await page2.getByText('Conflict').isVisible()\n\n  // Exactly one should have conflict\n  expect(page1HasConflict || page2HasConflict).toBe(true)\n  expect(page1HasConflict && page2HasConflict).toBe(false)\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n### Optimistic Locking Test\n\n```typescript\ntest('optimistic locking prevents overwrites', async ({browser}) => {\n  const ctx1 = await browser.newContext()\n  const ctx2 = await browser.newContext()\n\n  const page1 = await ctx1.newPage()\n  const page2 = await ctx2.newPage()\n\n  // Both load the same version\n  await page1.goto('/record/123')\n  await page2.goto('/record/123')\n\n  // User 1 edits and saves first\n  await page1.getByRole('button', {name: 'Edit'}).click()\n  await page1.getByLabel('Value').fill('Updated by User 1')\n  await page1.getByRole('button', {name: 'Save'}).click()\n  await expect(page1.getByText('Saved')).toBeVisible()\n\n  // User 2 tries to save with stale version\n  await page2.getByRole('button', {name: 'Edit'}).click()\n  await page2.getByLabel('Value').fill('Updated by User 2')\n  await page2.getByRole('button', {name: 'Save'}).click()\n\n  // Should fail with version conflict\n  await expect(page2.getByText('Someone else modified this')).toBeVisible()\n  await expect(page2.getByRole('button', {name: 'Reload'})).toBeVisible()\n\n  await ctx1.close()\n  await ctx2.close()\n})\n```\n\n## Chat & Messaging\n\n### Real-Time Chat\n\n```typescript\ntest('chat messages sync between users', async ({browser}) => {\n  const aliceCtx = await browser.newContext()\n  const bobCtx = await browser.newContext()\n\n  const alicePage = await aliceCtx.newPage()\n  const bobPage = await bobCtx.newPage()\n\n  // Setup user identities\n  await alicePage.route('**/api/me', (r) => r.fulfill({json: {name: 'Alice'}}))\n  await bobPage.route('**/api/me', (r) => r.fulfill({json: {name: 'Bob'}}))\n\n  await alicePage.goto('/chat/room-1')\n  await bobPage.goto('/chat/room-1')\n\n  // Alice sends message\n  await alicePage.getByLabel('Message').fill('Hi Bob!')\n  await alicePage.getByRole('button', {name: 'Send'}).click()\n\n  // Bob sees it\n  await expect(bobPage.getByText('Alice: Hi Bob!')).toBeVisible()\n\n  // Bob replies\n  await bobPage.getByLabel('Message').fill('Hey Alice!')\n  await bobPage.getByRole('button', {name: 'Send'}).click()\n\n  // Alice sees it\n  await expect(alicePage.getByText('Bob: Hey Alice!')).toBeVisible()\n\n  await aliceCtx.close()\n  await bobCtx.close()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                       | Solution                     |\n| ----------------------------- | ----------------------------- | ---------------------------- |\n| Sharing context between users | State leaks, not isolated     | Create separate contexts     |\n| Not closing contexts          | Memory leak, browser overload | Always close in cleanup      |\n| Hardcoded timing for sync     | Flaky tests                   | Use `expect().toBeVisible()` |\n| Testing only single user      | Misses collaboration bugs     | Test multi-user scenarios    |\n\n## Related References\n\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/network-advanced.md (Tokens: ~2623 | Category: anthropic-skill) -->\n# Advanced Network Interception\n\n## Table of Contents\n\n1. [Request Modification](#request-modification)\n2. [GraphQL Mocking](#graphql-mocking)\n3. [HAR Recording & Playback](#har-recording--playback)\n4. [Conditional Mocking](#conditional-mocking)\n5. [Network Throttling](#network-throttling)\n\n## Request Modification\n\n### Modify Request Headers\n\n```typescript\ntest('add auth header to requests', async ({page}) => {\n  await page.route('**/api/**', (route) => {\n    const headers = {\n      ...route.request().headers(),\n      'Authorization': 'Bearer test-token',\n      'X-Test-Header': 'test-value',\n    }\n    route.continue({headers})\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### Modify Request Body\n\n```typescript\ntest('modify POST body', async ({page}) => {\n  await page.route('**/api/orders', async (route) => {\n    if (route.request().method() === 'POST') {\n      const postData = route.request().postDataJSON()\n\n      // Add test metadata\n      const modifiedData = {\n        ...postData,\n        testMode: true,\n        testTimestamp: Date.now(),\n      }\n\n      await route.continue({\n        postData: JSON.stringify(modifiedData),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n})\n```\n\n### Transform Response\n\n```typescript\ntest('modify API response', async ({page}) => {\n  await page.route('**/api/products', async (route) => {\n    // Fetch real response\n    const response = await route.fetch()\n    const json = await response.json()\n\n    // Modify response\n    const modified = json.map((product: any) => ({\n      ...product,\n      price: product.price * 0.9, // 10% discount\n      testMode: true,\n    }))\n\n    await route.fulfill({\n      response,\n      json: modified,\n    })\n  })\n\n  await page.goto('/products')\n})\n```\n\n## GraphQL Mocking\n\n### Mock by Operation Name\n\n```typescript\ntest('mock GraphQL query', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'GetUser') {\n      return route.fulfill({\n        json: {\n          data: {\n            user: {\n              id: '1',\n              name: 'Test User',\n              email: 'test@example.com',\n            },\n          },\n        },\n      })\n    }\n\n    if (postData.operationName === 'GetProducts') {\n      return route.fulfill({\n        json: {\n          data: {\n            products: [\n              {id: '1', name: 'Product A', price: 29.99},\n              {id: '2', name: 'Product B', price: 49.99},\n            ],\n          },\n        },\n      })\n    }\n\n    // Pass through unmocked operations\n    return route.continue()\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n### GraphQL Mock Fixture\n\n```typescript\n// fixtures/graphql.fixture.ts\ntype GraphQLMock = {\n  operation: string\n  variables?: Record<string, any>\n  response: {data?: any; errors?: any[]}\n}\n\ntype GraphQLFixtures = {\n  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>\n}\n\nexport const test = base.extend<GraphQLFixtures>({\n  mockGraphQL: async ({page}, use) => {\n    await use(async (mocks) => {\n      await page.route('**/graphql', async (route) => {\n        const postData = route.request().postDataJSON()\n\n        const mock = mocks.find((m) => {\n          if (m.operation !== postData.operationName) return false\n\n          // Optionally match variables\n          if (m.variables) {\n            return JSON.stringify(m.variables) === JSON.stringify(postData.variables)\n          }\n          return true\n        })\n\n        if (mock) {\n          return route.fulfill({json: mock.response})\n        }\n\n        return route.continue()\n      })\n    })\n  },\n})\n\n// Usage\ntest('dashboard with mocked GraphQL', async ({page, mockGraphQL}) => {\n  await mockGraphQL([\n    {\n      operation: 'GetDashboardStats',\n      response: {\n        data: {stats: {users: 100, revenue: 50000}},\n      },\n    },\n    {\n      operation: 'GetUser',\n      variables: {id: '1'},\n      response: {\n        data: {user: {id: '1', name: 'John'}},\n      },\n    },\n  ])\n\n  await page.goto('/dashboard')\n  await expect(page.getByText('100 users')).toBeVisible()\n})\n```\n\n### Mock GraphQL Mutations\n\n```typescript\ntest('mock GraphQL mutation', async ({page}) => {\n  await page.route('**/graphql', async (route) => {\n    const postData = route.request().postDataJSON()\n\n    if (postData.operationName === 'CreateOrder') {\n      const {input} = postData.variables\n\n      return route.fulfill({\n        json: {\n          data: {\n            createOrder: {\n              id: 'order-123',\n              status: 'PENDING',\n              items: input.items,\n              total: input.items.reduce(\n                (sum: number, item: any) => sum + item.price * item.quantity,\n                0,\n              ),\n            },\n          },\n        },\n      })\n    }\n\n    return route.continue()\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  await expect(page.getByText('Order #order-123')).toBeVisible()\n})\n```\n\n## HAR Recording & Playback\n\n### Record HAR File\n\n```typescript\n// Record network traffic\ntest('record HAR', async ({page, context}) => {\n  // Start recording\n  await context.routeFromHAR('./recordings/checkout.har', {\n    update: true, // Create/update HAR file\n    url: '**/api/**',\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Place Order'}).click()\n\n  // HAR file is saved automatically\n})\n```\n\n### Playback HAR File\n\n```typescript\n// Use recorded HAR for offline testing\ntest('playback HAR', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/checkout.har', {\n    url: '**/api/**',\n    update: false, // Don't update, just playback\n  })\n\n  await page.goto('/checkout')\n\n  // All API calls served from HAR file\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### HAR with Fallback\n\n```typescript\ntest('HAR with live fallback', async ({page, context}) => {\n  await context.routeFromHAR('./recordings/api.har', {\n    url: '**/api/**',\n    update: false,\n    notFound: 'fallback', // Use real network if not in HAR\n  })\n\n  await page.goto('/dashboard')\n})\n```\n\n## Conditional Mocking\n\n### Mock Based on Request Body\n\n```typescript\ntest('conditional mock by body', async ({page}) => {\n  await page.route('**/api/search', async (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.query === 'error') {\n      return route.fulfill({\n        status: 500,\n        json: {error: 'Search failed'},\n      })\n    }\n\n    if (body.query === 'empty') {\n      return route.fulfill({\n        json: {results: []},\n      })\n    }\n\n    // Default response\n    return route.fulfill({\n      json: {\n        results: [{id: 1, title: `Result for: ${body.query}`}],\n      },\n    })\n  })\n\n  await page.goto('/search')\n\n  // Test different scenarios\n  await page.getByLabel('Search').fill('error')\n  await page.getByLabel('Search').press('Enter')\n  await expect(page.getByText('Search failed')).toBeVisible()\n})\n```\n\n### Mock Nth Request\n\n```typescript\ntest('different response on retry', async ({page}) => {\n  let callCount = 0\n\n  await page.route('**/api/status', (route) => {\n    callCount++\n\n    if (callCount < 3) {\n      return route.fulfill({\n        status: 503,\n        json: {error: 'Service unavailable'},\n      })\n    }\n\n    // Succeed on 3rd attempt\n    return route.fulfill({\n      json: {status: 'ok'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // App should retry and eventually succeed\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n### Mock with Delay\n\n```typescript\ntest('slow network simulation', async ({page}) => {\n  await page.route('**/api/data', async (route) => {\n    // Simulate 2 second delay\n    await new Promise((resolve) => setTimeout(resolve, 2000))\n\n    return route.fulfill({\n      json: {data: 'loaded'},\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Loading state should appear\n  await expect(page.getByText('Loading...')).toBeVisible()\n\n  // Then data appears\n  await expect(page.getByText('loaded')).toBeVisible()\n})\n```\n\n## Network Throttling\n\n### Slow 3G Simulation\n\n```typescript\ntest('slow network experience', async ({page, context}) => {\n  // Create CDP session for network throttling\n  const client = await context.newCDPSession(page)\n\n  await client.send('Network.emulateNetworkConditions', {\n    offline: false,\n    downloadThroughput: (500 * 1024) / 8, // 500 Kbps\n    uploadThroughput: (500 * 1024) / 8,\n    latency: 400, // 400ms\n  })\n\n  await page.goto('/')\n\n  // Test loading states appear\n  await expect(page.getByTestId('skeleton-loader')).toBeVisible()\n})\n```\n\n### Offline Mode\n\nUse `context.setOffline(true/false)` to simulate network connectivity changes.\n\n> **For comprehensive offline testing patterns:**\n>\n> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)\n> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)\n\n### Network Throttling Fixture\n\n```typescript\n// fixtures/network.fixture.ts\ntype NetworkCondition = 'slow3g' | 'fast3g' | 'offline'\n\nconst conditions = {\n  slow3g: {downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000},\n  fast3g: {downloadThroughput: 180000, uploadThroughput: 75000, latency: 150},\n}\n\ntype NetworkFixtures = {\n  setNetworkCondition: (condition: NetworkCondition) => Promise<void>\n}\n\nexport const test = base.extend<NetworkFixtures>({\n  setNetworkCondition: async ({page, context}, use) => {\n    const client = await context.newCDPSession(page)\n\n    await use(async (condition) => {\n      if (condition === 'offline') {\n        await context.setOffline(true)\n      } else {\n        await client.send('Network.emulateNetworkConditions', {\n          offline: false,\n          ...conditions[condition],\n        })\n      }\n    })\n\n    // Reset\n    await context.setOffline(false)\n  },\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern             | Problem                        | Solution                         |\n| ------------------------ | ------------------------------ | -------------------------------- |\n| Mocking all requests     | Tests don't reflect reality    | Mock only what's necessary       |\n| No cleanup of routes     | Routes persist across tests    | Use fixtures with cleanup        |\n| Ignoring request method  | Mock applies to wrong requests | Check `route.request().method()` |\n| Hardcoded mock responses | Brittle, hard to maintain      | Use factories for mock data      |\n\n## Related References\n\n- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking\n- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking\n\n<!-- File: .agents/skills/playwright-best-practices/advanced/third-party.md (Tokens: ~2955 | Category: anthropic-skill) -->\n# Third-Party Service Mocking\n\n## Table of Contents\n\n1. [OAuth/SSO Mocking](#oauthsso-mocking)\n2. [Payment Gateway Mocking](#payment-gateway-mocking)\n3. [Email Verification](#email-verification)\n4. [SMS Verification](#sms-verification)\n5. [Analytics & Tracking](#analytics--tracking)\n\n## OAuth/SSO Mocking\n\n### Mock Google OAuth\n\n```typescript\ntest('Google OAuth login', async ({page}) => {\n  // Mock the OAuth callback\n  await page.route('**/auth/google/callback**', (route) => {\n    const url = new URL(route.request().url())\n    // Simulate successful OAuth by redirecting with token\n    route.fulfill({\n      status: 302,\n      headers: {\n        Location: '/dashboard?token=mock-jwt-token',\n      },\n    })\n  })\n\n  // Mock the token verification endpoint\n  await page.route('**/api/auth/verify', (route) =>\n    route.fulfill({\n      json: {\n        valid: true,\n        user: {\n          id: '123',\n          email: 'test@gmail.com',\n          name: 'Test User',\n        },\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  await expect(page.getByText('Welcome, Test User')).toBeVisible()\n})\n```\n\n### OAuth Fixture\n\n```typescript\n// fixtures/oauth.fixture.ts\ntype OAuthProvider = 'google' | 'github' | 'microsoft'\n\ntype OAuthUser = {\n  id: string\n  email: string\n  name: string\n  avatar?: string\n}\n\ntype OAuthFixtures = {\n  mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>\n}\n\nexport const test = base.extend<OAuthFixtures>({\n  mockOAuth: async ({page}, use) => {\n    await use(async (provider, user) => {\n      // Mock callback redirect\n      await page.route(`**/auth/${provider}/callback**`, (route) =>\n        route.fulfill({\n          status: 302,\n          headers: {Location: `/auth/success?provider=${provider}`},\n        }),\n      )\n\n      // Mock session/user endpoint\n      await page.route('**/api/auth/session', (route) =>\n        route.fulfill({\n          json: {user, provider, authenticated: true},\n        }),\n      )\n\n      // Mock user info endpoint\n      await page.route('**/api/me', (route) => route.fulfill({json: user}))\n    })\n  },\n})\n\n// Usage\ntest('login with GitHub', async ({page, mockOAuth}) => {\n  await mockOAuth('github', {\n    id: 'gh-123',\n    email: 'dev@github.com',\n    name: 'GitHub User',\n  })\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with GitHub'}).click()\n\n  await expect(page.getByText('Welcome, GitHub User')).toBeVisible()\n})\n```\n\n### Mock SAML SSO\n\n```typescript\ntest('SAML SSO login', async ({page}) => {\n  // Mock SAML assertion consumer service\n  await page.route('**/saml/acs', async (route) => {\n    route.fulfill({\n      status: 302,\n      headers: {\n        'Location': '/dashboard',\n        'Set-Cookie': 'session=mock-saml-session; Path=/; HttpOnly',\n      },\n    })\n  })\n\n  // Mock session validation\n  await page.route('**/api/session', (route) =>\n    route.fulfill({\n      json: {\n        user: {email: 'user@company.com', name: 'SSO User'},\n        provider: 'saml',\n      },\n    }),\n  )\n\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'SSO Login'}).click()\n\n  await expect(page).toHaveURL('/dashboard')\n})\n```\n\n## Payment Gateway Mocking\n\n### Mock Stripe\n\n```typescript\ntest('Stripe checkout', async ({page}) => {\n  // Mock Stripe.js\n  await page.addInitScript(() => {\n    ;(window as any).Stripe = () => ({\n      elements: () => ({\n        create: () => ({\n          mount: () => {},\n          on: () => {},\n          destroy: () => {},\n        }),\n      }),\n      confirmCardPayment: async () => ({\n        paymentIntent: {status: 'succeeded', id: 'pi_mock_123'},\n      }),\n      createPaymentMethod: async () => ({\n        paymentMethod: {id: 'pm_mock_123'},\n      }),\n    })\n  })\n\n  // Mock backend payment endpoint\n  await page.route('**/api/create-payment-intent', (route) =>\n    route.fulfill({\n      json: {clientSecret: 'pi_mock_123_secret_mock'},\n    }),\n  )\n\n  await page.route('**/api/confirm-payment', (route) =>\n    route.fulfill({\n      json: {success: true, orderId: 'order-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay $99.99'}).click()\n\n  await expect(page.getByText('Payment successful')).toBeVisible()\n})\n```\n\n### Mock PayPal\n\n```typescript\ntest('PayPal checkout', async ({page}) => {\n  // Mock PayPal SDK\n  await page.addInitScript(() => {\n    ;(window as any).paypal = {\n      Buttons: () => ({\n        render: () => Promise.resolve(),\n        isEligible: () => true,\n      }),\n      FUNDING: {PAYPAL: 'paypal', CARD: 'card'},\n    }\n  })\n\n  // Mock PayPal order creation\n  await page.route('**/api/paypal/create-order', (route) =>\n    route.fulfill({\n      json: {orderId: 'PAYPAL-ORDER-123'},\n    }),\n  )\n\n  // Mock PayPal capture\n  await page.route('**/api/paypal/capture', (route) =>\n    route.fulfill({\n      json: {success: true, transactionId: 'TXN-123'},\n    }),\n  )\n\n  await page.goto('/checkout')\n\n  // Simulate PayPal approval callback\n  await page.evaluate(() => {\n    ;(window as any).onPayPalApprove?.({orderID: 'PAYPAL-ORDER-123'})\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Payment Fixture\n\n```typescript\n// fixtures/payment.fixture.ts\ntype PaymentFixtures = {\n  mockStripe: (options?: {failPayment?: boolean}) => Promise<void>\n}\n\nexport const test = base.extend<PaymentFixtures>({\n  mockStripe: async ({page}, use) => {\n    await use(async (options = {}) => {\n      await page.addInitScript(\n        ([shouldFail]) => {\n          ;(window as any).Stripe = () => ({\n            elements: () => ({\n              create: () => ({\n                mount: () => {},\n                on: (event: string, handler: Function) => {\n                  if (event === 'ready') setTimeout(handler, 100)\n                },\n                destroy: () => {},\n              }),\n            }),\n            confirmCardPayment: async () => {\n              if (shouldFail) {\n                return {error: {message: 'Card declined'}}\n              }\n              return {paymentIntent: {status: 'succeeded'}}\n            },\n          })\n        },\n        [options.failPayment],\n      )\n    })\n  },\n})\n\n// Usage\ntest('handles declined card', async ({page, mockStripe}) => {\n  await mockStripe({failPayment: true})\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Pay'}).click()\n\n  await expect(page.getByText('Card declined')).toBeVisible()\n})\n```\n\n## Email Verification\n\n### Mock Email API\n\n```typescript\ntest('email verification flow', async ({page, request}) => {\n  let verificationToken: string\n\n  // Capture the verification email\n  await page.route('**/api/send-verification', async (route) => {\n    const body = route.request().postDataJSON()\n    verificationToken = `mock-token-${Date.now()}`\n\n    // Don't actually send email, just store token\n    route.fulfill({\n      json: {sent: true, messageId: 'msg-123'},\n    })\n  })\n\n  // Mock token verification\n  await page.route('**/api/verify-email**', (route) => {\n    const url = new URL(route.request().url())\n    const token = url.searchParams.get('token')\n\n    if (token === verificationToken) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid token'}})\n    }\n  })\n\n  await page.goto('/signup')\n  await page.getByLabel('Email').fill('test@example.com')\n  await page.getByRole('button', {name: 'Sign Up'}).click()\n\n  await expect(page.getByText('Check your email')).toBeVisible()\n\n  // Simulate clicking email link\n  await page.goto(`/verify?token=${verificationToken}`)\n\n  await expect(page.getByText('Email verified')).toBeVisible()\n})\n```\n\n### Use Mailinator/Temp Mail\n\n```typescript\n// fixtures/email.fixture.ts\ntype EmailFixtures = {\n  getVerificationEmail: (inbox: string) => Promise<{link: string}>\n}\n\nexport const test = base.extend<EmailFixtures>({\n  getVerificationEmail: async ({request}, use) => {\n    await use(async (inbox) => {\n      // Poll Mailinator API for new email\n      const response = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const messages = await response.json()\n      const latest = messages.msgs[0]\n\n      // Get full message\n      const msgResponse = await request.get(\n        `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,\n        {\n          headers: {\n            Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,\n          },\n        },\n      )\n\n      const message = await msgResponse.json()\n\n      // Extract verification link from HTML\n      const linkMatch = message.parts[0].body.match(/href=\"([^\"]*verify[^\"]*)\"/)\n      return {link: linkMatch?.[1] || ''}\n    })\n  },\n})\n```\n\n## SMS Verification\n\n### Mock SMS API\n\n```typescript\ntest('SMS verification', async ({page}) => {\n  let smsCode: string\n\n  // Capture SMS send\n  await page.route('**/api/send-sms', (route) => {\n    smsCode = Math.random().toString().slice(2, 8) // 6-digit code\n\n    route.fulfill({\n      json: {sent: true, messageId: 'sms-123'},\n    })\n  })\n\n  // Mock code verification\n  await page.route('**/api/verify-sms', (route) => {\n    const body = route.request().postDataJSON()\n\n    if (body.code === smsCode) {\n      route.fulfill({json: {verified: true}})\n    } else {\n      route.fulfill({status: 400, json: {error: 'Invalid code'}})\n    }\n  })\n\n  await page.goto('/verify-phone')\n  await page.getByLabel('Phone').fill('+1234567890')\n  await page.getByRole('button', {name: 'Send Code'}).click()\n\n  // Enter the code\n  await page.getByLabel('Verification Code').fill(smsCode)\n  await page.getByRole('button', {name: 'Verify'}).click()\n\n  await expect(page.getByText('Phone verified')).toBeVisible()\n})\n```\n\n## Analytics & Tracking\n\n### Block Analytics in Tests\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  // Block all analytics/tracking\n  await page.route(\n    /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,\n    (route) => route.abort(),\n  )\n})\n```\n\n### Mock Analytics for Verification\n\n```typescript\ntest('tracks purchase event', async ({page}) => {\n  const analyticsEvents: any[] = []\n\n  // Capture analytics calls\n  await page.route('**/api/analytics/**', (route) => {\n    analyticsEvents.push(route.request().postDataJSON())\n    route.fulfill({status: 200})\n  })\n\n  // Mock analytics SDK\n  await page.addInitScript(() => {\n    ;(window as any).analytics = {\n      track: (event: string, props: any) => {\n        fetch('/api/analytics/track', {\n          method: 'POST',\n          body: JSON.stringify({event, props}),\n        })\n      },\n    }\n  })\n\n  await page.goto('/checkout')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n\n  // Verify analytics event was sent\n  expect(analyticsEvents).toContainEqual(\n    expect.objectContaining({\n      event: 'Purchase Completed',\n      props: expect.objectContaining({amount: expect.any(Number)}),\n    }),\n  )\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern              | Problem                        | Solution                |\n| ------------------------- | ------------------------------ | ----------------------- |\n| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints    |\n| Real payment processing   | Charges real money, slow       | Use test mode or mock   |\n| Waiting for real emails   | Very slow, unreliable          | Mock email API          |\n| Not mocking analytics     | Pollutes analytics data        | Block or mock analytics |\n\n## Related References\n\n- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns\n- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns\n\n<!-- File: .agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md (Tokens: ~2696 | Category: anthropic-skill) -->\n# Organizing Reusable Test Code\n\n## Table of Contents\n\n1. [Pattern Comparison](#pattern-comparison)\n2. [Selection Flowchart](#selection-flowchart)\n3. [Page Objects](#page-objects)\n4. [Custom Fixtures](#custom-fixtures)\n5. [Helper Functions](#helper-functions)\n6. [Combined Project Structure](#combined-project-structure)\n7. [Anti-Patterns](#anti-patterns)\n\nUse all three patterns together. Most projects benefit from a hybrid approach:\n\n- **Page objects** for UI interaction (pages/components with 5+ interactions)\n- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)\n- **Helper functions** for stateless utilities (generate data, format values, simple waits)\n\nIf only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.\n\n## Pattern Comparison\n\n| Aspect            | Page Objects                            | Custom Fixtures                            | Helper Functions                  |\n| ----------------- | --------------------------------------- | ------------------------------------------ | --------------------------------- |\n| **Purpose**       | Encapsulate UI interactions             | Provide resources with setup/teardown      | Stateless utilities               |\n| **Lifecycle**     | Manual (constructor/methods)            | Built-in (`use()` with automatic teardown) | None                              |\n| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures                   | Call other functions              |\n| **Best for**      | Pages with many reused interactions     | Resources needing setup AND teardown       | Simple logic with no side effects |\n\n## Selection Flowchart\n\n```text\nWhat kind of reusable code?\n|\n+-- Interacts with browser page/component?\n|   |\n|   +-- Has 5+ interactions (fill, click, navigate, assert)?\n|   |   +-- YES: Used in 3+ test files?\n|   |   |   +-- YES --> PAGE OBJECT\n|   |   |   +-- NO --> Inline or small helper\n|   |   +-- NO --> HELPER FUNCTION\n|   |\n|   +-- Needs setup before AND cleanup after test?\n|       +-- YES --> CUSTOM FIXTURE\n|       +-- NO --> PAGE OBJECT method or HELPER\n|\n+-- Manages resource with lifecycle (create/destroy)?\n|   +-- Examples: auth state, DB connection, API client, test user\n|   +-- YES --> CUSTOM FIXTURE (always)\n|\n+-- Stateless utility? (no browser, no side effects)\n|   +-- Examples: random email, format date, build URL, parse response\n|   +-- YES --> HELPER FUNCTION\n|\n+-- Not sure?\n    +-- Start with HELPER FUNCTION\n    +-- Promote to PAGE OBJECT when interactions grow\n    +-- Promote to FIXTURE when lifecycle needed\n```\n\n## Page Objects\n\nBest for pages/components with 5+ interactions appearing in 3+ test files.\n\n```typescript\n// page-objects/booking.page.ts\nimport {type Page, type Locator, expect} from '@playwright/test'\n\nexport class BookingPage {\n  readonly page: Page\n  readonly dateField: Locator\n  readonly guestCount: Locator\n  readonly roomType: Locator\n  readonly reserveBtn: Locator\n  readonly totalPrice: Locator\n\n  constructor(page: Page) {\n    this.page = page\n    this.dateField = page.getByLabel('Check-in date')\n    this.guestCount = page.getByLabel('Guests')\n    this.roomType = page.getByLabel('Room type')\n    this.reserveBtn = page.getByRole('button', {name: 'Reserve'})\n    this.totalPrice = page.getByTestId('total-price')\n  }\n\n  async goto() {\n    await this.page.goto('/booking')\n  }\n\n  async fillDetails(opts: {date: string; guests: number; room: string}) {\n    await this.dateField.fill(opts.date)\n    await this.guestCount.fill(String(opts.guests))\n    await this.roomType.selectOption(opts.room)\n  }\n\n  async reserve() {\n    await this.reserveBtn.click()\n    await this.page.waitForURL('**/confirmation')\n  }\n\n  async expectPrice(amount: string) {\n    await expect(this.totalPrice).toHaveText(amount)\n  }\n}\n```\n\n```typescript\n// tests/booking/reservation.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {BookingPage} from '../page-objects/booking.page'\n\ntest('complete reservation with standard room', async ({page}) => {\n  const booking = new BookingPage(page)\n  await booking.goto()\n  await booking.fillDetails({date: '2026-03-15', guests: 2, room: 'standard'})\n  await booking.reserve()\n  await expect(page.getByText('Reservation confirmed')).toBeVisible()\n})\n```\n\n**Page object principles:**\n\n- One class per logical page/component, not per URL\n- Constructor takes `Page`\n- Locators as `readonly` properties in constructor\n- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks\n- Navigation methods (`goto`) belong on the page object\n\n## Custom Fixtures\n\nBest for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.\n\n```typescript\n// fixtures/base.fixture.ts\nimport {test as base, expect} from '@playwright/test'\n\nimport {generateMember} from '../helpers/data'\nimport {BookingPage} from '../page-objects/booking.page'\n\ntype Fixtures = {\n  bookingPage: BookingPage\n  member: {email: string; password: string; id: string}\n  loggedInPage: import('@playwright/test').Page\n}\n\nexport const test = base.extend<Fixtures>({\n  bookingPage: async ({page}, use) => {\n    await use(new BookingPage(page))\n  },\n\n  member: async ({request}, use) => {\n    const data = generateMember()\n    const res = await request.post('/api/test/members', {data})\n    const member = await res.json()\n    await use(member)\n    await request.delete(`/api/test/members/${member.id}`)\n  },\n\n  loggedInPage: async ({page, member}, use) => {\n    await page.goto('/login')\n    await page.getByLabel('Email').fill(member.email)\n    await page.getByLabel('Password').fill(member.password)\n    await page.getByRole('button', {name: 'Sign in'}).click()\n    await expect(page).toHaveURL('/dashboard')\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/dashboard/overview.spec.ts\nimport {test, expect} from '../../fixtures/base.fixture'\n\ntest('member sees dashboard widgets', async ({loggedInPage}) => {\n  await expect(loggedInPage.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n  await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible()\n})\n\ntest('new member sees welcome prompt', async ({loggedInPage, member}) => {\n  await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible()\n})\n```\n\n**Fixture principles:**\n\n- Use `test.extend()` — never module-level variables\n- `use()` callback separates setup from teardown\n- Teardown runs even if test fails\n- Fixtures compose: one can depend on another\n- Fixtures are lazy: created only when requested\n- Wrap page objects in fixtures for lifecycle management\n\n## Helper Functions\n\nBest for stateless utilities — generating test data, formatting values, building URLs, parsing responses.\n\n```typescript\n// helpers/data.ts\nimport {randomUUID} from 'node:crypto'\n\nexport function generateEmail(prefix = 'user'): string {\n  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`\n}\n\nexport function generateMember(overrides: Partial<Member> = {}): Member {\n  return {\n    email: generateEmail(),\n    password: 'SecurePass456!',\n    name: 'Test Member',\n    ...overrides,\n  }\n}\n\ninterface Member {\n  email: string\n  password: string\n  name: string\n}\n\nexport function formatPrice(cents: number): string {\n  return `$${(cents / 100).toFixed(2)}`\n}\n```\n\n```typescript\n// helpers/assertions.ts\nimport {type Page, expect} from '@playwright/test'\n\nexport async function expectNotification(page: Page, message: string): Promise<void> {\n  const notification = page.getByRole('alert').filter({hasText: message})\n  await expect(notification).toBeVisible()\n  await expect(notification).toBeHidden({timeout: 10000})\n}\n```\n\n```typescript\n// tests/settings/account.spec.ts\nimport {test, expect} from '@playwright/test'\n\nimport {expectNotification} from '../../helpers/assertions'\nimport {generateEmail} from '../../helpers/data'\n\ntest('update account email', async ({page}) => {\n  const newEmail = generateEmail('updated')\n  await page.goto('/settings/account')\n  await page.getByLabel('Email').fill(newEmail)\n  await page.getByRole('button', {name: 'Save'}).click()\n  await expectNotification(page, 'Account updated')\n  await expect(page.getByLabel('Email')).toHaveValue(newEmail)\n})\n```\n\n**Helper principles:**\n\n- Pure functions with no side effects\n- No browser state — take `page` as parameter if needed\n- Promote to fixture if setup/teardown needed\n- Promote to page object if many page interactions grow\n- Keep small and focused\n\n## Combined Project Structure\n\n```text\ntests/\n+-- fixtures/\n|   +-- auth.fixture.ts\n|   +-- db.fixture.ts\n|   +-- base.fixture.ts\n+-- page-objects/\n|   +-- login.page.ts\n|   +-- booking.page.ts\n|   +-- components/\n|       +-- data-table.component.ts\n+-- helpers/\n|   +-- data.ts\n|   +-- assertions.ts\n+-- e2e/\n|   +-- auth/\n|   |   +-- login.spec.ts\n|   +-- booking/\n|       +-- reservation.spec.ts\nplaywright.config.ts\n```\n\n**Layer responsibilities:**\n\n| Layer            | Pattern         | Responsibility                                      |\n| ---------------- | --------------- | --------------------------------------------------- |\n| **Test file**    | `test()`        | Describes behavior, orchestrates layers             |\n| **Fixtures**     | `test.extend()` | Resource lifecycle — setup, provide, teardown       |\n| **Page objects** | Classes         | UI interaction — navigation, actions, locators      |\n| **Helpers**      | Functions       | Utilities — data generation, formatting, assertions |\n\n## Anti-Patterns\n\n### Page object managing resources\n\n```typescript\n// BAD: page object handling API calls and database\nclass LoginPage {\n  async createUser() {\n    /* API call */\n  }\n  async deleteUser() {\n    /* API call */\n  }\n  async signIn(email: string, password: string) {\n    /* UI */\n  }\n}\n```\n\nResource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.\n\n### Locator-only page objects\n\n```typescript\n// BAD: no methods, just locators\nclass LoginPage {\n  emailInput = this.page.getByLabel('Email')\n  passwordInput = this.page.getByLabel('Password')\n  submitBtn = this.page.getByRole('button', {name: 'Sign in'})\n  constructor(private page: Page) {}\n}\n```\n\nAdd intent-revealing methods or skip the page object entirely.\n\n### Monolithic fixtures\n\n```typescript\n// BAD: one fixture doing everything\ntest.extend({\n  everything: async ({page, request}, use) => {\n    const user = await createUser(request)\n    const products = await seedProducts(request, 50)\n    await setupPayment(request, user.id)\n    await page.goto('/dashboard')\n    await use({user, products, page})\n    // massive teardown...\n  },\n})\n```\n\nBreak into small, composable fixtures. Each fixture does one thing.\n\n### Helpers with side effects\n\n```typescript\n// BAD: module-level state\nlet createdUserId: string\n\nexport async function createTestUser(request: APIRequestContext) {\n  const res = await request.post('/api/users', {data: {email: 'test@example.com'}})\n  const user = await res.json()\n  createdUserId = user.id // shared across tests!\n  return user\n}\n```\n\nModule-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.\n\n### Over-abstracting simple operations\n\n```typescript\n// BAD: helper for one-liner\nexport async function clickButton(page: Page, name: string) {\n  await page.getByRole('button', {name}).click()\n}\n```\n\nOnly abstract when there is real duplication (3+ usages) or complexity (5+ interactions).\n\n<!-- File: .agents/skills/playwright-best-practices/architecture/test-architecture.md (Tokens: ~3232 | Category: anthropic-skill) -->\n# Choosing Test Types: E2E, Component, or API\n\n## Table of Contents\n\n1. [Decision Matrix](#decision-matrix)\n2. [API Tests](#api-tests)\n3. [Component Tests](#component-tests)\n4. [E2E Tests](#e2e-tests)\n5. [Layering Test Types](#layering-test-types)\n6. [Common Mistakes](#common-mistakes)\n7. [Related](#related)\n\n> **When to use**: Deciding which test type to write for a feature. Ask: \"What's the cheapest test that gives confidence this works?\"\n\n## Decision Matrix\n\n| Scenario                    | Recommended Type | Rationale                                     |\n| --------------------------- | ---------------- | --------------------------------------------- |\n| Login / auth flow           | E2E              | Cross-page, cookies, redirects, session state |\n| Form submission             | Component        | Isolated validation logic, error states       |\n| CRUD operations             | API              | Data integrity matters more than UI           |\n| Search with results UI      | Component + API  | API for query logic; component for rendering  |\n| Cross-page navigation       | E2E              | Routing, history, deep linking                |\n| API error handling          | API              | Status codes, error shapes, edge cases        |\n| UI error feedback           | Component        | Toast, banner, inline error rendering         |\n| Accessibility               | Component        | ARIA roles, keyboard nav per-component        |\n| Responsive layout           | Component        | Viewport-specific rendering without full app  |\n| API contract validation     | API              | Response shapes, headers, auth                |\n| WebSocket/real-time         | E2E              | Requires full browser environment             |\n| Payment / checkout          | E2E              | Multi-step, third-party iframes               |\n| Onboarding wizard           | E2E              | Multi-step, state persists across pages       |\n| Widget behavior             | Component        | Toggle, accordion, date picker, modal         |\n| Permissions / authorization | API              | Role-based access is backend logic            |\n\n## API Tests\n\n**Ideal for**:\n\n- CRUD operations (create, read, update, delete)\n- Input validation and error responses (400, 422)\n- Permission and authorization checks\n- Data integrity and business rules\n- API contract verification\n- Edge cases expensive to reproduce through UI\n- Test data setup/teardown for E2E tests\n\n**Avoid for**:\n\n- Testing how errors display to users\n- Browser-specific behavior (cookies, redirects)\n- Visual layout or responsive design\n- Flows requiring JavaScript execution or DOM interaction\n- Third-party iframe interactions\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('Products API', () => {\n  let token: string\n\n  test.beforeAll(async ({request}) => {\n    const res = await request.post('/api/auth/token', {\n      data: {email: 'manager@shop.io', password: 'mgr-secret'},\n    })\n    token = (await res.json()).accessToken\n  })\n\n  test('creates product with valid payload', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Widget Pro', sku: 'WGT-100', price: 29.99},\n    })\n\n    expect(res.status()).toBe(201)\n    const product = await res.json()\n    expect(product).toMatchObject({name: 'Widget Pro', sku: 'WGT-100'})\n    expect(product).toHaveProperty('id')\n  })\n\n  test('rejects duplicate SKU with 409', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Duplicate', sku: 'WGT-100', price: 19.99},\n    })\n\n    expect(res.status()).toBe(409)\n    expect((await res.json()).message).toContain('already exists')\n  })\n\n  test('returns 422 for missing required fields', async ({request}) => {\n    const res = await request.post('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      data: {name: 'Incomplete'},\n    })\n\n    expect(res.status()).toBe(422)\n    const err = await res.json()\n    expect(err.errors).toContainEqual(expect.objectContaining({field: 'sku'}))\n  })\n\n  test('staff role cannot delete products', async ({request}) => {\n    const staffLogin = await request.post('/api/auth/token', {\n      data: {email: 'staff@shop.io', password: 'staff-pass'},\n    })\n    const staffToken = (await staffLogin.json()).accessToken\n\n    const res = await request.delete('/api/products/123', {\n      headers: {Authorization: `Bearer ${staffToken}`},\n    })\n\n    expect(res.status()).toBe(403)\n  })\n\n  test('lists products with pagination', async ({request}) => {\n    const res = await request.get('/api/products', {\n      headers: {Authorization: `Bearer ${token}`},\n      params: {page: '1', limit: '20'},\n    })\n\n    expect(res.status()).toBe(200)\n    const body = await res.json()\n    expect(body.items).toBeInstanceOf(Array)\n    expect(body.items.length).toBeLessThanOrEqual(20)\n    expect(body).toHaveProperty('totalCount')\n  })\n})\n```\n\n## Component Tests\n\n**Ideal for**:\n\n- Form validation (required fields, format rules, error messages)\n- Interactive widgets (modals, dropdowns, accordions, date pickers)\n- Conditional rendering (show/hide, loading states, empty states)\n- Accessibility per-component (ARIA attributes, keyboard navigation)\n- Responsive layout at different viewports\n- Visual states (hover, focus, disabled, selected)\n\n**Avoid for**:\n\n- Testing routing or navigation between pages\n- Flows requiring real cookies, sessions, or server-side state\n- Data persistence or API contract validation\n- Third-party iframe interactions\n- Anything requiring multiple pages or browser contexts\n\n```typescript\nimport { test, expect } from \"@playwright/experimental-ct-react\";\nimport { ContactForm } from \"../src/components/ContactForm\";\n\ntest.describe(\"ContactForm component\", () => {\n  test(\"displays validation errors on empty submit\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Name is required\")).toBeVisible();\n    await expect(component.getByText(\"Email is required\")).toBeVisible();\n  });\n\n  test(\"rejects malformed email\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"invalid-email\");\n    await component.getByLabel(\"Message\").fill(\"Hello\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    await expect(component.getByText(\"Enter a valid email\")).toBeVisible();\n  });\n\n  test(\"invokes onSubmit with form data\", async ({ mount }) => {\n    const submissions: Array<{ name: string; email: string; message: string }> =\n      [];\n    const component = await mount(\n      <ContactForm onSubmit={(data) => submissions.push(data)} />\n    );\n\n    await component.getByLabel(\"Name\").fill(\"Alex\");\n    await component.getByLabel(\"Email\").fill(\"alex@company.org\");\n    await component.getByLabel(\"Message\").fill(\"Inquiry about pricing\");\n    await component.getByRole(\"button\", { name: \"Send message\" }).click();\n\n    expect(submissions).toHaveLength(1);\n    expect(submissions[0]).toEqual({\n      name: \"Alex\",\n      email: \"alex@company.org\",\n      message: \"Inquiry about pricing\",\n    });\n  });\n\n  test(\"disables button during submission\", async ({ mount }) => {\n    const component = await mount(\n      <ContactForm onSubmit={() => {}} submitting={true} />\n    );\n\n    await expect(\n      component.getByRole(\"button\", { name: \"Sending...\" })\n    ).toBeDisabled();\n  });\n\n  test(\"associates labels with inputs for accessibility\", async ({ mount }) => {\n    const component = await mount(<ContactForm onSubmit={() => {}} />);\n\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Name\" })\n    ).toBeVisible();\n    await expect(\n      component.getByRole(\"textbox\", { name: \"Email\" })\n    ).toBeVisible();\n  });\n});\n```\n\n## E2E Tests\n\n**Ideal for**:\n\n- Critical user flows that generate revenue (checkout, signup)\n- Authentication flows (login, SSO, MFA, password reset)\n- Multi-page workflows where state carries across navigation\n- Flows involving third-party iframes (payment widgets)\n- Smoke tests validating the entire stack\n- Real-time collaboration requiring multiple browser contexts\n\n**Avoid for**:\n\n- Testing every form validation permutation\n- CRUD operations where UI is a thin wrapper\n- Verifying individual component states\n- Testing API response shapes or error codes\n- Responsive layout at every breakpoint\n- Edge cases that only affect the backend\n\n```typescript\nimport {test, expect} from '@playwright/test'\n\ntest.describe('subscription flow', () => {\n  test.beforeEach(async ({page}) => {\n    await page.request.post('/api/test/seed-account', {\n      data: {plan: 'free', email: 'subscriber@demo.io'},\n    })\n    await page.goto('/account/upgrade')\n  })\n\n  test('upgrades to premium plan', async ({page}) => {\n    await test.step('select plan', async () => {\n      await expect(page.getByRole('heading', {name: 'Choose Your Plan'})).toBeVisible()\n      await page.getByRole('button', {name: 'Select Premium'}).click()\n    })\n\n    await test.step('enter billing details', async () => {\n      await page.getByLabel('Cardholder name').fill('Sam Johnson')\n      await page.getByLabel('Billing address').fill('456 Oak Ave')\n      await page.getByLabel('City').fill('Seattle')\n      await page.getByRole('combobox', {name: 'State'}).selectOption('WA')\n      await page.getByLabel('Postal code').fill('98101')\n      await page.getByRole('button', {name: 'Continue'}).click()\n    })\n\n    await test.step('complete payment', async () => {\n      const paymentFrame = page.frameLocator('iframe[title=\"Secure Payment\"]')\n      await paymentFrame.getByLabel('Card number').fill('5555555555554444')\n      await paymentFrame.getByLabel('Expiry').fill('09/29')\n      await paymentFrame.getByLabel('CVV').fill('456')\n      await page.getByRole('button', {name: 'Subscribe now'}).click()\n    })\n\n    await test.step('verify success', async () => {\n      await page.waitForURL('**/account/subscription/success**')\n      await expect(page.getByRole('heading', {name: 'Welcome to Premium'})).toBeVisible()\n      await expect(page.getByText(/Subscription #\\d+/)).toBeVisible()\n    })\n  })\n})\n```\n\n## Layering Test Types\n\nEffective test suites combine all three types. Example for an \"inventory management\" feature:\n\n### API Layer (60% of tests)\n\nCover every backend logic permutation. Cheap to run and maintain.\n\n```\ntests/api/inventory.spec.ts\n  - creates item with valid data (201)\n  - rejects duplicate SKU (409)\n  - rejects invalid quantity format (422)\n  - rejects missing required fields (422)\n  - warehouse-staff cannot delete items (403)\n  - unauthenticated request returns 401\n  - lists items with pagination\n  - filters items by category\n  - updates item stock level\n  - archives an item\n  - prevents archiving items with pending orders\n```\n\n### Component Layer (30% of tests)\n\nCover every visual state and interaction.\n\n```\ntests/components/InventoryForm.spec.tsx\n  - shows validation errors on empty submit\n  - shows inline error for invalid SKU format\n  - disables submit while saving\n  - calls onSubmit with form data\n  - resets form after successful save\n\ntests/components/InventoryTable.spec.tsx\n  - renders item rows from props\n  - shows empty state when no items\n  - handles archive confirmation modal\n  - sorts by column header click\n  - shows stock level badges with correct colors\n```\n\n### E2E Layer (10% of tests)\n\nCover only critical paths proving full stack works.\n\n```\ntests/e2e/inventory.spec.ts\n  - manager creates item and sees it in list\n  - manager updates item stock level\n  - warehouse-staff cannot access admin settings\n```\n\n### Execution Profile\n\nFor this feature:\n\n- **11 API tests** — ~2 seconds total, no browser\n- **10 component tests** — ~5 seconds total, real browser but no server\n- **3 E2E tests** — ~15 seconds total, full stack\n\nTotal: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).\n\n## Common Mistakes\n\n| Anti-Pattern                              | Problem                                                  | Better Approach                                                |\n| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |\n| E2E for every validation rule             | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display  |\n| No API tests, all E2E                     | Slow suite, flaky from UI timing, hard to diagnose       | API tests for data/logic, E2E for critical paths only          |\n| Component tests mocking everything        | Tests pass but app broken because mocks drift            | Mock only external boundaries; API tests verify real contracts |\n| Same assertion in API, component, AND E2E | Triple maintenance cost                                  | Each layer tests what it uniquely verifies                     |\n| E2E creating test data via UI             | 2-minute test where 90 seconds is setup                  | Seed via API in `beforeEach`, test actual flow                 |\n| Testing third-party behavior              | Testing that Stripe validates cards (Stripe's job)       | Mock Stripe; trust their contract                              |\n| Skipping API layer                        | Can't tell if bug is frontend or backend                 | API tests isolate backend; component tests isolate frontend    |\n| One giant E2E for entire feature          | 5-minute test failing somewhere with no clear cause      | Focused E2E per critical path; use `test.step()`               |\n\n## Related\n\n- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming\n- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing\n- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests\n- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`\n- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services\n- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic\n\n<!-- File: .agents/skills/playwright-best-practices/architecture/when-to-mock.md (Tokens: ~2757 | Category: anthropic-skill) -->\n# Mocking Strategy: Real vs Mock Services\n\n## Table of Contents\n\n1. [Core Principle](#core-principle)\n2. [Decision Matrix](#decision-matrix)\n3. [Decision Flowchart](#decision-flowchart)\n4. [Mocking Techniques](#mocking-techniques)\n5. [Real Service Strategies](#real-service-strategies)\n6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)\n7. [Validating Mock Accuracy](#validating-mock-accuracy)\n8. [Anti-Patterns](#anti-patterns)\n\n> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.\n\n## Core Principle\n\n**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.\n\n## Decision Matrix\n\n| Scenario                          | Mock?                    | Strategy                                        |\n| --------------------------------- | ------------------------ | ----------------------------------------------- |\n| Your own REST/GraphQL API         | Never                    | Hit real API against staging or local dev       |\n| Your database (through your API)  | Never                    | Seed via API or fixtures                        |\n| Authentication (your auth system) | Mostly no                | Use `storageState` to skip login in most tests  |\n| Stripe / payment gateway          | Always                   | `route.fulfill()` with expected responses       |\n| SendGrid / email service          | Always                   | Mock the API call, verify request payload       |\n| OAuth providers (Google, GitHub)  | Always                   | Mock token exchange, test your callback handler |\n| Analytics (Segment, Mixpanel)     | Always                   | `route.abort()` or `route.fulfill()`            |\n| Maps / geocoding APIs             | Always                   | Mock with static responses                      |\n| Feature flags (LaunchDarkly)      | Usually                  | Mock to force specific flag states              |\n| CDN / static assets               | Never                    | Let them load normally                          |\n| Flaky external dependency         | CI: mock, local: real    | Conditional mocking based on environment        |\n| Slow external dependency          | Dev: mock, nightly: real | Separate test projects in config                |\n\n## Decision Flowchart\n\n```text\nIs this service part of YOUR codebase?\n├── YES → Do NOT mock. Test the real integration.\n│   ├── Is it slow? → Optimize the service, not the test.\n│   └── Is it flaky? → Fix the service. Flaky infra is a bug.\n└── NO → It's a third-party service.\n    ├── Is it paid per call? → ALWAYS mock.\n    ├── Is it rate-limited? → ALWAYS mock.\n    ├── Is it slow or unreliable? → ALWAYS mock.\n    └── Is it a complex multi-step flow? → Mock with HAR recording.\n```\n\n## Mocking Techniques\n\n### Blocking Unwanted Requests\n\nBlock third-party scripts that slow tests and add no coverage:\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {\n    route.abort()\n  })\n})\n\ntest('dashboard renders without tracking scripts', async ({page}) => {\n  await page.goto('/dashboard')\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n})\n```\n\n### Full Mock (route.fulfill)\n\nCompletely replace a third-party API response:\n\n```typescript\ntest('order flow with mocked payment service', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        transactionId: 'txn_mock_abc',\n        status: 'completed',\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n\ntest('display error on payment decline', async ({page}) => {\n  await page.route('**/api/charge', (route) => {\n    route.fulfill({\n      status: 402,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        error: {code: 'insufficient_funds', message: 'Card declined.'},\n      }),\n    })\n  })\n\n  await page.goto('/order/confirm')\n  await page.getByRole('button', {name: 'Complete Purchase'}).click()\n  await expect(page.getByRole('alert')).toContainText('Card declined')\n})\n```\n\n### Partial Mock (Modify Responses)\n\nLet the real API call happen but tweak the response:\n\n```typescript\ntest('display low inventory warning', async ({page}) => {\n  await page.route('**/api/inventory/*', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.quantity = 1\n    data.lowStock = true\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/products/widget-pro')\n  await expect(page.getByText('Only 1 remaining')).toBeVisible()\n})\n\ntest('inject test notification into real response', async ({page}) => {\n  await page.route('**/api/alerts', async (route) => {\n    const response = await route.fetch()\n    const data = await response.json()\n\n    data.items.push({\n      id: 'test-alert',\n      text: 'Report generated',\n      category: 'info',\n    })\n\n    await route.fulfill({\n      response,\n      body: JSON.stringify(data),\n    })\n  })\n\n  await page.goto('/home')\n  await expect(page.getByText('Report generated')).toBeVisible()\n})\n```\n\n### Record and Replay (HAR Files)\n\nFor complex API sequences (OAuth flows, multi-step wizards):\n\n**Recording:**\n\n```typescript\ntest('capture API traffic for admin panel', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: true,\n  })\n\n  await page.goto('/admin')\n  await page.getByRole('tab', {name: 'Reports'}).click()\n  await page.getByRole('tab', {name: 'Settings'}).click()\n})\n```\n\n**Replaying:**\n\n```typescript\ntest('admin panel loads with recorded data', async ({page}) => {\n  await page.routeFromHAR('tests/fixtures/admin-panel.har', {\n    url: '**/api/**',\n    update: false,\n  })\n\n  await page.goto('/admin')\n  await expect(page.getByRole('heading', {name: 'Reports'})).toBeVisible()\n})\n```\n\n**HAR maintenance:**\n\n- Record against a known-good staging environment\n- Commit `.har` files to version control\n- Re-record when APIs change\n- Scope HAR to specific URL patterns\n\n## Real Service Strategies\n\n### Local Dev Server\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 30_000,\n  },\n  use: {\n    baseURL: 'http://localhost:3000',\n  },\n})\n```\n\n### Staging Environment\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  use: {\n    baseURL: process.env.CI ? 'https://staging.example.com' : 'http://localhost:3000',\n  },\n})\n```\n\n### Test Containers\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  webServer: {\n    command: 'docker compose -f docker-compose.test.yml up --wait',\n    url: 'http://localhost:3000/health',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n  globalTeardown: './tests/global-teardown.ts',\n})\n```\n\n```typescript\n// tests/global-teardown.ts\nimport {execSync} from 'child_process'\n\nexport default function globalTeardown() {\n  if (process.env.CI) {\n    execSync('docker compose -f docker-compose.test.yml down -v')\n  }\n}\n```\n\n## Hybrid Approach: Fixture-Based Mock Control\n\nCreate fixtures that let individual tests opt into mocking specific services:\n\n```typescript\n// tests/fixtures/service-mocks.ts\nimport {test as base} from '@playwright/test'\n\ntype MockConfig = {\n  mockPayments: boolean\n  mockNotifications: boolean\n  mockAnalytics: boolean\n}\n\nexport const test = base.extend<MockConfig>({\n  mockPayments: [true, {option: true}],\n  mockNotifications: [true, {option: true}],\n  mockAnalytics: [true, {option: true}],\n\n  page: async ({page, mockPayments, mockNotifications, mockAnalytics}, use) => {\n    if (mockPayments) {\n      await page.route('**/api/billing/**', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({status: 'paid', id: 'inv_mock_789'}),\n        })\n      })\n    }\n\n    if (mockNotifications) {\n      await page.route('**/api/notify', (route) => {\n        route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({delivered: true}),\n        })\n      })\n    }\n\n    if (mockAnalytics) {\n      await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {\n        route.abort()\n      })\n    }\n\n    await use(page)\n  },\n})\n\nexport {expect} from '@playwright/test'\n```\n\n```typescript\n// tests/billing.spec.ts\nimport {test, expect} from './fixtures/service-mocks'\n\ntest('subscription renewal sends notification', async ({page}) => {\n  await page.goto('/account/billing')\n  await page.getByRole('button', {name: 'Renew Now'}).click()\n  await expect(page.getByText('Subscription renewed')).toBeVisible()\n})\n\ntest.describe('integration suite', () => {\n  test.use({mockPayments: false})\n\n  test('real billing flow against test gateway', async ({page}) => {\n    await page.goto('/account/billing')\n    await page.getByRole('button', {name: 'Renew Now'}).click()\n    await expect(page.getByText('Subscription renewed')).toBeVisible()\n  })\n})\n```\n\n### Environment-Based Test Projects\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    {\n      name: 'ci-fast',\n      testMatch: '**/*.spec.ts',\n      use: {baseURL: 'http://localhost:3000'},\n    },\n    {\n      name: 'nightly-full',\n      testMatch: '**/*.integration.spec.ts',\n      use: {baseURL: 'https://staging.example.com'},\n      timeout: 120_000,\n    },\n  ],\n})\n```\n\n## Validating Mock Accuracy\n\nGuard against mock drift from real APIs:\n\n```typescript\ntest.describe('contract validation', () => {\n  test('billing mock matches real API shape', async ({request}) => {\n    const realResponse = await request.post('/api/billing/charge', {\n      data: {amount: 5000, currency: 'usd'},\n    })\n    const realBody = await realResponse.json()\n\n    const mockBody = {\n      status: 'paid',\n      id: 'inv_mock_789',\n    }\n\n    expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort())\n\n    for (const key of Object.keys(mockBody)) {\n      expect(typeof mockBody[key]).toBe(typeof realBody[key])\n    }\n  })\n})\n```\n\n## Anti-Patterns\n\n| Don't Do This                             | Problem                                                       | Do This Instead                                               |\n| ----------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |\n| Mock your own API                         | Tests pass, app breaks. Zero integration coverage.            | Hit your real API. Mock only third-party services.            |\n| Mock everything for speed                 | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries.                                |\n| Never mock anything                       | Tests are slow, flaky, fail when third parties have outages.  | Mock third-party services.                                    |\n| Use outdated mocks                        | Mock returns different shape than real API.                   | Run contract validation tests. Re-record HAR files regularly. |\n| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation.                          | Use `page.route()` which intercepts at network layer.         |\n| Copy-paste mocks across files             | One API change requires updating many files.                  | Centralize mocks in fixtures.                                 |\n| Block all network and whitelist           | Extremely brittle. Every new endpoint requires update.        | Allow all by default. Selectively mock third-party services.  |\n\n<!-- File: .agents/skills/playwright-best-practices/browser-apis/browser-apis.md (Tokens: ~2343 | Category: anthropic-skill) -->\n# Browser APIs: Geolocation, Permissions & More\n\n## Table of Contents\n\n1. [Geolocation](#geolocation)\n2. [Permissions](#permissions)\n3. [Clipboard](#clipboard)\n4. [Notifications](#notifications)\n5. [Camera & Microphone](#camera--microphone)\n\n## Geolocation\n\n### Mock Location\n\n```typescript\ntest('shows nearby stores', async ({context}) => {\n  // Grant permission and set location\n  await context.grantPermissions(['geolocation'])\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194}) // San Francisco\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('San Francisco')).toBeVisible()\n})\n```\n\n### Geolocation Fixture\n\n```typescript\n// fixtures/geolocation.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype Coordinates = {latitude: number; longitude: number; accuracy?: number}\n\ntype GeoFixtures = {\n  setLocation: (coords: Coordinates) => Promise<void>\n}\n\nexport const test = base.extend<GeoFixtures>({\n  setLocation: async ({context}, use) => {\n    await context.grantPermissions(['geolocation'])\n\n    await use(async (coords) => {\n      await context.setGeolocation({\n        latitude: coords.latitude,\n        longitude: coords.longitude,\n        accuracy: coords.accuracy ?? 100,\n      })\n    })\n  },\n})\n\n// Usage\ntest('delivery zone check', async ({page, setLocation}) => {\n  await setLocation({latitude: 40.7128, longitude: -74.006}) // NYC\n\n  await page.goto('/delivery')\n\n  await expect(page.getByText('Delivery available')).toBeVisible()\n})\n```\n\n### Test Location Changes\n\n```typescript\ntest('tracks location updates', async ({context}) => {\n  await context.grantPermissions(['geolocation'])\n\n  const page = await context.newPage()\n  await page.goto('/tracking')\n\n  // Initial location\n  await context.setGeolocation({latitude: 37.7749, longitude: -122.4194})\n  await page.getByRole('button', {name: 'Start Tracking'}).click()\n\n  await expect(page.getByTestId('location')).toContainText('37.7749')\n\n  // Move to new location\n  await context.setGeolocation({latitude: 37.8044, longitude: -122.2712})\n\n  // Trigger location update\n  await page.evaluate(() => {\n    navigator.geolocation.getCurrentPosition(() => {})\n  })\n\n  await expect(page.getByTestId('location')).toContainText('37.8044')\n})\n```\n\n### Test Geolocation Denial\n\n```typescript\ntest('handles location denied', async ({browser}) => {\n  // Create context without geolocation permission\n  const context = await browser.newContext({\n    permissions: [], // No permissions\n  })\n\n  const page = await context.newPage()\n  await page.goto('/store-finder')\n  await page.getByRole('button', {name: 'Find Nearby'}).click()\n\n  await expect(page.getByText('Location access denied')).toBeVisible()\n  await expect(page.getByLabel('Enter ZIP code')).toBeVisible()\n\n  await context.close()\n})\n```\n\n## Permissions\n\n### Grant Permissions\n\n```typescript\ntest('notifications with permission', async ({context}) => {\n  await context.grantPermissions(['notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/alerts')\n\n  // Notification API should work\n  const permission = await page.evaluate(() => Notification.permission)\n  expect(permission).toBe('granted')\n})\n```\n\n### Test Permission Denied\n\n```typescript\ntest('handles notification permission denied', async ({browser}) => {\n  const context = await browser.newContext({\n    permissions: [], // Deny all\n  })\n\n  const page = await context.newPage()\n  await page.goto('/notifications')\n\n  await page.getByRole('button', {name: 'Enable Notifications'}).click()\n\n  await expect(page.getByText('Please enable notifications')).toBeVisible()\n\n  await context.close()\n})\n```\n\n### Multiple Permissions\n\n```typescript\ntest('video call with permissions', async ({context}) => {\n  await context.grantPermissions(['camera', 'microphone', 'notifications'])\n\n  const page = await context.newPage()\n  await page.goto('/video-call')\n\n  // All permissions should be granted\n  const permissions = await page.evaluate(async () => ({\n    camera: await navigator.permissions.query({\n      name: 'camera' as PermissionName,\n    }),\n    microphone: await navigator.permissions.query({\n      name: 'microphone' as PermissionName,\n    }),\n  }))\n\n  expect(permissions.camera.state).toBe('granted')\n  expect(permissions.microphone.state).toBe('granted')\n})\n```\n\n## Clipboard\n\n### Test Copy to Clipboard\n\n```typescript\ntest('copy button works', async ({page, context}) => {\n  // Grant clipboard permissions\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/share')\n\n  await page.getByRole('button', {name: 'Copy Link'}).click()\n\n  // Read clipboard content\n  const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())\n\n  expect(clipboardContent).toContain('https://example.com/share/')\n})\n```\n\n### Test Paste from Clipboard\n\n```typescript\ntest('paste from clipboard', async ({page, context}) => {\n  await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n  await page.goto('/editor')\n\n  // Write to clipboard\n  await page.evaluate(() => navigator.clipboard.writeText('Pasted content'))\n\n  // Trigger paste\n  await page.getByLabel('Content').focus()\n  await page.keyboard.press('Control+V')\n\n  await expect(page.getByLabel('Content')).toHaveValue('Pasted content')\n})\n```\n\n### Clipboard Fixture\n\n```typescript\n// fixtures/clipboard.fixture.ts\nimport {test as base} from '@playwright/test'\n\ntype ClipboardFixtures = {\n  clipboard: {\n    write: (text: string) => Promise<void>\n    read: () => Promise<string>\n  }\n}\n\nexport const test = base.extend<ClipboardFixtures>({\n  clipboard: async ({page, context}, use) => {\n    await context.grantPermissions(['clipboard-read', 'clipboard-write'])\n\n    await use({\n      write: async (text) => {\n        await page.evaluate((t) => navigator.clipboard.writeText(t), text)\n      },\n      read: async () => {\n        return page.evaluate(() => navigator.clipboard.readText())\n      },\n    })\n  },\n})\n```\n\n## Notifications\n\n### Mock Notification API\n\n```typescript\ntest('shows browser notification', async ({page}) => {\n  const notifications: any[] = []\n\n  // Mock Notification constructor\n  await page.addInitScript(() => {\n    ;(window as any).__notifications = []\n    ;(window as any).Notification = class {\n      constructor(title: string, options?: NotificationOptions) {\n        ;(window as any).__notifications.push({title, ...options})\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/alerts')\n  await page.getByRole('button', {name: 'Notify Me'}).click()\n\n  // Check notification was created\n  const created = await page.evaluate(() => (window as any).__notifications)\n  expect(created).toHaveLength(1)\n  expect(created[0].title).toBe('New Alert')\n})\n```\n\n### Test Notification Click\n\n```typescript\ntest('notification click handler', async ({page}) => {\n  await page.addInitScript(() => {\n    ;(window as any).Notification = class {\n      onclick: (() => void) | null = null\n      constructor(title: string) {\n        // Simulate click after creation\n        setTimeout(() => this.onclick?.(), 100)\n      }\n      static permission = 'granted'\n      static requestPermission = async () => 'granted'\n    }\n  })\n\n  await page.goto('/messages')\n  await page.evaluate(() => {\n    new Notification('New Message')\n  })\n\n  // Should navigate to messages when notification clicked\n  await expect(page).toHaveURL(/\\/messages/)\n})\n```\n\n## Camera & Microphone\n\n### Mock Media Devices\n\n```typescript\ntest('video preview works', async ({page, context}) => {\n  await context.grantPermissions(['camera'])\n\n  // Mock getUserMedia\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      canvas.width = 640\n      canvas.height = 480\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/video-settings')\n  await page.getByRole('button', {name: 'Start Camera'}).click()\n\n  await expect(page.getByTestId('video-preview')).toBeVisible()\n})\n```\n\n### Test Media Device Selection\n\n```typescript\ntest('switch camera', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.enumerateDevices = async () =>\n      [\n        {\n          deviceId: 'cam1',\n          kind: 'videoinput',\n          label: 'Front Camera',\n          groupId: '1',\n        },\n        {\n          deviceId: 'cam2',\n          kind: 'videoinput',\n          label: 'Back Camera',\n          groupId: '2',\n        },\n      ] as MediaDeviceInfo[]\n\n    navigator.mediaDevices.getUserMedia = async () => {\n      const canvas = document.createElement('canvas')\n      return canvas.captureStream()\n    }\n  })\n\n  await page.goto('/camera')\n\n  // Should show camera options\n  await expect(page.getByRole('combobox', {name: 'Camera'})).toBeVisible()\n  await expect(page.getByText('Front Camera')).toBeVisible()\n  await expect(page.getByText('Back Camera')).toBeVisible()\n})\n```\n\n### Test Media Errors\n\n```typescript\ntest('handles camera access error', async ({page}) => {\n  await page.addInitScript(() => {\n    navigator.mediaDevices.getUserMedia = async () => {\n      throw new DOMException('Permission denied', 'NotAllowedError')\n    }\n  })\n\n  await page.goto('/video-call')\n  await page.getByRole('button', {name: 'Join Call'}).click()\n\n  await expect(page.getByText('Camera access denied')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Join Audio Only'})).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                  | Problem                           | Solution                            |\n| ----------------------------- | --------------------------------- | ----------------------------------- |\n| Not granting permissions      | Tests fail with permission errors | Use `context.grantPermissions()`    |\n| Testing real geolocation      | Flaky, environment-dependent      | Mock with `setGeolocation()`        |\n| Not testing permission denial | Misses error handling             | Test both granted and denied states |\n| Using real camera/mic         | CI has no devices                 | Mock `getUserMedia`                 |\n\n## Related References\n\n- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures\n- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation\n\n<!-- File: .agents/skills/playwright-best-practices/browser-apis/iframes.md (Tokens: ~2698 | Category: anthropic-skill) -->\n# iFrame Testing\n\n## Table of Contents\n\n1. [Basic iFrame Access](#basic-iframe-access)\n2. [Cross-Origin iFrames](#cross-origin-iframes)\n3. [Nested iFrames](#nested-iframes)\n4. [Dynamic iFrames](#dynamic-iframes)\n5. [iFrame Navigation](#iframe-navigation)\n6. [Common Patterns](#common-patterns)\n\n## Basic iFrame Access\n\n### Using frameLocator\n\n```typescript\n// Access iframe by selector\nconst frame = page.frameLocator('iframe#payment')\nawait frame.getByRole('button', {name: 'Pay'}).click()\n\n// Access by name attribute\nconst namedFrame = page.frameLocator('iframe[name=\"checkout\"]')\nawait namedFrame.getByLabel('Card number').fill('4242424242424242')\n\n// Access by title\nconst titledFrame = page.frameLocator('iframe[title=\"Payment Form\"]')\n\n// Access by src (partial match)\nconst srcFrame = page.frameLocator('iframe[src*=\"stripe.com\"]')\n```\n\n### Frame vs FrameLocator\n\n```typescript\n// frameLocator - for locator-based operations (recommended)\nconst frameLocator = page.frameLocator('#my-iframe')\nawait frameLocator.getByRole('button').click()\n\n// frame() - for Frame object operations (navigation, evaluation)\nconst frame = page.frame({name: 'my-frame'})\nif (frame) {\n  await frame.goto('https://example.com')\n  const title = await frame.title()\n}\n\n// Get all frames\nconst frames = page.frames()\nfor (const f of frames) {\n  console.log('Frame URL:', f.url())\n}\n```\n\n### Waiting for iFrame Content\n\n```typescript\n// Wait for iframe to load\nconst frame = page.frameLocator('#dynamic-iframe')\n\n// Wait for element inside iframe\nawait expect(frame.getByRole('heading')).toBeVisible({timeout: 10000})\n\n// Wait for iframe src to change\nawait page.waitForFunction(() => {\n  const iframe = document.querySelector('iframe#my-frame') as HTMLIFrameElement\n  return iframe?.src.includes('loaded')\n})\n```\n\n## Cross-Origin iFrames\n\n### Accessing Cross-Origin Content\n\n```typescript\n// Cross-origin iframes work seamlessly with frameLocator\nconst thirdPartyFrame = page.frameLocator('iframe[src*=\"third-party.com\"]')\n\n// Interact with elements inside cross-origin iframe\nawait thirdPartyFrame.getByRole('textbox').fill('test@example.com')\nawait thirdPartyFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Wait for cross-origin iframe to be ready\nawait expect(thirdPartyFrame.locator('body')).toBeVisible()\n```\n\n### Payment Provider iFrames (Stripe, PayPal)\n\n```typescript\ntest('Stripe payment iframe', async ({page}) => {\n  await page.goto('/checkout')\n\n  // Stripe uses multiple iframes for each field\n  const cardFrame = page.frameLocator('iframe[name*=\"__privateStripeFrame\"]').first()\n\n  // Wait for Stripe to initialize\n  await expect(cardFrame.locator('[placeholder=\"Card number\"]')).toBeVisible({\n    timeout: 15000,\n  })\n\n  // Fill card details\n  await cardFrame.locator('[placeholder=\"Card number\"]').fill('4242424242424242')\n  await cardFrame.locator('[placeholder=\"MM / YY\"]').fill('12/30')\n  await cardFrame.locator('[placeholder=\"CVC\"]').fill('123')\n})\n```\n\n### Handling OAuth in iFrames\n\n```typescript\ntest('OAuth iframe flow', async ({page}) => {\n  await page.goto('/login')\n  await page.getByRole('button', {name: 'Sign in with Google'}).click()\n\n  // If OAuth opens in iframe instead of popup\n  const oauthFrame = page.frameLocator('iframe[src*=\"accounts.google.com\"]')\n\n  // Wait for OAuth form\n  await expect(oauthFrame.getByLabel('Email')).toBeVisible({timeout: 10000})\n  await oauthFrame.getByLabel('Email').fill('test@gmail.com')\n})\n```\n\n## Nested iFrames\n\n### Accessing Nested Frames\n\n```typescript\n// Parent iframe contains child iframe\nconst parentFrame = page.frameLocator('#outer-frame')\nconst childFrame = parentFrame.frameLocator('#inner-frame')\n\n// Interact with deeply nested content\nawait childFrame.getByRole('button', {name: 'Submit'}).click()\n\n// Multiple levels of nesting\nconst level1 = page.frameLocator('#level1')\nconst level2 = level1.frameLocator('#level2')\nconst level3 = level2.frameLocator('#level3')\nawait level3.getByText('Deep content').click()\n```\n\n### Finding Elements Across Frame Hierarchy\n\n```typescript\n// Helper to search all frames for an element\nasync function findInAnyFrame(page: Page, selector: string): Promise<Locator | null> {\n  // Check main page first\n  const mainCount = await page.locator(selector).count()\n  if (mainCount > 0) return page.locator(selector)\n\n  // Check all frames\n  for (const frame of page.frames()) {\n    const count = await frame.locator(selector).count()\n    if (count > 0) {\n      return frame.locator(selector)\n    }\n  }\n  return null\n}\n\ntest('find element in any frame', async ({page}) => {\n  await page.goto('/complex-page')\n  const element = await findInAnyFrame(page, '[data-testid=\"submit-btn\"]')\n  if (element) await element.click()\n})\n```\n\n## Dynamic iFrames\n\n### iFrames Created at Runtime\n\n```typescript\ntest('handle dynamically created iframe', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Click button that creates iframe\n  await page.getByRole('button', {name: 'Open Widget'}).click()\n\n  // Wait for iframe to appear in DOM\n  await page.waitForSelector('iframe#widget-frame')\n\n  // Now access the frame\n  const widgetFrame = page.frameLocator('#widget-frame')\n  await expect(widgetFrame.getByText('Widget Loaded')).toBeVisible()\n})\n```\n\n### iFrames with Changing src\n\n```typescript\ntest('iframe src changes', async ({page}) => {\n  await page.goto('/multi-step')\n\n  const frame = page.frameLocator('#step-frame')\n\n  // Step 1\n  await expect(frame.getByText('Step 1')).toBeVisible()\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Wait for iframe to reload with new content\n  await expect(frame.getByText('Step 2')).toBeVisible({timeout: 10000})\n  await frame.getByRole('button', {name: 'Next'}).click()\n\n  // Step 3\n  await expect(frame.getByText('Step 3')).toBeVisible({timeout: 10000})\n})\n```\n\n### Lazy-Loaded iFrames\n\n```typescript\ntest('lazy loaded iframe', async ({page}) => {\n  await page.goto('/page-with-lazy-iframe')\n\n  // Scroll to trigger lazy load\n  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))\n\n  // Wait for iframe to load\n  const lazyFrame = page.frameLocator('#lazy-iframe')\n  await expect(lazyFrame.locator('body')).not.toBeEmpty({timeout: 15000})\n\n  // Interact with content\n  await lazyFrame.getByRole('button').click()\n})\n```\n\n## iFrame Navigation\n\n### Navigating Within iFrame\n\n```typescript\ntest('iframe internal navigation', async ({page}) => {\n  await page.goto('/app')\n\n  // Get frame object for navigation control\n  const frame = page.frame({name: 'content-frame'})\n  if (!frame) throw new Error('Frame not found')\n\n  // Navigate within iframe\n  await frame.goto('https://embedded-app.com/page2')\n\n  // Wait for navigation\n  await frame.waitForURL('**/page2')\n\n  // Verify content\n  await expect(frame.getByRole('heading')).toHaveText('Page 2')\n})\n```\n\n### Handling Frame Navigation Events\n\n```typescript\ntest('track iframe navigation', async ({page}) => {\n  const navigations: string[] = []\n\n  // Listen to frame navigation\n  page.on('framenavigated', (frame) => {\n    if (frame.parentFrame()) {\n      // This is an iframe navigation\n      navigations.push(frame.url())\n    }\n  })\n\n  await page.goto('/with-iframe')\n  await page.frameLocator('#nav-frame').getByRole('link', {name: 'Page 2'}).click()\n\n  // Verify navigation occurred\n  expect(navigations.some((url) => url.includes('page2'))).toBe(true)\n})\n```\n\n## Common Patterns\n\n### iFrame Fixture\n\n```typescript\n// fixtures.ts\nimport {test as base, FrameLocator} from '@playwright/test'\n\nexport const test = base.extend<{paymentFrame: FrameLocator}>({\n  paymentFrame: async ({page}, use) => {\n    await page.goto('/checkout')\n\n    // Wait for payment iframe to be ready\n    const frame = page.frameLocator('iframe[src*=\"payment\"]')\n    await expect(frame.locator('body')).toBeVisible({timeout: 15000})\n\n    await use(frame)\n  },\n})\n\n// test file\ntest('complete payment', async ({paymentFrame}) => {\n  await paymentFrame.getByLabel('Card').fill('4242424242424242')\n  await paymentFrame.getByRole('button', {name: 'Pay'}).click()\n})\n```\n\n### Debugging iFrame Issues\n\n```typescript\ntest('debug iframe content', async ({page}) => {\n  await page.goto('/page-with-iframes')\n\n  // List all frames\n  console.log('All frames:')\n  for (const frame of page.frames()) {\n    console.log(`  - ${frame.name() || '(unnamed)'}: ${frame.url()}`)\n  }\n\n  // Screenshot specific iframe content\n  const frame = page.frame({name: 'target-frame'})\n  if (frame) {\n    const body = frame.locator('body')\n    await body.screenshot({path: 'iframe-content.png'})\n  }\n\n  // Get iframe HTML for debugging\n  const frameContent = page.frameLocator('#my-frame')\n  const html = await frameContent.locator('body').innerHTML()\n  console.log('iFrame HTML:', html.substring(0, 500))\n})\n```\n\n### Handling iFrame Load Failures\n\n```typescript\ntest('handle iframe load failure', async ({page}) => {\n  await page.goto('/page-with-unreliable-iframe')\n\n  const frame = page.frameLocator('#unreliable-frame')\n\n  try {\n    // Try to interact with iframe content\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 5000})\n    await frame.getByRole('button').click()\n  } catch (error) {\n    // Fallback: refresh iframe\n    await page.evaluate(() => {\n      const iframe = document.querySelector('#unreliable-frame') as HTMLIFrameElement\n      if (iframe) iframe.src = iframe.src\n    })\n\n    // Retry\n    await expect(frame.getByRole('button')).toBeVisible({timeout: 10000})\n    await frame.getByRole('button').click()\n  }\n})\n```\n\n### Mocking iFrame Content\n\n```typescript\ntest('mock iframe response', async ({page}) => {\n  // Intercept iframe src request\n  await page.route('**/embedded-widget**', (route) => {\n    route.fulfill({\n      contentType: 'text/html',\n      body: `\n        <!DOCTYPE html>\n        <html>\n          <body>\n            <h1>Mocked Widget</h1>\n            <button>Mocked Button</button>\n          </body>\n        </html>\n      `,\n    })\n  })\n\n  await page.goto('/page-with-widget')\n\n  const frame = page.frameLocator('#widget-frame')\n  await expect(frame.getByRole('heading')).toHaveText('Mocked Widget')\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                           | Solution                                           |\n| ------------------------------------- | --------------------------------- | -------------------------------------------------- |\n| Using `page.frame()` for interactions | Less reliable than frameLocator   | Use `page.frameLocator()` for element interactions |\n| Hardcoding iframe index               | Fragile if DOM order changes      | Use name, id, or src attribute selectors           |\n| Not waiting for iframe load           | Race conditions                   | Wait for element inside iframe to be visible       |\n| Assuming same-origin                  | Cross-origin has different timing | Always wait for iframe content explicitly          |\n| Ignoring nested iframes               | Element not found                 | Chain frameLocator calls for nested frames         |\n\n## Related References\n\n- **Locators**: See [locators.md](../core/locators.md) for selector strategies\n- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues\n\n<!-- File: .agents/skills/playwright-best-practices/browser-apis/service-workers.md (Tokens: ~3052 | Category: anthropic-skill) -->\n# Service Worker Testing\n\n## Table of Contents\n\n1. [Service Worker Basics](#service-worker-basics)\n2. [Registration & Lifecycle](#registration--lifecycle)\n3. [Cache Testing](#cache-testing)\n4. [Offline Testing](#offline-testing)\n5. [Push Notifications](#push-notifications)\n6. [Background Sync](#background-sync)\n\n## Service Worker Basics\n\n### Waiting for Service Worker Registration\n\n```typescript\ntest('service worker registers', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to register\n  const swRegistered = await page.evaluate(async () => {\n    if (!('serviceWorker' in navigator)) return false\n\n    const registration = await navigator.serviceWorker.ready\n    return !!registration.active\n  })\n\n  expect(swRegistered).toBe(true)\n})\n```\n\n### Getting Service Worker State\n\n```typescript\ntest('check SW state', async ({page}) => {\n  await page.goto('/')\n\n  const swState = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.getRegistration()\n    if (!registration) return null\n\n    return {\n      installing: !!registration.installing,\n      waiting: !!registration.waiting,\n      active: !!registration.active,\n      scope: registration.scope,\n    }\n  })\n\n  expect(swState?.active).toBe(true)\n  expect(swState?.scope).toContain(page.url())\n})\n```\n\n### Service Worker Context\n\n```typescript\ntest('access service worker', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Get all service workers in context\n  const workers = context.serviceWorkers()\n\n  // Wait for service worker if not yet available\n  if (workers.length === 0) {\n    await context.waitForEvent('serviceworker')\n  }\n\n  const sw = context.serviceWorkers()[0]\n  expect(sw.url()).toContain('sw.js')\n})\n```\n\n## Registration & Lifecycle\n\n### Testing SW Update Flow\n\n```typescript\ntest('service worker updates', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Check for update\n  const hasUpdate = await page.evaluate(async () => {\n    const registration = await navigator.serviceWorker.ready\n    await registration.update()\n\n    return new Promise<boolean>((resolve) => {\n      if (registration.waiting) {\n        resolve(true)\n      } else {\n        registration.addEventListener('updatefound', () => {\n          resolve(true)\n        })\n        // Timeout if no update\n        setTimeout(() => resolve(false), 5000)\n      }\n    })\n  })\n\n  // If update found, test skip waiting flow\n  if (hasUpdate) {\n    await page.evaluate(async () => {\n      const registration = await navigator.serviceWorker.ready\n      registration.waiting?.postMessage({type: 'SKIP_WAITING'})\n    })\n\n    // Wait for controller change\n    await page.evaluate(() => {\n      return new Promise<void>((resolve) => {\n        navigator.serviceWorker.addEventListener('controllerchange', () => {\n          resolve()\n        })\n      })\n    })\n  }\n})\n```\n\n### Testing SW Installation\n\n```typescript\ntest('verify SW install event', async ({context, page}) => {\n  // Listen for service worker before navigating\n  const swPromise = context.waitForEvent('serviceworker')\n\n  await page.goto('/pwa-app')\n\n  const sw = await swPromise\n\n  // Evaluate in SW context\n  const swVersion = await sw.evaluate(() => {\n    // Access SW globals\n    return (self as any).SW_VERSION || 'unknown'\n  })\n\n  expect(swVersion).toBe('1.0.0')\n})\n```\n\n### Unregistering Service Workers\n\n```typescript\ntest.beforeEach(async ({page}) => {\n  await page.goto('/')\n\n  // Unregister all service workers for clean state\n  await page.evaluate(async () => {\n    const registrations = await navigator.serviceWorker.getRegistrations()\n    await Promise.all(registrations.map((r) => r.unregister()))\n  })\n\n  // Clear caches\n  await page.evaluate(async () => {\n    const cacheNames = await caches.keys()\n    await Promise.all(cacheNames.map((name) => caches.delete(name)))\n  })\n})\n```\n\n## Cache Testing\n\n### Verifying Cached Resources\n\n```typescript\ntest('assets are cached', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for SW to cache assets\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n\n  // Check cache contents\n  const cachedUrls = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const requests = await cache.keys()\n    return requests.map((r) => r.url)\n  })\n\n  expect(cachedUrls).toContain(expect.stringContaining('/styles.css'))\n  expect(cachedUrls).toContain(expect.stringContaining('/app.js'))\n})\n```\n\n### Testing Cache Strategies\n\n```typescript\ntest('cache-first strategy', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Wait for initial cache\n  await page.waitForFunction(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.length > 0\n  })\n\n  // Block network for cached resources\n  await page.route('**/styles.css', (route) => route.abort())\n\n  // Reload - should work from cache\n  await page.reload()\n\n  // Verify page still styled (CSS loaded from cache)\n  const hasStyles = await page.evaluate(() => {\n    const body = document.body\n    const styles = window.getComputedStyle(body)\n    return styles.fontFamily !== '' // Has custom font from CSS\n  })\n\n  expect(hasStyles).toBe(true)\n})\n```\n\n### Testing Cache Updates\n\n```typescript\ntest('cache updates on new version', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Get initial cache\n  const initialCacheKeys = await page.evaluate(async () => {\n    const cache = await caches.open('app-cache-v1')\n    const keys = await cache.keys()\n    return keys.map((r) => r.url)\n  })\n\n  // Simulate app update by mocking SW response\n  await page.route('**/sw.js', (route) => {\n    route.fulfill({\n      contentType: 'application/javascript',\n      body: `\n        const VERSION = 'v2';\n        self.addEventListener('install', (e) => {\n          e.waitUntil(caches.open('app-cache-v2'));\n          self.skipWaiting();\n        });\n      `,\n    })\n  })\n\n  // Trigger update\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.update()\n  })\n\n  // Verify new cache exists\n  await page.waitForFunction(async () => {\n    return await caches.has('app-cache-v2')\n  })\n})\n```\n\n## Offline Testing\n\nThis section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).\n\n### Simulating Offline Mode\n\n```typescript\ntest('app works offline', async ({page, context}) => {\n  await page.goto('/pwa-app')\n\n  // Ensure SW is active and content cached\n  await page.evaluate(async () => {\n    await navigator.serviceWorker.ready\n  })\n  await page.waitForTimeout(1000) // Allow caching to complete\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to cached page\n  await page.reload()\n\n  // Verify content loads\n  await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()\n\n  // Verify offline indicator\n  await expect(page.locator('.offline-badge')).toBeVisible()\n\n  // Go back online\n  await context.setOffline(false)\n  await expect(page.locator('.offline-badge')).not.toBeVisible()\n})\n```\n\n### Testing Offline Fallback\n\n```typescript\ntest('shows offline page for uncached routes', async ({page, context}) => {\n  await page.goto('/pwa-app')\n  await page.evaluate(() => navigator.serviceWorker.ready)\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Navigate to uncached page\n  await page.goto('/uncached-page')\n\n  // Should show offline fallback\n  await expect(page.getByText('You are offline')).toBeVisible()\n  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()\n})\n```\n\n### Testing Offline Form Submission\n\n```typescript\ntest('queues form submission offline', async ({page, context}) => {\n  await page.goto('/pwa-app/form')\n\n  // Go offline\n  await context.setOffline(true)\n\n  // Submit form\n  await page.getByLabel('Message').fill('Offline message')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Should show queued status\n  await expect(page.getByText('Queued for sync')).toBeVisible()\n\n  // Go online\n  await context.setOffline(false)\n\n  // Trigger sync (or wait for automatic)\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    // Manually trigger sync for testing\n    await (reg as any).sync?.register('form-sync')\n  })\n\n  // Verify submission completed\n  await expect(page.getByText('Message sent')).toBeVisible({timeout: 10000})\n})\n```\n\n## Push Notifications\n\n### Mocking Push Subscription\n\n```typescript\ntest('handles push subscription', async ({page, context}) => {\n  // Grant notification permission\n  await context.grantPermissions(['notifications'])\n\n  await page.goto('/pwa-app')\n\n  // Subscribe to push\n  const subscription = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    const sub = await reg.pushManager.subscribe({\n      userVisibleOnly: true,\n      applicationServerKey: 'test-key',\n    })\n    return sub.toJSON()\n  })\n\n  expect(subscription.endpoint).toBeDefined()\n})\n```\n\n### Testing Push Message Handling\n\n```typescript\ntest('handles push notification', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Wait for SW\n  const swPromise = context.waitForEvent('serviceworker')\n  const sw = await swPromise\n\n  // Simulate push message to service worker\n  await sw.evaluate(async () => {\n    // Dispatch push event\n    const pushEvent = new PushEvent('push', {\n      data: new PushMessageData(JSON.stringify({title: 'Test', body: 'Push message'})),\n    })\n    self.dispatchEvent(pushEvent)\n  })\n\n  // Note: Actual notification display testing is limited in Playwright\n  // Focus on verifying the SW handles the push correctly\n})\n```\n\n### Testing Notification Click\n\n```typescript\ntest('notification click opens page', async ({context, page}) => {\n  await context.grantPermissions(['notifications'])\n  await page.goto('/pwa-app')\n\n  // Store notification URL target\n  let notificationUrl = ''\n\n  // Listen for new pages (notification click opens new page)\n  context.on('page', (newPage) => {\n    notificationUrl = newPage.url()\n  })\n\n  // Trigger notification via SW\n  await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    await reg.showNotification('Test', {\n      body: 'Click me',\n      data: {url: '/notification-target'},\n    })\n  })\n\n  // Simulate clicking notification (via SW)\n  const sw = context.serviceWorkers()[0]\n  await sw.evaluate(() => {\n    self.dispatchEvent(\n      new NotificationEvent('notificationclick', {\n        notification: {data: {url: '/notification-target'}} as any,\n      }),\n    )\n  })\n\n  // Verify navigation occurred\n  await page.waitForTimeout(1000)\n  // Check if new page opened or current page navigated\n})\n```\n\n## Background Sync\n\n### Testing Background Sync Registration\n\n```typescript\ntest('registers background sync', async ({page}) => {\n  await page.goto('/pwa-app')\n\n  // Register sync\n  const syncRegistered = await page.evaluate(async () => {\n    const reg = await navigator.serviceWorker.ready\n    if (!('sync' in reg)) return false\n\n    await (reg as any).sync.register('my-sync')\n    return true\n  })\n\n  expect(syncRegistered).toBe(true)\n})\n```\n\n### Testing Sync Event\n\n```typescript\ntest('sync event fires when online', async ({context, page}) => {\n  await page.goto('/pwa-app')\n\n  // Queue data while offline\n  await context.setOffline(true)\n\n  await page.evaluate(async () => {\n    // Store data in IndexedDB for sync\n    const db = await openDB()\n    await db.put('sync-queue', {id: 1, data: 'test'})\n\n    // Register sync\n    const reg = await navigator.serviceWorker.ready\n    await (reg as any).sync.register('data-sync')\n  })\n\n  // Track sync completion\n  await page.evaluate(() => {\n    window.syncCompleted = false\n    navigator.serviceWorker.addEventListener('message', (e) => {\n      if (e.data.type === 'SYNC_COMPLETE') {\n        window.syncCompleted = true\n      }\n    })\n  })\n\n  // Go online\n  await context.setOffline(false)\n\n  // Wait for sync to complete\n  await page.waitForFunction(() => window.syncCompleted, {timeout: 10000})\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                   | Problem                 | Solution                                     |\n| ------------------------------ | ----------------------- | -------------------------------------------- |\n| Not clearing SW between tests  | Tests affect each other | Unregister SW in beforeEach                  |\n| Not waiting for SW ready       | Race conditions         | Always await `navigator.serviceWorker.ready` |\n| Testing in isolation only      | Misses real SW behavior | Test with actual caching                     |\n| Hardcoded timeouts for caching | Flaky tests             | Wait for cache to populate                   |\n| Ignoring SW update cycle       | Missing update bugs     | Test install, activate, update flows         |\n\n## Related References\n\n- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns\n- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions\n- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception\n- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns\n\n<!-- File: .agents/skills/playwright-best-practices/browser-apis/websockets.md (Tokens: ~2458 | Category: anthropic-skill) -->\n# WebSocket & Real-Time Testing\n\n## Table of Contents\n\n1. [WebSocket Basics](#websocket-basics)\n2. [Mocking WebSocket Messages](#mocking-websocket-messages)\n3. [Testing Real-Time Features](#testing-real-time-features)\n4. [Server-Sent Events](#server-sent-events)\n5. [Reconnection Testing](#reconnection-testing)\n\n## WebSocket Basics\n\n### Wait for WebSocket Connection\n\n```typescript\ntest('chat connects via websocket', async ({page}) => {\n  // Listen for WebSocket connection\n  const wsPromise = page.waitForEvent('websocket')\n\n  await page.goto('/chat')\n\n  const ws = await wsPromise\n  expect(ws.url()).toContain('/ws/chat')\n\n  // Wait for connection to be established\n  await ws.waitForEvent('framesent')\n})\n```\n\n### Monitor WebSocket Messages\n\n```typescript\ntest('receives real-time updates', async ({page}) => {\n  const messages: string[] = []\n\n  // Set up listener before navigation\n  page.on('websocket', (ws) => {\n    ws.on('framereceived', (frame) => {\n      messages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/dashboard')\n\n  // Wait for some messages\n  await expect.poll(() => messages.length).toBeGreaterThan(0)\n\n  // Verify message format\n  const data = JSON.parse(messages[0])\n  expect(data).toHaveProperty('type')\n})\n```\n\n### Capture Sent Messages\n\n```typescript\ntest('sends correct message format', async ({page}) => {\n  const sentMessages: string[] = []\n\n  page.on('websocket', (ws) => {\n    ws.on('framesent', (frame) => {\n      sentMessages.push(frame.payload as string)\n    })\n  })\n\n  await page.goto('/chat')\n  await page.getByLabel('Message').fill('Hello!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  await expect.poll(() => sentMessages.length).toBeGreaterThan(0)\n\n  const sent = JSON.parse(sentMessages[sentMessages.length - 1])\n  expect(sent).toEqual({\n    type: 'message',\n    content: 'Hello!',\n  })\n})\n```\n\n## Mocking WebSocket Messages\n\n### Inject Messages via Page Evaluate\n\n```typescript\ntest('displays incoming chat message', async ({page}) => {\n  await page.goto('/chat')\n\n  // Wait for WebSocket to be ready\n  await page.waitForFunction(() => (window as any).chatSocket?.readyState === 1)\n\n  // Simulate incoming message\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'message',\n        from: 'Alice',\n        content: 'Hello there!',\n      }),\n    })\n    ;(window as any).chatSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByText('Alice: Hello there!')).toBeVisible()\n})\n```\n\n### Mock WebSocket with Route Handler\n\n```typescript\ntest('mock websocket entirely', async ({page, context}) => {\n  // Intercept the WebSocket upgrade\n  await context.route('**/ws/**', async (route) => {\n    // For WebSocket routes, we can't fulfill directly\n    // Instead, use page.evaluate to mock the client-side\n  })\n\n  // Alternative: Mock at application level\n  await page.addInitScript(() => {\n    const OriginalWebSocket = window.WebSocket\n    ;(window as any).WebSocket = function (url: string) {\n      const ws = {\n        readyState: 1,\n        send: (data: string) => {\n          console.log('WS Send:', data)\n        },\n        close: () => {},\n        addEventListener: () => {},\n        removeEventListener: () => {},\n      }\n      setTimeout(() => ws.onopen?.(), 100)\n      return ws\n    }\n  })\n\n  await page.goto('/chat')\n})\n```\n\n### WebSocket Mock Fixture\n\n```typescript\n// fixtures/websocket.fixture.ts\nimport {test as base, Page} from '@playwright/test'\n\ntype WsMessage = {type: string; [key: string]: any}\n\ntype WebSocketFixtures = {\n  mockWebSocket: {\n    injectMessage: (message: WsMessage) => Promise<void>\n    getSentMessages: () => Promise<WsMessage[]>\n  }\n}\n\nexport const test = base.extend<WebSocketFixtures>({\n  mockWebSocket: async ({page}, use) => {\n    const sentMessages: WsMessage[] = []\n\n    // Capture sent messages\n    await page.addInitScript(() => {\n      ;(window as any).__wsSent = []\n      const OriginalWebSocket = window.WebSocket\n      window.WebSocket = function (url: string) {\n        const ws = new OriginalWebSocket(url)\n        const originalSend = ws.send.bind(ws)\n        ws.send = (data: string) => {\n          ;(window as any).__wsSent.push(JSON.parse(data))\n          originalSend(data)\n        }\n        ;(window as any).__ws = ws\n        return ws\n      } as any\n    })\n\n    await use({\n      injectMessage: async (message) => {\n        await page.evaluate((msg) => {\n          const event = new MessageEvent('message', {\n            data: JSON.stringify(msg),\n          })\n          ;(window as any).__ws?.dispatchEvent(event)\n        }, message)\n      },\n      getSentMessages: async () => {\n        return page.evaluate(() => (window as any).__wsSent || [])\n      },\n    })\n  },\n})\n\n// Usage\ntest('chat with mocked websocket', async ({page, mockWebSocket}) => {\n  await page.goto('/chat')\n\n  // Inject incoming message\n  await mockWebSocket.injectMessage({\n    type: 'message',\n    from: 'Bob',\n    content: 'Hi!',\n  })\n\n  await expect(page.getByText('Bob: Hi!')).toBeVisible()\n\n  // Send a reply\n  await page.getByLabel('Message').fill('Hello Bob!')\n  await page.getByRole('button', {name: 'Send'}).click()\n\n  // Verify sent message\n  const sent = await mockWebSocket.getSentMessages()\n  expect(sent).toContainEqual(expect.objectContaining({content: 'Hello Bob!'}))\n})\n```\n\n## Testing Real-Time Features\n\n### Live Notifications\n\n```typescript\ntest('displays live notification', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Simulate notification via WebSocket\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'notification',\n        title: 'New Order',\n        message: 'Order #123 received',\n      }),\n    })\n    ;(window as any).notificationSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByRole('alert')).toContainText('Order #123 received')\n})\n```\n\n### Live Data Updates\n\n```typescript\ntest('updates stock price in real-time', async ({page}) => {\n  await page.goto('/stocks/AAPL')\n\n  const priceElement = page.getByTestId('stock-price')\n  const initialPrice = await priceElement.textContent()\n\n  // Simulate price update\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'price_update',\n        symbol: 'AAPL',\n        price: 150.25,\n      }),\n    })\n    ;(window as any).stockSocket.dispatchEvent(event)\n  })\n\n  await expect(priceElement).not.toHaveText(initialPrice!)\n  await expect(priceElement).toContainText('150.25')\n})\n```\n\n### Collaborative Editing\n\n```typescript\ntest('shows collaborator cursor', async ({page}) => {\n  await page.goto('/document/123')\n\n  // Simulate another user's cursor position\n  await page.evaluate(() => {\n    const event = new MessageEvent('message', {\n      data: JSON.stringify({\n        type: 'cursor',\n        userId: 'user-456',\n        userName: 'Alice',\n        position: {x: 100, y: 200},\n      }),\n    })\n    ;(window as any).docSocket.dispatchEvent(event)\n  })\n\n  await expect(page.getByTestId('cursor-user-456')).toBeVisible()\n  await expect(page.getByText('Alice')).toBeVisible()\n})\n```\n\n## Server-Sent Events\n\n### Test SSE Updates\n\n```typescript\ntest('receives SSE updates', async ({page}) => {\n  // Mock SSE endpoint\n  await page.route('**/api/events', (route) => {\n    route.fulfill({\n      status: 200,\n      headers: {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n        'Connection': 'keep-alive',\n      },\n      body: `data: {\"type\":\"update\",\"value\":42}\\n\\n`,\n    })\n  })\n\n  await page.goto('/live-data')\n\n  await expect(page.getByTestId('value')).toHaveText('42')\n})\n```\n\n### Simulate Multiple SSE Events\n\n```typescript\ntest('handles multiple SSE events', async ({page}) => {\n  await page.route('**/api/events', async (route) => {\n    const encoder = new TextEncoder()\n    const events = [`data: {\"count\":1}\\n\\n`, `data: {\"count\":2}\\n\\n`, `data: {\"count\":3}\\n\\n`]\n\n    route.fulfill({\n      status: 200,\n      headers: {'Content-Type': 'text/event-stream'},\n      body: events.join(''),\n    })\n  })\n\n  await page.goto('/counter')\n\n  // Should receive all events\n  await expect(page.getByTestId('count')).toHaveText('3')\n})\n```\n\n## Reconnection Testing\n\n### Test Connection Loss\n\n```typescript\ntest('handles connection loss gracefully', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate connection close\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  // Should show disconnected state\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n})\n```\n\n### Test Reconnection\n\n```typescript\ntest('reconnects after connection loss', async ({page}) => {\n  await page.goto('/chat')\n\n  // Simulate disconnect\n  await page.evaluate(() => {\n    ;(window as any).chatSocket.close()\n  })\n\n  await expect(page.getByText('Reconnecting...')).toBeVisible()\n\n  // Simulate reconnection\n  await page.evaluate(() => {\n    const event = new Event('open')\n    ;(window as any).chatSocket = {readyState: 1}\n    ;(window as any).chatSocket.dispatchEvent?.(event)\n  })\n\n  // Force component to re-check connection\n  await page.evaluate(() => {\n    window.dispatchEvent(new Event('online'))\n  })\n\n  await expect(page.getByText('Connected')).toBeVisible()\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                          | Problem                       | Solution                           |\n| ------------------------------------- | ----------------------------- | ---------------------------------- |\n| Not waiting for WebSocket ready       | Messages sent too early       | Wait for `readyState === 1`        |\n| Testing against real WebSocket server | Flaky, timing-dependent       | Mock WebSocket messages            |\n| Ignoring connection state             | Tests pass but feature broken | Test connected/disconnected states |\n| No cleanup of listeners               | Memory leaks in tests         | Clean up event listeners           |\n\n## Related References\n\n- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns\n- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns\n- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users\n\n<!-- File: .agents/skills/playwright-best-practices/core/annotations.md (Tokens: ~2574 | Category: anthropic-skill) -->\n# Test Annotations & Organization\n\n## Table of Contents\n\n1. [Skip Annotations](#skip-annotations)\n2. [Fixme & Fail Annotations](#fixme--fail-annotations)\n3. [Slow Tests](#slow-tests)\n4. [Test Steps](#test-steps)\n5. [Custom Annotations](#custom-annotations)\n6. [Conditional Annotations](#conditional-annotations)\n\n## Skip Annotations\n\n### Basic Skip\n\n```typescript\n// Skip unconditionally\ntest.skip('feature not implemented', async ({page}) => {\n  // This test won't run\n})\n\n// Skip with reason\ntest('payment flow', async ({page}) => {\n  test.skip(true, 'Payment gateway in maintenance')\n  // Test body won't execute\n})\n```\n\n### Conditional Skip\n\n```typescript\ntest('webkit-specific feature', async ({page, browserName}) => {\n  test.skip(browserName !== 'webkit', 'This feature only works in WebKit')\n\n  await page.goto('/webkit-feature')\n})\n\ntest('production only', async ({page}) => {\n  test.skip(process.env.ENV !== 'production', 'Only runs against production')\n\n  await page.goto('/prod-feature')\n})\n```\n\n### Skip by Platform\n\n```typescript\ntest('windows-specific', async ({page}) => {\n  test.skip(process.platform !== 'win32', 'Windows only')\n})\n\ntest('not on CI', async ({page}) => {\n  test.skip(!!process.env.CI, 'Skipped in CI environment')\n})\n```\n\n### Skip Describe Block\n\n```typescript\ntest.describe('Admin features', () => {\n  test.skip(({browserName}) => browserName === 'firefox', 'Firefox admin bug')\n\n  test('admin dashboard', async ({page}) => {\n    // Skipped in Firefox\n  })\n\n  test('admin settings', async ({page}) => {\n    // Skipped in Firefox\n  })\n})\n```\n\n## Fixme & Fail Annotations\n\n### Fixme - Known Issues\n\n```typescript\n// Mark test as needing fix (skips the test)\ntest.fixme('broken after refactor', async ({page}) => {\n  // Test won't run but is tracked\n})\n\n// Conditional fixme\ntest('flaky on CI', async ({page}) => {\n  test.fixme(!!process.env.CI, 'Investigate CI flakiness - ticket #123')\n\n  await page.goto('/flaky-feature')\n})\n```\n\n### Fail - Expected Failures\n\n```typescript\n// Test is expected to fail (runs but expects failure)\ntest('known bug', async ({page}) => {\n  test.fail()\n\n  await page.goto('/buggy-page')\n  // If this passes, the test fails (bug was fixed!)\n  await expect(page.getByText('Working')).toBeVisible()\n})\n\n// Conditional fail\ntest('fails on webkit', async ({page, browserName}) => {\n  test.fail(browserName === 'webkit', 'WebKit rendering bug #456')\n\n  await page.goto('/render-test')\n  await expect(page.getByTestId('element')).toHaveCSS('width', '100px')\n})\n```\n\n### Difference Between Skip, Fixme, Fail\n\n| Annotation     | Runs? | Use Case                         |\n| -------------- | ----- | -------------------------------- |\n| `test.skip()`  | No    | Feature not applicable           |\n| `test.fixme()` | No    | Known bug, needs investigation   |\n| `test.fail()`  | Yes   | Expected to fail, tracking a bug |\n\n## Slow Tests\n\n### Mark Slow Tests\n\n```typescript\n// Triple the default timeout\ntest('large data import', async ({page}) => {\n  test.slow()\n\n  await page.goto('/import')\n  await page.setInputFiles('#file', 'large-file.csv')\n  await page.getByRole('button', {name: 'Import'}).click()\n\n  await expect(page.getByText('Import complete')).toBeVisible()\n})\n\n// Conditional slow\ntest('video processing', async ({page, browserName}) => {\n  test.slow(browserName === 'webkit', 'WebKit video processing is slow')\n\n  await page.goto('/video-editor')\n})\n```\n\n### Custom Timeout\n\n```typescript\ntest('very long operation', async ({page}) => {\n  // Set specific timeout (in milliseconds)\n  test.setTimeout(120000) // 2 minutes\n\n  await page.goto('/long-operation')\n})\n\n// Timeout for describe block\ntest.describe('Integration tests', () => {\n  test.describe.configure({timeout: 60000})\n\n  test('test 1', async ({page}) => {\n    // Has 60 second timeout\n  })\n})\n```\n\n## Test Steps\n\n### Basic Steps\n\n```typescript\ntest('checkout flow', async ({page}) => {\n  await test.step('Add item to cart', async () => {\n    await page.goto('/products')\n    await page.getByRole('button', {name: 'Add to Cart'}).click()\n  })\n\n  await test.step('Go to checkout', async () => {\n    await page.getByRole('link', {name: 'Cart'}).click()\n    await page.getByRole('button', {name: 'Checkout'}).click()\n  })\n\n  await test.step('Fill shipping info', async () => {\n    await page.getByLabel('Address').fill('123 Test St')\n    await page.getByLabel('City').fill('Test City')\n  })\n\n  await test.step('Complete payment', async () => {\n    await page.getByLabel('Card').fill('4242424242424242')\n    await page.getByRole('button', {name: 'Pay'}).click()\n  })\n\n  await expect(page.getByText('Order confirmed')).toBeVisible()\n})\n```\n\n### Nested Steps\n\n```typescript\ntest('user registration', async ({page}) => {\n  await test.step('Fill registration form', async () => {\n    await page.goto('/register')\n\n    await test.step('Personal info', async () => {\n      await page.getByLabel('Name').fill('John Doe')\n      await page.getByLabel('Email').fill('john@example.com')\n    })\n\n    await test.step('Security', async () => {\n      await page.getByLabel('Password').fill('SecurePass123')\n      await page.getByLabel('Confirm Password').fill('SecurePass123')\n    })\n  })\n\n  await test.step('Submit and verify', async () => {\n    await page.getByRole('button', {name: 'Register'}).click()\n    await expect(page.getByText('Welcome')).toBeVisible()\n  })\n})\n```\n\n### Steps with Return Values\n\n```typescript\ntest('verify order', async ({page}) => {\n  const orderId = await test.step('Create order', async () => {\n    await page.goto('/checkout')\n    await page.getByRole('button', {name: 'Place Order'}).click()\n\n    // Return value from step\n    return await page.getByTestId('order-id').textContent()\n  })\n\n  await test.step('Verify order details', async () => {\n    await page.goto(`/orders/${orderId}`)\n    await expect(page.getByText(`Order #${orderId}`)).toBeVisible()\n  })\n})\n```\n\n### Step in Page Object\n\n```typescript\n// pages/checkout.page.ts\nexport class CheckoutPage {\n  async fillShippingInfo(address: string, city: string) {\n    await test.step('Fill shipping information', async () => {\n      await this.page.getByLabel('Address').fill(address)\n      await this.page.getByLabel('City').fill(city)\n    })\n  }\n\n  async completePayment(cardNumber: string) {\n    await test.step('Complete payment', async () => {\n      await this.page.getByLabel('Card').fill(cardNumber)\n      await this.page.getByRole('button', {name: 'Pay'}).click()\n    })\n  }\n}\n```\n\n## Custom Annotations\n\n### Add Annotations\n\n```typescript\ntest('important feature', async ({page}, testInfo) => {\n  // Add custom annotation\n  testInfo.annotations.push({\n    type: 'priority',\n    description: 'high',\n  })\n\n  testInfo.annotations.push({\n    type: 'ticket',\n    description: 'JIRA-123',\n  })\n\n  await page.goto('/feature')\n})\n```\n\n### Annotation Fixture\n\n```typescript\n// fixtures/annotations.fixture.ts\nimport {test as base, TestInfo} from '@playwright/test'\n\ntype AnnotationFixtures = {\n  annotate: {\n    ticket: (id: string) => void\n    priority: (level: 'low' | 'medium' | 'high') => void\n    owner: (name: string) => void\n  }\n}\n\nexport const test = base.extend<AnnotationFixtures>({\n  annotate: async ({}, use, testInfo) => {\n    await use({\n      ticket: (id) => {\n        testInfo.annotations.push({type: 'ticket', description: id})\n      },\n      priority: (level) => {\n        testInfo.annotations.push({type: 'priority', description: level})\n      },\n      owner: (name) => {\n        testInfo.annotations.push({type: 'owner', description: name})\n      },\n    })\n  },\n})\n\n// Usage\ntest('critical feature', async ({page, annotate}) => {\n  annotate.ticket('JIRA-456')\n  annotate.priority('high')\n  annotate.owner('Alice')\n\n  await page.goto('/critical')\n})\n```\n\n### Read Annotations in Reporter\n\n```typescript\n// reporters/annotation-reporter.ts\nimport {Reporter, TestCase, TestResult} from '@playwright/test/reporter'\n\nclass AnnotationReporter implements Reporter {\n  onTestEnd(test: TestCase, result: TestResult) {\n    const ticket = test.annotations.find((a) => a.type === 'ticket')\n    const priority = test.annotations.find((a) => a.type === 'priority')\n\n    if (ticket) {\n      console.log(`Test linked to: ${ticket.description}`)\n    }\n\n    if (priority?.description === 'high' && result.status === 'failed') {\n      console.log(`HIGH PRIORITY FAILURE: ${test.title}`)\n    }\n  }\n}\n\nexport default AnnotationReporter\n```\n\n## Conditional Annotations\n\n### Annotation Helper\n\n```typescript\n// helpers/test-annotations.ts\nimport {test} from '@playwright/test'\n\nexport function skipInCI(reason = 'Skipped in CI') {\n  test.skip(!!process.env.CI, reason)\n}\n\nexport function skipInBrowser(browser: string, reason: string) {\n  test.beforeEach(({browserName}) => {\n    test.skip(browserName === browser, reason)\n  })\n}\n\nexport function onlyInEnv(env: string) {\n  test.skip(process.env.ENV !== env, `Only runs in ${env}`)\n}\n```\n\n```typescript\n// tests/feature.spec.ts\nimport {skipInCI, onlyInEnv} from '../helpers/test-annotations'\n\ntest('local only feature', async ({page}) => {\n  skipInCI('Uses local resources')\n\n  await page.goto('/local-feature')\n})\n\ntest('production check', async ({page}) => {\n  onlyInEnv('production')\n\n  await page.goto('/prod-only')\n})\n```\n\n### Describe-Level Conditions\n\n```typescript\ntest.describe('Mobile features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(!isMobile, 'Mobile only tests')\n  })\n\n  test('touch gestures', async ({page}) => {\n    // Only runs on mobile\n  })\n})\n\ntest.describe('Desktop features', () => {\n  test.beforeEach(({isMobile}) => {\n    test.skip(isMobile, 'Desktop only tests')\n  })\n\n  test('hover interactions', async ({page}) => {\n    // Only runs on desktop\n  })\n})\n```\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                | Problem                | Solution                         |\n| --------------------------- | ---------------------- | -------------------------------- |\n| Skipping without reason     | Hard to track why      | Always provide description       |\n| Too many skipped tests      | Test debt accumulates  | Review and clean up regularly    |\n| Using skip instead of fixme | Loses intent           | Use fixme for bugs, skip for N/A |\n| Not using steps             | Hard to debug failures | Group logical actions in steps   |\n\n## Related References\n\n- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`\n- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests\n- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n\n<!-- File: .agents/skills/playwright-best-practices/core/assertions-waiting.md (Tokens: ~2110 | Category: anthropic-skill) -->\n# Assertions & Waiting\n\n## Table of Contents\n\n1. [Web-First Assertions](#web-first-assertions)\n2. [Generic Assertions](#generic-assertions)\n3. [Soft Assertions](#soft-assertions)\n4. [Waiting Strategies](#waiting-strategies)\n5. [Polling & Retrying](#polling--retrying)\n6. [Custom Matchers](#custom-matchers)\n\n## Web-First Assertions\n\nAuto-retry until condition is met or timeout. Always prefer these over generic assertions.\n\n### Locator Assertions\n\n```typescript\nimport {expect} from '@playwright/test'\n\n// Visibility\nawait expect(page.getByRole('button')).toBeVisible()\nawait expect(page.getByRole('button')).toBeHidden()\nawait expect(page.getByRole('button')).not.toBeVisible()\n\n// Enabled/Disabled\nawait expect(page.getByRole('button')).toBeEnabled()\nawait expect(page.getByRole('button')).toBeDisabled()\n\n// Text content\nawait expect(page.getByRole('heading')).toHaveText('Welcome')\nawait expect(page.getByRole('heading')).toHaveText(/welcome/i)\nawait expect(page.getByRole('heading')).toContainText('Welcome')\n\n// Count\nawait expect(page.getByRole('listitem')).toHaveCount(5)\n\n// Attributes\nawait expect(page.getByRole('link')).toHaveAttribute('href', '/home')\nawait expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)\n\n// CSS\nawait expect(page.getByRole('button')).toHaveClass(/primary/)\nawait expect(page.getByRole('button')).toHaveCSS('color', 'rgb(0, 0, 255)')\n\n// Input values\nawait expect(page.getByLabel('Email')).toHaveValue('user@example.com')\nawait expect(page.getByLabel('Email')).toBeEmpty()\n\n// Focus\nawait expect(page.getByLabel('Email')).toBeFocused()\n\n// Checked state\nawait expect(page.getByRole('checkbox')).toBeChecked()\nawait expect(page.getByRole('checkbox')).not.toBeChecked()\n\n// Editable state\nawait expect(page.getByLabel('Name')).toBeEditable()\n```\n\n### Page Assertions\n\n```typescript\n// URL\nawait expect(page).toHaveURL('/dashboard')\nawait expect(page).toHaveURL(/\\/dashboard/)\n\n// Title\nawait expect(page).toHaveTitle('Dashboard - MyApp')\nawait expect(page).toHaveTitle(/dashboard/i)\n```\n\n### Response Assertions\n\n```typescript\nconst response = await page.request.get('/api/users')\nawait expect(response).toBeOK()\nawait expect(response).not.toBeOK()\n```\n\n## Generic Assertions\n\nUse for non-UI values. Do NOT retry - execute immediately.\n\n```typescript\n// Equality\nexpect(value).toBe(5)\nexpect(object).toEqual({name: 'Test'})\nexpect(array).toContain('item')\n\n// Truthiness\nexpect(value).toBeTruthy()\nexpect(value).toBeFalsy()\nexpect(value).toBeNull()\nexpect(value).toBeUndefined()\nexpect(value).toBeDefined()\n\n// Numbers\nexpect(value).toBeGreaterThan(5)\nexpect(value).toBeLessThanOrEqual(10)\nexpect(value).toBeCloseTo(5.5, 1)\n\n// Strings\nexpect(string).toMatch(/pattern/)\nexpect(string).toContain('substring')\n\n// Arrays/Objects\nexpect(array).toHaveLength(3)\nexpect(object).toHaveProperty('key', 'value')\n\n// Exceptions\nexpect(() => fn()).toThrow()\nexpect(() => fn()).toThrow('error message')\nawait expect(asyncFn()).rejects.toThrow()\n```\n\n## Soft Assertions\n\nContinue test execution after failure, report all failures at end.\n\n```typescript\ntest('check multiple elements', async ({page}) => {\n  await page.goto('/dashboard')\n\n  // Won't stop on first failure\n  await expect.soft(page.getByRole('heading')).toHaveText('Dashboard')\n  await expect.soft(page.getByRole('button', {name: 'Save'})).toBeEnabled()\n  await expect.soft(page.getByText('Welcome')).toBeVisible()\n\n  // Test continues; all failures reported at end\n})\n```\n\n### Soft Assertions with Early Exit\n\n```typescript\ntest('check form', async ({page}) => {\n  await expect.soft(page.getByRole('form')).toBeVisible()\n\n  // Exit early if form not visible (pointless to check fields)\n  if (expect.soft.hasFailures()) {\n    return\n  }\n\n  await expect.soft(page.getByLabel('Name')).toBeVisible()\n  await expect.soft(page.getByLabel('Email')).toBeVisible()\n})\n```\n\n## Waiting Strategies\n\n### Auto-Waiting (Default)\n\nActions automatically wait for:\n\n- Element to be attached to DOM\n- Element to be visible\n- Element to be stable (no animations)\n- Element to be enabled\n- Element to receive events\n\n```typescript\n// These auto-wait\nawait page.click('button')\nawait page.fill('input', 'text')\nawait page.getByRole('button').click()\n```\n\n### Wait for Navigation\n\n```typescript\n// Wait for URL change\nawait page.waitForURL('/dashboard')\nawait page.waitForURL(/\\/dashboard/)\n\n// Wait for navigation after action\nawait Promise.all([page.waitForURL('**/dashboard'), page.click('a[href=\"/dashboard\"]')])\n\n// Or without Promise.all\nconst urlPromise = page.waitForURL('**/dashboard')\nawait page.click('a')\nawait urlPromise\n```\n\n### Wait for Network\n\n```typescript\n// Wait for specific response\nconst responsePromise = page.waitForResponse('**/api/users')\nawait page.click('button')\nconst response = await responsePromise\nexpect(response.status()).toBe(200)\n\n// Wait for request\nconst requestPromise = page.waitForRequest('**/api/submit')\nawait page.click('button')\nconst request = await requestPromise\n\n// Wait for no network activity\nawait page.waitForLoadState('networkidle')\n```\n\n### Wait for Element State\n\n```typescript\n// Wait for element to appear\nawait page.getByRole('dialog').waitFor({state: 'visible'})\n\n// Wait for element to disappear\nawait page.getByText('Loading...').waitFor({state: 'hidden'})\n\n// Wait for element to be attached\nawait page.getByTestId('result').waitFor({state: 'attached'})\n\n// Wait for element to be detached\nawait page.getByTestId('modal').waitFor({state: 'detached'})\n```\n\n### Wait for Function\n\n```typescript\n// Wait for arbitrary condition\nawait page.waitForFunction(() => {\n  return document.querySelector('.loaded') !== null\n})\n\n// With arguments\nawait page.waitForFunction(\n  (selector) => document.querySelector(selector)?.textContent === 'Ready',\n  '.status',\n)\n```\n\n## Polling & Retrying\n\n### toPass() for Polling\n\nRetry until block passes or times out:\n\n```typescript\nawait expect(async () => {\n  const response = await page.request.get('/api/status')\n  expect(response.status()).toBe(200)\n\n  const data = await response.json()\n  expect(data.ready).toBe(true)\n}).toPass({\n  intervals: [1000, 2000, 5000], // Retry intervals\n  timeout: 30000,\n})\n```\n\n### expect.poll()\n\nPoll a function until assertion passes:\n\n```typescript\n// Poll API until condition met\nawait expect\n  .poll(\n    async () => {\n      const response = await page.request.get('/api/job/123')\n      return (await response.json()).status\n    },\n    {\n      intervals: [1000, 2000, 5000],\n      timeout: 30000,\n    },\n  )\n  .toBe('completed')\n\n// Poll DOM value\nawait expect.poll(() => page.getByTestId('counter').textContent()).toBe('10')\n```\n\n## Custom Matchers\n\n```typescript\n// playwright.config.ts or fixtures\nimport {expect} from '@playwright/test'\n\nexpect.extend({\n  async toHaveDataLoaded(page: Page) {\n    const locator = page.getByTestId('data-container')\n    let pass = false\n    let message = ''\n\n    try {\n      await expect(locator).toBeVisible()\n      await expect(locator).not.toContainText('Loading')\n      pass = true\n    } catch (e) {\n      message = `Expected data to be loaded but found loading state`\n    }\n\n    return {pass, message: () => message}\n  },\n})\n\n// Extend TypeScript types\ndeclare global {\n  namespace PlaywrightTest {\n    interface Matchers<R> {\n      toHaveDataLoaded(): Promise<R>\n    }\n  }\n}\n\n// Usage\nawait expect(page).toHaveDataLoaded()\n```\n\n## Timeouts\n\n### Configure Timeouts\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  timeout: 30000, // Test timeout\n  expect: {\n    timeout: 5000, // Assertion timeout\n  },\n})\n\n// Per-test timeout\ntest('long test', async ({page}) => {\n  test.setTimeout(60000)\n  // ...\n})\n\n// Per-assertion timeout\nawait expect(page.getByRole('button')).toBeVisible({timeout: 10000})\n```\n\n## Best Practices\n\n| Do                             | Don't                          |\n| ------------------------------ | ------------------------------ |\n| Use web-first assertions       | Use generic assertions for DOM |\n| Let auto-waiting work          | Add unnecessary explicit waits |\n| Use `toPass()` for polling     | Write manual retry loops       |\n| Configure appropriate timeouts | Use `waitForTimeout()`         |\n| Check specific conditions      | Wait for arbitrary time        |\n\n## Anti-Patterns to Avoid\n\n| Anti-Pattern                                              | Problem                       | Solution                                     |\n| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |\n| `await page.waitForTimeout(5000)`                         | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse`        |\n| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above                 | Use `waitForResponse` or element state waits |\n| Generic assertions on DOM elements                        | No auto-retry, flaky          | Use web-first assertions with `expect()`     |\n\n## Related References\n\n- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting\n- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions\n- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking\n\n<!-- File: .agents/skills/playwright-best-practices/core/configuration.md (Tokens: ~3404 | Category: anthropic-skill) -->\n# Playwright Configuration\n\n## Table of Contents\n\n1. [CLI Quick Reference](#cli-quick-reference)\n2. [Decision Guide](#decision-guide)\n3. [Production-Ready Config](#production-ready-config)\n4. [Patterns](#patterns)\n5. [Anti-Patterns](#anti-patterns)\n6. [Troubleshooting](#troubleshooting)\n7. [Related](#related)\n\n> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.\n\n## CLI Quick Reference\n\n```bash\nnpx playwright init                           # scaffold config + first test\nnpx playwright test --config=custom.config.ts # use alternate config\nnpx playwright test --project=chromium        # run single project\nnpx playwright test --reporter=html           # override reporter\nnpx playwright test --grep @smoke             # run tests tagged @smoke\nnpx playwright test --grep-invert @slow       # exclude @slow tests\nnpx playwright show-report                    # open last HTML report\nDEBUG=pw:api npx playwright test              # verbose logging\n```\n\n## Decision Guide\n\n### Timeout Selection\n\n| Symptom                                   | Setting             | Default       | Recommended       |\n| ----------------------------------------- | ------------------- | ------------- | ----------------- |\n| Test takes too long overall               | `timeout`           | 30s           | 30-60s (max 120s) |\n| Assertion retries too long/short          | `expect.timeout`    | 5s            | 5-10s             |\n| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s           | 10-30s            |\n| `click()`, `fill()` time out              | `actionTimeout`     | 0 (unlimited) | 10-15s            |\n| Dev server slow to start                  | `webServer.timeout` | 60s           | 60-180s           |\n\n### Server Management\n\n| Scenario                     | Approach                                                |\n| ---------------------------- | ------------------------------------------------------- |\n| App in same repo             | `webServer` with `reuseExistingServer: !process.env.CI` |\n| Separate repos               | Manual start or Docker Compose                          |\n| Testing deployed environment | No `webServer`; set `baseURL` via env                   |\n| Multiple services            | Array of `webServer` entries                            |\n\n### Single vs Multi-Project\n\n| Scenario               | Approach                                   |\n| ---------------------- | ------------------------------------------ |\n| Early development      | Single project (chromium only)             |\n| Pre-release validation | Multi-project: chromium + firefox + webkit |\n| Mobile-responsive app  | Add mobile projects alongside desktop      |\n| Auth + non-auth tests  | Setup project with dependencies            |\n| Tight CI budget        | Chromium on PRs; all browsers on main      |\n\n### globalSetup vs Setup Projects vs Fixtures\n\n| Need                    | Use                                |\n| ----------------------- | ---------------------------------- |\n| One-time DB seed        | `globalSetup`                      |\n| Shared browser auth     | Setup project with `dependencies`  |\n| Per-test isolated state | Custom fixture via `test.extend()` |\n| Cleanup after all tests | `globalTeardown`                   |\n\n## Production-Ready Config\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\nimport dotenv from 'dotenv'\n\ndotenv.config({path: path.resolve(__dirname, '.env')})\n\nexport default defineConfig({\n  testDir: './e2e',\n  testMatch: '**/*.spec.ts',\n\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? '50%' : undefined,\n\n  reporter: process.env.CI\n    ? [['html', {open: 'never'}], ['github']]\n    : [['html', {open: 'on-failure'}]],\n\n  timeout: 30_000,\n  expect: {timeout: 5_000},\n\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:4000',\n    actionTimeout: 10_000,\n    navigationTimeout: 15_000,\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'retain-on-failure',\n    locale: 'en-US',\n    timezoneId: 'America/Los_Angeles',\n  },\n\n  projects: [\n    {name: 'chromium', use: {...devices['Desktop Chrome']}},\n    {name: 'firefox', use: {...devices['Desktop Firefox']}},\n    {name: 'webkit', use: {...devices['Desktop Safari']}},\n    {name: 'mobile-chrome', use: {...devices['Pixel 7']}},\n    {name: 'mobile-safari', use: {...devices['iPhone 14']}},\n  ],\n\n  webServer: {\n    command: 'npm run start',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    stdout: 'pipe',\n    stderr: 'pipe',\n  },\n})\n```\n\n## Patterns\n\n### Environment-Specific Configuration\n\n**Use when**: Tests run against dev, staging, and production environments.\n\n```ts\nimport path from 'path'\n\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\nimport dotenv from 'dotenv'\n\nconst ENV = process.env.TEST_ENV || 'local'\ndotenv.config({path: path.resolve(__dirname, `.env.${ENV}`)})\n\nconst envConfig: Record<string, {baseURL: string; retries: number}> = {\n  local: {baseURL: 'http://localhost:4000', retries: 0},\n  staging: {baseURL: 'https://staging.myapp.com', retries: 2},\n  prod: {baseURL: 'https://myapp.com', retries: 2},\n}\n\nexport default defineConfig({\n  testDir: './e2e',\n  retries: envConfig[ENV].retries,\n  use: {baseURL: envConfig[ENV].baseURL},\n})\n```\n\n```bash\nTEST_ENV=staging npx playwright test\nTEST_ENV=prod npx playwright test --grep @smoke\n```\n\n### Setup Project with Dependencies\n\n**Use when**: Tests need shared authentication state before running.\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'setup',\n      testMatch: /auth\\.setup\\.ts/,\n    },\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: {\n        ...devices['Desktop Firefox'],\n        storageState: 'playwright/.auth/session.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n})\n```\n\n```ts\n// e2e/auth.setup.ts\nimport {test as setup, expect} from '@playwright/test'\n\nconst authFile = 'playwright/.auth/session.json'\n\nsetup('authenticate', async ({page}) => {\n  await page.goto('/login')\n  await page.getByLabel('Username').fill('testuser@example.com')\n  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)\n  await page.getByRole('button', {name: 'Log in'}).click()\n  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()\n  await page.context().storageState({path: authFile})\n})\n```\n\n### webServer with Build Step\n\n**Use when**: Tests need a running application server managed by Playwright.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {baseURL: 'http://localhost:4000'},\n  webServer: {\n    command: process.env.CI ? 'npm run build && npm run preview' : 'npm run dev',\n    url: 'http://localhost:4000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n    env: {\n      NODE_ENV: 'test',\n      DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',\n    },\n  },\n})\n```\n\n### globalSetup / globalTeardown\n\n**Use when**: One-time non-browser work like seeding a database. Runs once per test run.\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  globalSetup: './e2e/setup.ts',\n  globalTeardown: './e2e/teardown.ts',\n})\n```\n\n```ts\n// e2e/setup.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalSetup(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db seed', {stdio: 'inherit'})\n  process.env.TEST_RUN_ID = `run-${Date.now()}`\n}\n```\n\n```ts\n// e2e/teardown.ts\nimport {FullConfig} from '@playwright/test'\n\nexport default async function globalTeardown(config: FullConfig) {\n  const {execSync} = await import('child_process')\n  execSync('npx prisma db push --force-reset', {stdio: 'inherit'})\n}\n```\n\n### Environment Variables with .env\n\n**Use when**: Managing secrets, URLs, or feature flags without hardcoding.\n\n```bash\n# .env.example (commit this)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=\nAPI_KEY=\n\n# .env.local (gitignored)\nBASE_URL=http://localhost:4000\nTEST_PASSWORD=secret123\nAPI_KEY=dev-key-abc\n\n# .env.staging (gitignored)\nBASE_URL=https://staging.myapp.com\nTEST_PASSWORD=staging-pass\nAPI_KEY=staging-key-xyz\n```\n\n```bash\n# .gitignore\n.env\n.env.local\n.env.staging\n.env.production\nplaywright/.auth/\n```\n\nInstall dotenv:\n\n```bash\nnpm install -D dotenv\n```\n\n### Tag-Based Test Filtering\n\n**Use when**: Running subsets of tests in different CI stages (PR vs nightly).\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n\n  // Filter by tags in CI\n  grep: process.env.CI ? /@smoke|@critical/ : undefined,\n  grepInvert: process.env.CI ? /@flaky/ : undefined,\n})\n```\n\n**Project-specific filtering:**\n\n```ts\n// playwright.config.ts\nimport {defineConfig, devices} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  projects: [\n    {\n      name: 'smoke',\n      grep: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'regression',\n      grepInvert: /@smoke/,\n      use: {...devices['Desktop Chrome']},\n    },\n    {\n      name: 'critical-only',\n      grep: /@critical/,\n      use: {...devices['Desktop Chrome']},\n    },\n  ],\n})\n```\n\n```bash\n# Run specific project\nnpx playwright test --project=smoke\nnpx playwright test --project=regression\n```\n\n### Artifact Collection Strategy\n\n| Setting      | Local   | CI                    | Reason                                    |\n| ------------ | ------- | --------------------- | ----------------------------------------- |\n| `trace`      | `'off'` | `'on-first-retry'`    | Traces are large; collect on failure only |\n| `screenshot` | `'off'` | `'only-on-failure'`   | Useful for CI debugging                   |\n| `video`      | `'off'` | `'retain-on-failure'` | Recording slows tests                     |\n\n```ts\n// playwright.config.ts\nimport {defineConfig} from '@playwright/test'\n\nexport default defineConfig({\n  testDir: './e2e',\n  use: {\n    trace: process.env.CI ? 'on-first-retry' : 'off',\n    screenshot: process.env.CI ? 'only-on-failure' : 'off',\n    video: process.env.CI ? 'retain-on-failure' : 'off',\n  },\n})\n```\n\n## Anti-Patterns\n\n| Don't                                                            | Problem                                | Do Instead                          |\n| ---------------------------------------------------------------- | -------------------------------------- | ----------------------------------- |\n| `timeout: 300_000` globally                                      | Masks flaky tests; slow CI             | Fix root cause; keep 30s default    |\n| Hardcoded URLs: `page.goto('http://localhost:4000/login')`       | Breaks in other environments           | Use `baseURL` + relative paths      |\n| All browsers on every PR                                         | 3x CI time                             | Chromium on PRs; all on main        |\n| `trace: 'on'` always                                             | Huge artifacts, slow uploads           | `trace: 'on-first-retry'`           |\n| `video: 'on'` always                                             | Massive storage; slow tests            | `video: 'retain-on-failure'`        |\n| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent                | Define once in project config       |\n| `retries: 3` locally                                             | Hides flakiness                        | `retries: 0` local, `retries: 2` CI |\n| No `forbidOnly` in CI                                            | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI`      |\n| `globalSetup` for browser auth                                   | No browser context available           | Use setup project with dependencies |\n| Committing `.env` with credentials                               | Security risk                          | Commit `.env.example` only          |\n\n## Troubleshooting\n\n### baseURL Not Working\n\n**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.\n\n```ts\n// Wrong - ignores baseURL\nawait page.goto('http://localhost:4000/dashboard')\n\n// Correct - uses baseURL\nawait page.goto('/dashboard')\n```\n\n### webServer Starts But Tests Get Connection Refused\n\n**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.\n\n```ts\nwebServer: {\n  command: 'npm run dev',\n  url: 'http://localhost:4000/api/health',  // use real endpoint\n  reuseExistingServer: !process.env.CI,\n  timeout: 120_000,\n},\n```\n\n### Tests Pass Locally But Timeout in CI\n\n**Cause**: CI machines are slower. Increase timeouts and reduce workers:\n\n```ts\nexport default defineConfig({\n  workers: process.env.CI ? '50%' : undefined,\n  use: {\n    navigationTimeout: process.env.CI ? 30_000 : 15_000,\n    actionTimeout: process.env.CI ? 15_000 : 10_000,\n  },\n})\n```\n\n### \"Target page, context or browser has been closed\"\n\n**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.\n\n**Fix**: Don't increase global timeout. Find slow step using trace:\n\n```bash\nnpx playwright test --trace on\nnpx playwright show-report\n```\n\n## Related\n\n- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`\n- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state\n- [test-suite-structure.md](test-suite-structure.md) - file structure and naming\n- [authentication.md](../advanced/authentication.md) - setup projects for shared auth\n- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns\n\n<!-- ... +165 more modular skills available via /api/skills/sanity-io/sanity?path={path} -->\n</yakaai_skills>"}