From 7b72fd56e6ce10665d499e90f03a19ec67fde943 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 19 Apr 2026 15:21:49 +0300 Subject: [PATCH 1/6] Add skills refactored from instructions --- .clinerules/project-standards.md | 15 ----- .github/instructions/astro.instructions.md | 15 ----- .github/instructions/css.instructions.md | 21 ------ .github/instructions/docs.instructions.md | 16 ----- .github/instructions/general.instructions.md | 63 ++---------------- .../instructions/javascript.instructions.md | 16 ----- .github/instructions/markdown.instructions.md | 66 ------------------- .github/instructions/testing.instructions.md | 40 ----------- .github/skills/css/SKILL.md | 32 +++++++++ .github/skills/css/references/examples.md | 14 ++++ .github/skills/docs-astro/SKILL.md | 27 ++++++++ .../skills/docs-astro/references/examples.md | 14 ++++ .github/skills/docs-markdown/SKILL.md | 28 ++++++++ .../docs-markdown/references/examples.md | 13 ++++ .github/skills/javascript-typescript/SKILL.md | 30 +++++++++ .../references/examples.md | 14 ++++ .github/skills/markdown-mermaid/SKILL.md | 51 ++++++++++++++ .../markdown-mermaid/references/examples.md | 16 +++++ .github/skills/testing/SKILL.md | 54 +++++++++++++++ .github/skills/testing/references/examples.md | 10 +++ .github/skills/view-transitions/SKILL.md | 38 +++++++++++ .../view-transitions/references/examples.md | 16 +++++ 22 files changed, 363 insertions(+), 246 deletions(-) delete mode 100644 .clinerules/project-standards.md delete mode 100644 .github/instructions/astro.instructions.md delete mode 100644 .github/instructions/css.instructions.md delete mode 100644 .github/instructions/docs.instructions.md delete mode 100644 .github/instructions/javascript.instructions.md delete mode 100644 .github/instructions/markdown.instructions.md delete mode 100644 .github/instructions/testing.instructions.md create mode 100644 .github/skills/css/SKILL.md create mode 100644 .github/skills/css/references/examples.md create mode 100644 .github/skills/docs-astro/SKILL.md create mode 100644 .github/skills/docs-astro/references/examples.md create mode 100644 .github/skills/docs-markdown/SKILL.md create mode 100644 .github/skills/docs-markdown/references/examples.md create mode 100644 .github/skills/javascript-typescript/SKILL.md create mode 100644 .github/skills/javascript-typescript/references/examples.md create mode 100644 .github/skills/markdown-mermaid/SKILL.md create mode 100644 .github/skills/markdown-mermaid/references/examples.md create mode 100644 .github/skills/testing/SKILL.md create mode 100644 .github/skills/testing/references/examples.md create mode 100644 .github/skills/view-transitions/SKILL.md create mode 100644 .github/skills/view-transitions/references/examples.md diff --git a/.clinerules/project-standards.md b/.clinerules/project-standards.md deleted file mode 100644 index c003a5116..000000000 --- a/.clinerules/project-standards.md +++ /dev/null @@ -1,15 +0,0 @@ -# Project Standards - -Follow the coding standards defined in `.github/instructions/`: - -- General standards: See `.github/instructions/general.instructions.md` -- Astro components: See `.github/instructions/astro.instructions.md` -- JavaScript/TypeScript: See `.github/instructions/javascript.instructions.md` -- Markdown: See `.github/instructions/markdown.instructions.md` - -Key rules: - -- Use TypeScript exclusively -- Never use manual HTML fixtures -- No apologizing, be direct/concise -- Wait for permission before implementing suggestions diff --git a/.github/instructions/astro.instructions.md b/.github/instructions/astro.instructions.md deleted file mode 100644 index fc6ab723f..000000000 --- a/.github/instructions/astro.instructions.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -applyTo: "docs/**/*.astro" ---- - -# Astro template standards - -- Always use TypeScript for type safety. -- Always put script in a separate file and import it. - -## Testing Astro Components - -- When testing Astro components, use the Astro Container API instead of manual HTML fixtures. -- This ensures tests stay in sync with component changes automatically. -- Reference: Astro Container API Documentation at docs.astro.build/en/reference/container-reference/ -- There is a model component and test demonstrating this pattern in the src/components/Test directory named webComponent. diff --git a/.github/instructions/css.instructions.md b/.github/instructions/css.instructions.md deleted file mode 100644 index 6ea09ecfb..000000000 --- a/.github/instructions/css.instructions.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -applyTo: "**/*.css" ---- - -# CSS rules (specificity budget) - -- Prefer a single class selector for styling (`.componentPart`). -- Avoid ID selectors (`#something`) in CSS; use classes/attributes instead. -- Avoid chaining state across unrelated roots (e.g., `#header .child.is-open ...`). Put state on the owning component root. -- Avoid long selector chains; if you need more specificity, add a single component class rather than stacking selectors. -- Avoid `!important` except in vendor CSS and print rules. - -- Do not use Tailwind `dark:` variant classes for dark-mode theming. This project uses a custom theme system and CSS variables; use the project's theme tokens and helper classes instead of Tailwind `dark:` utilities. - -- Do not use Tailwind `dark:` variant classes for dark-mode theming. This project uses a custom theme system and CSS variables; use the project's theme tokens and helper classes instead of Tailwind `dark:` utilities. - -# Z-index tokens - -- Do not hard-code numeric `z-index` values. -- Use the z-index tokens defined in `src/styles/index.css` (e.g., `z-index: var(--z-modal)`). -- If no existing token fits, ask the user what z-layer to use before adding a new token. diff --git a/.github/instructions/docs.instructions.md b/.github/instructions/docs.instructions.md deleted file mode 100644 index 0343b561e..000000000 --- a/.github/instructions/docs.instructions.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -applyTo: "docs/**/*.md" ---- - -# Documentation standards - -- Use clear and concise language. -- Ensure headings follow a consistent hierarchy. -- Use sentence case for all headings and titles. -- Use backticks for inline code snippets. -- Use fenced code blocks with language identifiers for code samples. -- Use bullet points or numbered lists for steps and features. -- Include links to relevant resources or documentation. -- Ensure proper spacing and indentation for readability. -- End files with a single newline character. -- Follow the [Markdown linting instructions](./markdown.instructions.md) for formatting consistency. diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md index 4f63ab973..1c1d93bba 100644 --- a/.github/instructions/general.instructions.md +++ b/.github/instructions/general.instructions.md @@ -40,85 +40,33 @@ applyTo: "**" # Code Organization and Directory Structure ## src/lib Directory Restrictions + - **The src/lib directory is for server-side build code ONLY** - NO client-side code can go in src/lib (it gets bundled into server-side builds) - Client-side utilities should go in src/components/scripts/ or appropriate component directories ## API Code Organization + - **API endpoints** go in `src/pages/api/` - **Code files related to API endpoints** go in `src/pages/api/` and are prefixed with `_` (e.g., `_utils/`, `_contracts/`) - **API utility files** go specifically in the `_utils/` folder - **API contract/type files** go in `_contracts/` folder for centralized type definitions ## API Endpoints (Permission Required) + - **Do not create, recreate, or restore `src/pages/api/*` endpoints without explicit user permission.** - Prefer Astro Actions (`/_actions/...`) for new backend behavior unless instructed otherwise. ## Mixed Concern Files + - Files that straddle server-side API and client-side concerns (like API client wrappers) require clarification - **Ask before placing such files** - they may need special handling or alternative organization - Example: gdpr.client.ts (API client wrapper) - unclear placement due to mixed server/client concerns -# Astro View Transitions Navigation - -Components may have behavior dependent on Astro View Transitions navigation events. Choose the appropriate navigation method: - -- **Fresh page load**: Use `page.goto(url)` for full browser navigation (no View Transitions, triggers full page lifecycle) -- **Client-side navigation**: Use `navigateToPage('/path')` for in-site navigation with View Transitions (triggers `astro:page-load` and other View Transition events) - -Always use the `navigateToPage()` method for client-side navigation - never ad-hoc `click('a[href]')` calls. This maintains centralized control. - -When a Playwright-native action (e.g., `page.click()`, `page.fill()`, `page.hover()`) is required, expose it through the shared `BasePage` helpers (e.g., `BasePage.click()`), then call that helper from tests instead of the raw Playwright API. This keeps all browser interactions centrally managed and makes future behavior changes (timeouts, logging, etc.) easier. - # Personality -# Testing Standards - -## Astro Component Testing - Container API (MANDATORY) - -- **NEVER use manual HTML strings in test files or fixtures.** They get out of sync with templates and are worse than no test at all. -- **ALWAYS use Astro's Container API** to create fixtures from actual .astro templates. See: https://docs.astro.build/en/reference/container-reference/ -- **Test fixtures MUST import actual components**, not duplicate HTML. Example: - ```astro - --- - import MyComponent from '@components/MyComponent/index.astro' - const { testProp } = Astro.props - --- - - ``` -- **Hard-coded HTML fixtures are FORBIDDEN.** If you find yourself writing HTML in a fixture, STOP and use the actual component instead. -- Reference the working example in src/components/Test/container.astro and its test file. -- Use experimental_AstroContainer.create() to instantiate the container. -- Use container.renderToString(Component) to get rendered HTML from actual Astro components. -- Configure Vitest with getViteConfig() from 'astro/config' to support Astro Container API. -- Test files should follow a client.spec.ts naming pattern or similar. -- Fixture files should follow a componentName.fixture.astro naming pattern (e.g., newsletter.fixture.astro). -- A working example test using the Container API is available at /home/kevin/Repos/Webstack Builders/Corporate Website/astro.webstackbuilders.com/src/components/Test/container.spec.ts - -## E2E Testing Standards - -- **NEVER hard-code content slugs in e2e tests** (e.g., `/articles/typescript-best-practices`, `/services/web-development`). Content can be deleted or renamed. Always dynamically fetch the first available item from listing pages (articles, services, case-studies, etc.) and navigate to it. This prevents test breakage when content changes. -- **Playwright E2E Tests**: ALWAYS run with `CI=1` and `FORCE_COLOR=1` environment variables (e.g., `CI=1 FORCE_COLOR=1 npx playwright test`). This prevents the Playwright test runner from launching its own dev server. The user maintains a running dev server for development. -- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `CI=1 FORCE_COLOR=1 npx playwright test test/e2e/specific-file.spec.ts`). -- **NEVER start a dev server yourself**. The user runs their own dev server for development. When you need a dev server running, notify the user instead of starting one. - -### Astro View Transitions Testing - -- **Navigation method matters**: Choose between `page.goto()` and Astro's client-side navigation based on what you're testing: - - Use `page.goto(url)` for testing **fresh page loads** (full browser navigation, no View Transitions) - - Use `BasePage.click()` on navigation links or `BasePage.navigateToPage()` for testing **View Transitions** (client-side navigation within the site) so that all clicks flow through the centralized helpers -- **Wait for page load properly**: Use BasePage's `waitForPageLoad()` method to wait for `astro:page-load` event instead of arbitrary timeouts -- **NEVER use `page.waitForTimeout()`** for waiting on View Transitions - it's unreliable and slows tests. Use event-based waits instead -- **transition:persist directive**: Must be applied directly to HTML elements (including custom elements), not on Astro component wrappers. Example: - ```astro - - - - - - ``` - # Personality + - Do not apologize - Do not flatter me - Do not use superlatives lke "absolutely" @@ -126,6 +74,7 @@ When a Playwright-native action (e.g., `page.click()`, `page.fill()`, `page.hove - Be direct # Response Guidelines + - When the user asks "What would you suggest", "what do you recommend", or similar language, provide multiple options with clear explanations but do NOT begin implementation - Always wait for explicit permission before implementing suggested changes - Present suggestions as numbered options with pros/cons when applicable diff --git a/.github/instructions/javascript.instructions.md b/.github/instructions/javascript.instructions.md deleted file mode 100644 index d219164cd..000000000 --- a/.github/instructions/javascript.instructions.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -applyTo: "**/*.{js,jsx,ts,tsx}" ---- - -# JavaScript and TypeScript standards - -- Use `const` and `let` instead of `var`. -- Prefer arrow functions for anonymous functions. -- Use JSDoc for all public functions and components. -- Do not add semicolons at the end of statements (semicolon-free style). - -# Playwright E2E timeout policy (test/e2e/**) - -- For Playwright E2E code under `test/e2e/**` (spec files and page object models), **do not** introduce new numeric timeouts (e.g. `{ timeout: 5000 }`, `waitForTimeout(600)`, `test.setTimeout(…)`). -- Always choose an existing timeout knob from `test/e2e/helpers/waitTimeouts.ts` (use `import { wait } ...` and `wait.*`). -- If none of the existing knobs fit the use case, **stop and ask** what to do (e.g. whether to add a new `wait.bespoke*` knob, refactor to an event-based wait, or change the underlying behavior). \ No newline at end of file diff --git a/.github/instructions/markdown.instructions.md b/.github/instructions/markdown.instructions.md deleted file mode 100644 index 8ead227e7..000000000 --- a/.github/instructions/markdown.instructions.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -applyTo: "**/*.{md,mdx}" ---- - -# Markdown Linting Instructions - -## General Guidelines - -- For all Markdown files (`.md`), ensure that generated content adheres to the `markdownlint` rules configured for this project. -- List items should be correctly indented (`MD007`). -- No Multiple consecutive blank lines (`MD012`) -- Always use a blank line after a heading (`MD022`). -- Always add a blank line before and after lists (`MD032`). -- Avoid using backslashes for line breaks; use two spaces instead (`MD030`). -- Ensure all inline code blocks are surrounded by backticks (`MD046`). -- Bullet lists should be consistent (e.g., use `*` or `-`, but not both) (`MD044`). -- Files should end with a single newline character (`MD047`). -- Bare URLs should be wrapped in angle brackets (`MD034`). For example, use `` instead of `https://example.com`. -- Headings should not have trailing punctuation (`MD026`). Remove colons, periods, or other punctuation from the end of headings. -- All fenced code blocks must have a language specified (`MD040`). Use appropriate languages like `js`, `typescript`, `yaml`, `json`, `text`, `bash`, etc. -- Fenced code blocks should be surrounded by blank lines (`MD031`). -- All fenced code blocks must have a language specified (`MD040`). Use appropriate languages like `js`, `typescript`, `yaml`, `json`, `text`, `bash`, etc. This rule is already mentioned above but bears repeating. -- Lists should be surrounded by blank lines (`MD032`). Always add a blank line before and after lists (both ordered and unordered). -- Ordered lists should use consistent numbering (`MD029`). Either use sequential numbering (1, 2, 3) or all ones (1, 1, 1). Do not mix styles (e.g., 1, 2, 3, 4 is correct; 1, 2, 3, 4 where one item is numbered 2 when it should be 3 is incorrect). -- No trailing spaces on lines (`MD009`), except when intentionally using two trailing spaces for a line break. - -## MDX And Inline Code Safety - -- When you mean the less-than operator (e.g. `a < b`), do not write a raw `<` in prose. - - Prefer `<` (and `>` when needed), or wrap the expression in backticks. -- Always wrap code-y snippets in backticks so the Markdown/MDX parser doesn’t treat them as HTML/JSX. - - Examples: `{ param, ... }`, `generic`, `Array`, `T extends Foo`. - -## Mermaid Diagram Authoring - -- This repo renders Mermaid during builds; invalid or unsupported Mermaid syntax will fail `npm run build`. -- Subgraphs: - - Do not use quoted subgraph titles like `subgraph "Title"`. - - Prefer a simple subgraph id with a simple label in brackets: `subgraph myGroup[My Group]`. - - Avoid punctuation-heavy labels in subgraph brackets (parentheses, `+`, `%`, em-dashes). Keep labels plain words. -- Labels: - - Avoid double quotes inside node labels like `A[When is a trace "complete"?]`. - - Avoid `@` in node labels (it can be parsed as a reserved token in some Mermaid flowchart grammars). -- Diagram types: - - Do not use `quadrantChart` (not supported by the current renderer); use `graph TD` (or another supported type) instead. - -## How to Apply These Rules - -- If you are generating a new Markdown file, follow these rules from the beginning. -- If you are asked to refactor or fix a Markdown file, correct any issues that violate these guidelines. For example, if a list is inconsistently formatted, make it consistent. -- If a heading is missing a blank line after it, add one. -- Always add blank lines before and after lists to ensure proper spacing. -- When creating fenced code blocks, always specify the appropriate language identifier. -- Ensure all files end with exactly one newline character. -- In code reviews, flag any violations of these rules as a suggestion. - -## Code Block Language Guidelines - -- Use `js` for JavaScript code -- Use `typescript` or `ts` for TypeScript code -- Use `yaml` for YAML configuration files -- Use `json` for JSON data -- Use `bash` or `shell` for terminal commands -- Use `text` for plain text output or file structures -- Use `astro` for Astro component code -- Use appropriate language identifiers for other code types diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md deleted file mode 100644 index 8fe11a5bb..000000000 --- a/.github/instructions/testing.instructions.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -applyTo: "**/*.spec.ts" ---- - -# Testing Standards - -## Astro Container API (Unit Tests) - -- **NEVER use manual HTML strings** - use Astro's Container API with actual .astro templates -- **Test fixtures MUST import actual components**, not duplicate HTML -- Naming: `filename.spec.ts` for tests, `componentName.fixture.astro` for fixtures -- Working example: `src/components/Test/__tests__/webComponent.spec.ts` - -## E2E Testing - -- **NEVER hard-code content slugs** - fetch dynamically from listing pages -- **Always run with `CI=1` and `FORCE_COLOR=1`** - e.g., `CI=1 FORCE_COLOR=1 npx playwright test test/e2e/file.spec.ts` -- **NEVER run full e2e suite** unless requested - it takes 10+ minutes -- **NEVER start dev server** - user maintains running server -- **Use `BasePage.waitForPageLoad()`** to wait for `astro:page-load` event -- **NEVER use `waitForTimeout()`** - use event-based waits -- **NEVER use ad-hoc numeric timeouts** in E2E specs or page objects - use `wait.*` from `test/e2e/helpers/waitTimeouts.ts`. -- If no existing `wait.*` knob fits, **ask what to do** before adding a new `wait.bespoke*` knob. -- **Avoid `waitForLoadState('networkidle')` for gating**: WebKit/mobile-safari can hang indefinitely due to long-lived requests. - - Prefer deterministic readiness signals: `waitForSelector`, `waitForFunction` on app-ready attributes, or `BasePage.waitForPageLoad()`. - - If you truly need a network-idle-ish gate, use `BasePage.waitForNetworkIdleBestEffort()`. -- **transition:persist**: Apply to HTML elements in component definition, not on component usage - -## Vite Optimized Deps (E2E Determinism) - -- If Playwright E2E becomes flaky due to Vite "optimized deps" issues (e.g., stale/outdated optimized modules, wrong MIME type, 504 "Outdated Optimize Dep"), fix it by: - - Adding the problematic package/entrypoint to `vite.optimizeDeps.include` in `astro.config.ts` - - Enabling `vite.optimizeDeps.force` for Playwright runs (`PLAYWRIGHT=true`) -- Priority is deterministic E2E runs over dev startup time. - -## View Transitions Testing - -- `page.goto(url)` = Full page reload (no View Transitions) -- `page.navigateToPage('/path')` = Astro View Transitions (client-side navigation) -- Always use `navigateToPage()` for consistency - never ad-hoc `click('a[href]')` diff --git a/.github/skills/css/SKILL.md b/.github/skills/css/SKILL.md new file mode 100644 index 000000000..5ef4974e7 --- /dev/null +++ b/.github/skills/css/SKILL.md @@ -0,0 +1,32 @@ +--- +name: css +description: Use this skill when writing or reviewing CSS in this repository. Triggers include .css files, selector specificity, z-index usage, dark-mode theming, and Tailwind dark variant questions. +--- + +# CSS skill + +Use this skill for stylesheet edits in this repository. + +## Rules + +- Prefer a single class selector for styling. +- Avoid ID selectors in CSS. Use classes or attributes instead. +- Avoid chaining state across unrelated roots. +- Avoid long selector chains. If more specificity is needed, add a single component class. +- Avoid `!important` except in vendor CSS and print rules. +- Do not use Tailwind `dark:` variant classes for dark-mode theming. +- Use the project's theme tokens and helper classes instead. + +## Z-index rules + +- Do not hard-code numeric `z-index` values. +- Use the z-index tokens defined in `src/styles/index.css`. +- If no existing token fits, ask the user before adding a new token. + +## Related guidance + +- Theme color tokens remain governed by the always-on `theme-colors.instructions.md` rules. + +## References + +- See `.claude/skills/css/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/css/references/examples.md b/.github/skills/css/references/examples.md new file mode 100644 index 000000000..e82d70079 --- /dev/null +++ b/.github/skills/css/references/examples.md @@ -0,0 +1,14 @@ +# CSS references + +Use these files as baseline examples for CSS patterns in this repo: + +- `src/components/Toasts/NetworkStatus/index.module.css` +- `src/styles/index.css` +- `src/styles/vendor/mermaid.css` +- `src/styles/theme-inline.css` + +Key repo-specific constraints: + +- Prefer low-specificity component classes. +- Use theme tokens instead of ad-hoc colors. +- Use z-index tokens from `src/styles/index.css` instead of numeric literals. diff --git a/.github/skills/docs-astro/SKILL.md b/.github/skills/docs-astro/SKILL.md new file mode 100644 index 000000000..76359ecf9 --- /dev/null +++ b/.github/skills/docs-astro/SKILL.md @@ -0,0 +1,27 @@ +--- +name: docs-astro +description: Use this skill when writing or editing Astro templates under docs/. Triggers include docs/**/*.astro, Astro frontmatter conventions, separate script files, and docs component testing guidance. +--- + +# Docs Astro skill + +Use this skill for Astro files under `docs/`. + +## Rules + +- Always use TypeScript for type safety. +- Put script logic in a separate file and import it rather than embedding complex script code inline. + +## Testing guidance + +- When testing Astro components, use the Astro Container API instead of manual HTML fixtures. +- This keeps tests in sync with the actual component implementation. +- Use the working examples in `src/components/Test` as the reference pattern. + +## Related guidance + +- For broader Astro component testing rules, also use the `testing` skill. + +## References + +- See `.claude/skills/docs-astro/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/docs-astro/references/examples.md b/.github/skills/docs-astro/references/examples.md new file mode 100644 index 000000000..380f99379 --- /dev/null +++ b/.github/skills/docs-astro/references/examples.md @@ -0,0 +1,14 @@ +# Docs Astro references + +Use these files as baseline examples for Astro patterns in this repo: + +- `src/components/Head/index.astro` +- `src/layouts/MarkdownLayout.astro` +- `src/components/Test/container.astro` +- `src/components/Test/__tests__/webComponent.spec.ts` + +Key repo-specific constraints: + +- Use TypeScript frontmatter. +- Keep script logic separated when practical. +- Use Astro Container API patterns for component testing. diff --git a/.github/skills/docs-markdown/SKILL.md b/.github/skills/docs-markdown/SKILL.md new file mode 100644 index 000000000..2cce10150 --- /dev/null +++ b/.github/skills/docs-markdown/SKILL.md @@ -0,0 +1,28 @@ +--- +name: docs-markdown +description: Use this skill when writing or editing Markdown files under docs/. Triggers include docs/**/*.md, documentation wording, heading hierarchy, and docs-specific structure questions. +--- + +# Docs Markdown skill + +Use this skill for Markdown files under `docs/`. + +## Rules + +- Use clear and concise language. +- Ensure headings follow a consistent hierarchy. +- Use sentence case for headings and titles. +- Use backticks for inline code snippets. +- Use fenced code blocks with language identifiers. +- Use bullet points or numbered lists for steps and features. +- Include links to relevant resources or documentation. +- Ensure spacing and indentation remain readable. +- End files with a single newline. + +## Related guidance + +- For Markdown linting, MDX safety, and Mermaid authoring, also use the `markdown-mermaid` skill. + +## References + +- See `.claude/skills/docs-markdown/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/docs-markdown/references/examples.md b/.github/skills/docs-markdown/references/examples.md new file mode 100644 index 000000000..9f2b18354 --- /dev/null +++ b/.github/skills/docs-markdown/references/examples.md @@ -0,0 +1,13 @@ +# Docs Markdown references + +Use these files as baseline examples for Markdown documentation in this repo: + +- `docs/DEPLOYMENT_SETUP.md` +- `docs/ENVIRONMENT_VARIABLES.md` +- `docs/newsletter/infrastructure.md` + +Key repo-specific constraints: + +- Keep headings in sentence case. +- Use concise, procedural writing for setup guides. +- Defer Markdown lint and Mermaid-specific rules to the `markdown-mermaid` skill. diff --git a/.github/skills/javascript-typescript/SKILL.md b/.github/skills/javascript-typescript/SKILL.md new file mode 100644 index 000000000..ed07c8995 --- /dev/null +++ b/.github/skills/javascript-typescript/SKILL.md @@ -0,0 +1,30 @@ +--- +name: javascript-typescript +description: Use this skill when writing or reviewing JavaScript or TypeScript in this repository. Triggers include .js, .jsx, .ts, .tsx, semicolon style, JSDoc, and Playwright timeout knobs in test/e2e. +--- + +# JavaScript and TypeScript skill + +Use this skill for JavaScript and TypeScript edits in this repository. + +## Rules + +- Use `const` and `let` instead of `var`. +- Prefer arrow functions for anonymous functions. +- Use JSDoc for public functions and components. +- Do not add semicolons at the end of statements. + +## Playwright timeout policy + +- For code under `test/e2e/**`, do not introduce new numeric timeouts. +- Use existing timeout knobs from `test/e2e/helpers/waitTimeouts.ts`. +- If none fit, stop and ask whether to add a new timeout knob, refactor to an event-based wait, or change the underlying behavior. + +## Related guidance + +- For broader test rules, also use the `testing` skill. +- For Astro View Transitions navigation behavior, also use the `view-transitions` skill. + +## References + +- See `.claude/skills/javascript-typescript/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/javascript-typescript/references/examples.md b/.github/skills/javascript-typescript/references/examples.md new file mode 100644 index 000000000..93a785aaf --- /dev/null +++ b/.github/skills/javascript-typescript/references/examples.md @@ -0,0 +1,14 @@ +# JavaScript and TypeScript references + +Use these files as baseline examples for JavaScript and TypeScript patterns in this repo: + +- `src/components/Toasts/NetworkStatus/client/index.ts` +- `test/e2e/helpers/waitTimeouts.ts` +- `test/e2e/helpers/pageObjectModels/BasePage.ts` +- `src/middleware.ts` + +Key repo-specific constraints: + +- Follow the semicolon-free style already used across the codebase. +- Reuse `wait.*` timeout knobs in E2E code. +- Prefer the existing shared helpers over ad-hoc browser interaction code. diff --git a/.github/skills/markdown-mermaid/SKILL.md b/.github/skills/markdown-mermaid/SKILL.md new file mode 100644 index 000000000..1e68e862d --- /dev/null +++ b/.github/skills/markdown-mermaid/SKILL.md @@ -0,0 +1,51 @@ +--- +name: markdown-mermaid +description: Use this skill when writing or editing Markdown or MDX, especially when Markdown linting, fenced code blocks, inline code safety, or Mermaid diagrams are involved. Triggers include .md, .mdx, markdownlint, mermaid, flowchart, graph TD, subgraph, and fenced code block formatting. +--- + +# Markdown and Mermaid skill + +Use this skill for `.md` and `.mdx` authoring in this repository. + +## Process + +1. Apply the Markdown structure and linting rules first. +2. If the file contains Mermaid, apply the Mermaid-specific authoring constraints before finishing edits. +3. Reuse the existing Mermaid docs and tests when choosing diagram syntax. + +## Markdown rules + +- Add a blank line after headings. +- Add a blank line before and after lists. +- Use consistent list markers. +- End files with a single newline. +- Wrap inline code-like snippets in backticks. +- Use fenced code blocks with explicit language identifiers. +- Wrap bare URLs in angle brackets. +- Avoid raw `<` in prose when it could be parsed as HTML or JSX. Prefer `<` or backticks. + +## MDX and inline code safety + +- Wrap code-y snippets in backticks so the parser does not treat them as HTML or JSX. +- This includes examples like `{ param, ... }`, `generic`, `Array`, and `T extends Foo`. + +## Mermaid rules + +- This repo renders Mermaid during builds, so invalid Mermaid syntax will fail `npm run build`. +- Prefer `flowchart TD` or `flowchart LR` for new flowchart-style diagrams. +- `graph TD` is acceptable when matching existing content, but treat it as legacy syntax. +- Do not use `quadrantChart`. +- Do not use quoted subgraph titles like `subgraph "Title"`. +- Prefer simple subgraph ids with plain labels like `subgraph myGroup[My Group]`. +- Avoid punctuation-heavy subgraph labels. +- Avoid double quotes inside node labels. +- Avoid `@` in node labels. + +## Review rules + +- If you are refactoring an existing Markdown file, fix formatting issues that violate these rules while you are there. +- In reviews, flag Markdown lint and Mermaid syntax issues explicitly. + +## References + +- See `.claude/skills/markdown-mermaid/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/markdown-mermaid/references/examples.md b/.github/skills/markdown-mermaid/references/examples.md new file mode 100644 index 000000000..721472ef6 --- /dev/null +++ b/.github/skills/markdown-mermaid/references/examples.md @@ -0,0 +1,16 @@ +# Markdown and Mermaid references + +Use these files as the baseline references for Markdown and Mermaid behavior in this repo: + +- `docs/MERMAID.md` +- `docs/DEPLOYMENT_SETUP.md` +- `src/lib/config/mermaid.ts` +- `src/lib/config/markdown.ts` +- `src/lib/markdown/__tests__/integration/rehype-mermaid-astro.spec.ts` +- `test/e2e/specs/04-components/markdown.spec.ts` + +Key repo-specific constraints already enforced by code and tests: + +- Mermaid is rendered to inline SVG at build time. +- Mermaid blocks are excluded from the standard Shiki code-block path. +- Build and test coverage already exist for Mermaid rendering, so new syntax should stay close to existing supported patterns. diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md new file mode 100644 index 000000000..7f83730d4 --- /dev/null +++ b/.github/skills/testing/SKILL.md @@ -0,0 +1,54 @@ +--- +name: testing +description: Use this skill when writing, reviewing, or running unit tests or E2E tests. Triggers include test, spec, fixture, Playwright, Vitest, Astro Container API, and wait timeout policy. +--- + +# Testing skill + +Use this skill for all test-related work in this repository, especially `*.spec.ts`, Astro component tests, and Playwright E2E coverage. + +## Process + +1. Identify the test type before editing anything: + - Astro component or unit test + - Playwright E2E test + - Test infrastructure or config +2. Apply the matching rules below. +3. Reuse the existing examples and helpers before introducing new patterns. +4. Run only targeted verification unless the user explicitly asks for broader coverage. + +## Astro component and unit test rules + +- Never use manual HTML strings in test files or fixtures. +- Use Astro's Container API with actual `.astro` templates. +- Test fixtures must import actual components, not duplicate rendered HTML. +- Prefer the naming pattern `filename.spec.ts` for tests and `componentName.fixture.astro` for fixtures. +- Use `experimental_AstroContainer.create()` to instantiate the container. +- Use `container.renderToString(Component)` to render actual Astro components. +- Configure Vitest with `getViteConfig()` from `astro/config` when Container API support is required. + +## E2E test rules + +- Never hard-code content slugs in E2E tests. Fetch dynamically from listing pages. +- Always run Playwright with `CI=1` and `FORCE_COLOR=1`. +- Never run the full E2E suite unless the user explicitly asks for it. +- Never start the dev server yourself. The user maintains the running dev server. +- Never use ad-hoc numeric timeouts in E2E specs or page objects. +- Use `wait.*` from `test/e2e/helpers/waitTimeouts.ts`. +- If no existing `wait.*` knob fits, ask before adding a bespoke timeout. +- Avoid `waitForLoadState('networkidle')` for gating. Prefer deterministic readiness signals. +- If a network-idle-like wait is truly needed, use `BasePage.waitForNetworkIdleBestEffort()`. + +## View Transitions note + +- For Astro View Transitions behavior, `transition:persist`, or navigation semantics, also use the `view-transitions` skill. + +## Vite optimized deps guidance + +- If Playwright becomes flaky due to optimized deps issues, prefer deterministic fixes over workarounds. +- Add the problematic package or entrypoint to `vite.optimizeDeps.include` in `astro.config.ts`. +- Enable `vite.optimizeDeps.force` for Playwright runs with `PLAYWRIGHT=true` when needed. + +## References + +- See `.claude/skills/testing/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/testing/references/examples.md b/.github/skills/testing/references/examples.md new file mode 100644 index 000000000..aed817b65 --- /dev/null +++ b/.github/skills/testing/references/examples.md @@ -0,0 +1,10 @@ +# Testing references + +Use these existing repo files as the baseline examples before creating new testing patterns: + +- `src/components/Test/container.astro` +- `src/components/Test/container.spec.ts` +- `src/components/Test/__tests__/webComponent.spec.ts` +- `test/e2e/helpers/waitTimeouts.ts` + +When editing E2E tests, prefer shared BasePage helpers and existing timeout knobs instead of direct Playwright calls or numeric waits. diff --git a/.github/skills/view-transitions/SKILL.md b/.github/skills/view-transitions/SKILL.md new file mode 100644 index 000000000..736f85657 --- /dev/null +++ b/.github/skills/view-transitions/SKILL.md @@ -0,0 +1,38 @@ +--- +name: view-transitions +description: Use this skill when working on Astro View Transitions behavior, transition:persist, client-side navigation, or related Playwright coverage. Triggers include navigateToPage, waitForPageLoad, astro:page-load, page.goto, BasePage helpers, and persisted UI across navigation. +--- + +# View Transitions skill + +Use this skill when changing Astro View Transitions behavior, implementing persisted UI, or writing tests that depend on client-side navigation. + +## Process + +1. Decide whether the behavior under test or implementation is a full page load or Astro client-side navigation. +2. Use the matching navigation primitive. +3. Reuse the shared BasePage helpers instead of ad-hoc Playwright interactions. +4. If persistence is involved, verify that `transition:persist` is applied in the component definition, not only at the call site. + +## Navigation rules + +- Use `page.goto(url)` for full browser navigations without View Transitions. +- Use `navigateToPage('/path')` for in-site client-side navigation with View Transitions. +- Never use ad-hoc `click('a[href]')` calls for client-side navigation flows. +- When a Playwright-native action is still required, expose it through shared `BasePage` helpers and reuse that helper from tests. + +## Synchronization rules + +- Use `BasePage.waitForPageLoad()` to wait for the `astro:page-load` event. +- Never use `page.waitForTimeout()` to gate View Transitions. +- Prefer deterministic readiness signals over generic waiting. + +## Persistence rules + +- Apply `transition:persist` directly to HTML elements, including custom elements, in the component definition. +- Do not place `transition:persist` only on an Astro component usage wrapper. +- When testing persistence, verify DOM identity and persisted state across navigation instead of only checking visual presence. + +## References + +- See `.claude/skills/view-transitions/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/view-transitions/references/examples.md b/.github/skills/view-transitions/references/examples.md new file mode 100644 index 000000000..8d42dbac3 --- /dev/null +++ b/.github/skills/view-transitions/references/examples.md @@ -0,0 +1,16 @@ +# View Transitions references + +Use these files as the baseline examples for Astro View Transitions behavior in this repo: + +- `test/e2e/helpers/pageObjectModels/BasePage.ts` +- `test/e2e/specs/04-components/theme-picker.spec.ts` +- `test/e2e/specs/09-persistence/themepicker.spec.ts` +- `test/e2e/specs/09-persistence/footer.spec.ts` +- `src/components/ThemePicker/index.astro` +- `src/components/Footer/index.astro` + +Key patterns already established here: + +- `navigateToPage()` for client-side navigation +- `waitForPageLoad()` for `astro:page-load` synchronization +- `transition:persist` applied to actual rendered elements From ca60969d924fa30f3cf122f7f949463ec8745bf0 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 19 Apr 2026 15:26:39 +0300 Subject: [PATCH 2/6] Move directory --- .github/skills/css/SKILL.md | 2 +- .github/skills/docs-astro/SKILL.md | 2 +- .github/skills/docs-markdown/SKILL.md | 2 +- .github/skills/javascript-typescript/SKILL.md | 2 +- .github/skills/markdown-mermaid/SKILL.md | 2 +- .github/skills/testing/SKILL.md | 2 +- .github/skills/view-transitions/SKILL.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/skills/css/SKILL.md b/.github/skills/css/SKILL.md index 5ef4974e7..8a7081641 100644 --- a/.github/skills/css/SKILL.md +++ b/.github/skills/css/SKILL.md @@ -29,4 +29,4 @@ Use this skill for stylesheet edits in this repository. ## References -- See `.claude/skills/css/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/css/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/docs-astro/SKILL.md b/.github/skills/docs-astro/SKILL.md index 76359ecf9..5c5506df9 100644 --- a/.github/skills/docs-astro/SKILL.md +++ b/.github/skills/docs-astro/SKILL.md @@ -24,4 +24,4 @@ Use this skill for Astro files under `docs/`. ## References -- See `.claude/skills/docs-astro/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/docs-astro/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/docs-markdown/SKILL.md b/.github/skills/docs-markdown/SKILL.md index 2cce10150..5768f6534 100644 --- a/.github/skills/docs-markdown/SKILL.md +++ b/.github/skills/docs-markdown/SKILL.md @@ -25,4 +25,4 @@ Use this skill for Markdown files under `docs/`. ## References -- See `.claude/skills/docs-markdown/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/docs-markdown/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/javascript-typescript/SKILL.md b/.github/skills/javascript-typescript/SKILL.md index ed07c8995..a083c0689 100644 --- a/.github/skills/javascript-typescript/SKILL.md +++ b/.github/skills/javascript-typescript/SKILL.md @@ -27,4 +27,4 @@ Use this skill for JavaScript and TypeScript edits in this repository. ## References -- See `.claude/skills/javascript-typescript/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/javascript-typescript/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/markdown-mermaid/SKILL.md b/.github/skills/markdown-mermaid/SKILL.md index 1e68e862d..027899a8a 100644 --- a/.github/skills/markdown-mermaid/SKILL.md +++ b/.github/skills/markdown-mermaid/SKILL.md @@ -48,4 +48,4 @@ Use this skill for `.md` and `.mdx` authoring in this repository. ## References -- See `.claude/skills/markdown-mermaid/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/markdown-mermaid/references/examples.md` for concrete repo examples. \ No newline at end of file diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md index 7f83730d4..b91a25994 100644 --- a/.github/skills/testing/SKILL.md +++ b/.github/skills/testing/SKILL.md @@ -51,4 +51,4 @@ Use this skill for all test-related work in this repository, especially `*.spec. ## References -- See `.claude/skills/testing/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/testing/references/examples.md` for concrete repo examples. diff --git a/.github/skills/view-transitions/SKILL.md b/.github/skills/view-transitions/SKILL.md index 736f85657..d44bd4c57 100644 --- a/.github/skills/view-transitions/SKILL.md +++ b/.github/skills/view-transitions/SKILL.md @@ -35,4 +35,4 @@ Use this skill when changing Astro View Transitions behavior, implementing persi ## References -- See `.claude/skills/view-transitions/references/examples.md` for concrete repo examples. \ No newline at end of file +- See `.github/skills/view-transitions/references/examples.md` for concrete repo examples. \ No newline at end of file From 3553db83d44a0c119083f9e4b81aa45bf9b09ca4 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 19 Apr 2026 15:45:45 +0300 Subject: [PATCH 3/6] Add rate limiting in acton --- .../scripts/sentry/__tests__/helpers.spec.ts | 28 ++++ src/components/scripts/sentry/helpers.ts | 21 +++ .../scripts/store/__tests__/consent.spec.ts | 131 ++++++++++++++++++ src/components/scripts/store/consent.ts | 99 +++++++++++-- 4 files changed, 271 insertions(+), 8 deletions(-) diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts index 8a9168888..1a55b60e8 100644 --- a/src/components/scripts/sentry/__tests__/helpers.spec.ts +++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts @@ -54,6 +54,23 @@ const createContactSubmitHttpErrorEvent = (): Parameters[0] +const createConsentRateLimitHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.consentCreate' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 429', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + const createHint = (): Parameters[1] => ({}) as Parameters[1] @@ -103,6 +120,17 @@ describe('sentry helpers', () => { expect(result).toBeNull() }) + it('drops handled consent rate-limit http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createConsentRateLimitHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + it('scrubs PII when analytics consent is missing and preserves safe breadcrumbs', () => { isProdMock.mockReturnValue(true) getConsentSnapshotMock.mockReturnValue({ analytics: false }) diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts index 6a32d0837..4d5d0c020 100644 --- a/src/components/scripts/sentry/helpers.ts +++ b/src/components/scripts/sentry/helpers.ts @@ -21,6 +21,21 @@ const isHandledContactSubmitHttpError = (event: Parameters[0] ) } +const isHandledConsentRateLimitHttpError = (event: Parameters[0]): boolean => { + const requestUrl = event.request?.url + const exception = event.exception?.values?.[0] + const mechanismType = exception?.mechanism?.type + const errorMessage = exception?.value ?? event.message ?? '' + + return ( + typeof requestUrl === 'string' && + requestUrl.includes('/_actions/gdpr.consentCreate') && + mechanismType === 'auto.http.client.fetch' && + typeof errorMessage === 'string' && + errorMessage.includes('HTTP Client Error with status code: 429') + ) +} + function scrubBreadcrumbs( breadcrumbs: NonNullable[0]['breadcrumbs']> ) { @@ -49,6 +64,12 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => { return null } + // Consent logging is best-effort on the client. Rate limiting here is expected + // under bursty preference changes, so drop the browser-side auto-fetch event. + if (isHandledConsentRateLimitHttpError(event)) { + return null + } + const currentConsent = getConsentSnapshot() if (!currentConsent.analytics) { if (event.user) { diff --git a/src/components/scripts/store/__tests__/consent.spec.ts b/src/components/scripts/store/__tests__/consent.spec.ts index 71cd86983..5268e92d5 100644 --- a/src/components/scripts/store/__tests__/consent.spec.ts +++ b/src/components/scripts/store/__tests__/consent.spec.ts @@ -25,6 +25,7 @@ import { initConsentSideEffects, } from '@components/scripts/store/consent' import { $isConsentBannerVisible } from '@components/scripts/store/consentBanner' +import * as errorHandlerModule from '@components/scripts/errors/handler' // Mock js-cookie vi.mock('js-cookie', () => ({ @@ -61,6 +62,7 @@ vi.mock('@components/scripts/sentry/helpers', () => ({ })) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() vi.clearAllMocks() vi.unstubAllGlobals() @@ -473,6 +475,135 @@ describe('Consent side effects', () => { onlineGetter.mockRestore() }) + it('coalesces burst consent updates into the latest payload before sending', async () => { + vi.useFakeTimers() + + const fetchSpy = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchSpy) + + let consentListener: + | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) + | undefined + vi.spyOn($consent, 'subscribe').mockImplementation(listener => { + consentListener = listener + return () => {} + }) + vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {}) + + initConsentSideEffects() + + const dataSubjectId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const state0 = { + analytics: false, + marketing: false, + functional: false, + DataSubjectId: dataSubjectId, + } + const state1 = { + analytics: true, + marketing: false, + functional: false, + DataSubjectId: dataSubjectId, + } + const state2 = { + analytics: true, + marketing: true, + functional: false, + DataSubjectId: dataSubjectId, + } + const state3 = { + analytics: true, + marketing: true, + functional: true, + DataSubjectId: dataSubjectId, + } + + await consentListener?.(state1, state0) + await consentListener?.(state2, state1) + await consentListener?.(state3, state2) + + expect(fetchSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(250) + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + const firstFetchCall = fetchSpy.mock.calls.at(0) + if (!firstFetchCall) { + throw new TestError('Expected coalesced consent logging fetch to be called once') + } + + const [, options] = firstFetchCall + const payload = JSON.parse(options?.body as string) + expect(payload.purposes).toEqual(['analytics', 'marketing', 'functional']) + }) + + it('retries consent logging after a 429 without reporting a script error', async () => { + vi.useFakeTimers() + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: { get: vi.fn(() => '1') }, + json: vi.fn().mockResolvedValue({ error: { message: 'Try again in 1s' } }), + }) + .mockResolvedValueOnce({ ok: true }) + vi.stubGlobal('fetch', fetchSpy) + + const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError') + + let consentListener: + | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) + | undefined + vi.spyOn($consent, 'subscribe').mockImplementation(listener => { + consentListener = listener + return () => {} + }) + vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {}) + + initConsentSideEffects() + + const oldState = { + analytics: false, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + const newState = { + analytics: true, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + + await consentListener?.(newState, oldState) + + await vi.advanceTimersByTimeAsync(250) + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + expect(handleScriptErrorSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + expect(handleScriptErrorSpy).not.toHaveBeenCalled() + }) + it('deletes the data subject id when functional consent is revoked', () => { let functionalListener: ((_hasConsent: boolean) => void) | undefined vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(listener => { diff --git a/src/components/scripts/store/consent.ts b/src/components/scripts/store/consent.ts index 71ba36092..bdf2d8c61 100644 --- a/src/components/scripts/store/consent.ts +++ b/src/components/scripts/store/consent.ts @@ -33,6 +33,18 @@ export interface ConsentState { const consentCookieCategories: ConsentCategories[] = ['analytics', 'marketing', 'functional'] const CONSENT_COOKIE_PREFIX = 'consent_' +const CONSENT_LOG_DEBOUNCE_MS = 250 +const CONSENT_LOG_MAX_RETRY_DELAY_MS = 30_000 + +class ConsentLogRetryableError extends Error { + readonly retryAfterMs: number + + constructor(message: string, retryAfterMs: number, cause?: unknown) { + super(message, { cause }) + this.name = 'ConsentLogRetryableError' + this.retryAfterMs = retryAfterMs + } +} const prefixConsentCookie = (category: ConsentCategories): string => `${CONSENT_COOKIE_PREFIX}${category}` @@ -371,13 +383,64 @@ export function initConsentSideEffects(): void { userAgent: string verified: boolean } - const pendingConsentLogQueue: ConsentLogPayload[] = [] + let queuedConsentLogPayload: ConsentLogPayload | null = null let hasConsentLoggingFailure = false let isConsentLogProcessing = false let onlineListener: (() => void) | null = null + let consentLogTimerId: ReturnType | null = null const isNavigatorOnline = () => typeof navigator === 'undefined' || navigator.onLine !== false + const clearConsentLogTimer = () => { + if (consentLogTimerId === null || typeof window === 'undefined') { + return + } + + window.clearTimeout(consentLogTimerId) + consentLogTimerId = null + } + + const scheduleConsentLogProcessing = (delayMs: number) => { + if (typeof window === 'undefined') { + void processConsentLogQueue() + return + } + + clearConsentLogTimer() + consentLogTimerId = window.setTimeout(() => { + consentLogTimerId = null + void processConsentLogQueue() + }, delayMs) + } + + const parseRetryAfterMs = (response: Response, serverMessage?: string): number => { + const retryAfterHeader = response.headers.get('Retry-After') + if (retryAfterHeader) { + const retryAfterSeconds = Number(retryAfterHeader) + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.min(retryAfterSeconds * 1000, CONSENT_LOG_MAX_RETRY_DELAY_MS) + } + + const retryAfterDate = Date.parse(retryAfterHeader) + if (!Number.isNaN(retryAfterDate)) { + return Math.min( + Math.max(0, retryAfterDate - Date.now()), + CONSENT_LOG_MAX_RETRY_DELAY_MS + ) + } + } + + const retryMatch = serverMessage?.match(/try again in\s+(\d+)s/i) + if (retryMatch) { + const retryAfterSeconds = Number(retryMatch[1]) + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.min(retryAfterSeconds * 1000, CONSENT_LOG_MAX_RETRY_DELAY_MS) + } + } + + return 5_000 + } + const ensureOnlineListener = () => { if (typeof window === 'undefined' || onlineListener) { return @@ -407,26 +470,34 @@ export function initConsentSideEffects(): void { isConsentLogProcessing = true try { - while (pendingConsentLogQueue.length > 0) { - const payload = pendingConsentLogQueue[0]! + while (queuedConsentLogPayload) { + const payload = queuedConsentLogPayload + queuedConsentLogPayload = null + try { await sendConsentPayload(payload) - pendingConsentLogQueue.shift() } catch (error) { if (!isNavigatorOnline()) { + queuedConsentLogPayload ??= payload ensureOnlineListener() break } + if (error instanceof ConsentLogRetryableError) { + queuedConsentLogPayload ??= payload + scheduleConsentLogProcessing(error.retryAfterMs) + break + } + hasConsentLoggingFailure = true - pendingConsentLogQueue.shift() handleScriptError(error, consentLoggingContext) + break } } } finally { isConsentLogProcessing = false - if (pendingConsentLogQueue.length === 0 && onlineListener && typeof window !== 'undefined') { + if (!queuedConsentLogPayload && onlineListener && typeof window !== 'undefined') { window.removeEventListener('online', onlineListener) onlineListener = null } @@ -434,8 +505,8 @@ export function initConsentSideEffects(): void { } const enqueueConsentPayload = (payload: ConsentLogPayload) => { - pendingConsentLogQueue.push(payload) - void processConsentLogQueue() + queuedConsentLogPayload = payload + scheduleConsentLogProcessing(CONSENT_LOG_DEBOUNCE_MS) } const sendConsentPayload = async (payload: ConsentLogPayload) => { @@ -457,6 +528,18 @@ export function initConsentSideEffects(): void { ? (responseBody as { message: string }).message : undefined) + if (response.status === 429) { + throw new ConsentLogRetryableError( + serverMessage ?? 'Consent logging is temporarily rate limited', + parseRetryAfterMs(response, serverMessage), + { + status: response.status, + statusText: response.statusText, + body: responseBody, + } + ) + } + throw new ClientScriptError({ message: serverMessage ?? `Failed to record consent (status ${response.status})`, cause: { From 81c7d19d49a84280d997d9332c62498ab94aeb33 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 19 Apr 2026 15:58:51 +0300 Subject: [PATCH 4/6] Fix jobTitle and companyName required mismatch with action on Contact form --- .../downloads/__tests__/action.spec.ts | 115 ++++++++++++++++++ src/actions/downloads/action.ts | 17 ++- .../__fixtures__/downloadForm.fixture.astro | 10 -- .../Downloads/client/__tests__/index.spec.ts | 10 +- .../Downloads/client/__tests__/testUtils.ts | 6 - .../Pages/Downloads/client/index.ts | 6 +- .../Pages/Downloads/client/selectors.ts | 8 -- 7 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 src/actions/downloads/__tests__/action.spec.ts diff --git a/src/actions/downloads/__tests__/action.spec.ts b/src/actions/downloads/__tests__/action.spec.ts new file mode 100644 index 000000000..8d0b1902f --- /dev/null +++ b/src/actions/downloads/__tests__/action.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ActionConfig = { + handler: (_input: Input, _context: unknown) => Promise +} + +const getMockedHandler = (action: unknown): ActionConfig['handler'] => { + return (action as ActionConfig).handler +} + +vi.mock('astro:actions', () => { + return { + defineAction: (config: unknown) => config, + } +}) + +vi.mock('@actions/gdpr/entities/consent', () => { + return { + createConsentRecord: vi.fn(async () => ({ id: 'consent-1' })), + } +}) + +vi.mock('@actions/utils/environment/environmentActions', () => { + return { + getPrivacyPolicyVersion: vi.fn(() => 'privacy-version-1'), + } +}) + +vi.mock('@actions/utils/errors', () => { + return { + handleActionsFunctionError: vi.fn(() => undefined), + } +}) + +vi.mock('@actions/utils/hubspot', () => { + return { + createOrUpdateContact: vi.fn(async () => ({ id: 'hubspot-1' })), + setMarketingOptIn: vi.fn(async () => undefined), + } +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('downloads inputSchema', () => { + it('accepts omitted optional job fields', async () => { + const { inputSchema } = await import('../action') + + const result = inputSchema.parse({ + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + }) + + expect(result).toEqual({ + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + }) + }) + + it('normalizes blank optional job fields to undefined', async () => { + const { inputSchema } = await import('../action') + + const result = inputSchema.parse({ + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + jobTitle: ' ', + companyName: '', + }) + + expect(result).toEqual({ + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + jobTitle: undefined, + companyName: undefined, + }) + }) +}) + +describe('downloads.submit.handler', () => { + it('submits successfully without optional job fields', async () => { + const { downloads } = await import('../action') + const { createOrUpdateContact } = await import('@actions/utils/hubspot') + const { createConsentRecord } = await import('@actions/gdpr/entities/consent') + + const context = { + request: new Request('https://example.com/_actions/downloads/submit', { + method: 'POST', + headers: { 'user-agent': 'ua-1' }, + }), + clientAddress: '203.0.113.10', + } + + const response = await getMockedHandler(downloads.submit)({ + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + }, context) + + expect(response).toEqual({ + success: true, + message: 'Form submitted successfully', + }) + expect(createOrUpdateContact).toHaveBeenCalledWith({ + email: 'jane@example.com', + firstname: 'Jane', + lastname: 'Doe', + }) + expect(createConsentRecord).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/actions/downloads/action.ts b/src/actions/downloads/action.ts index ba92e3a2c..4ff475c1c 100644 --- a/src/actions/downloads/action.ts +++ b/src/actions/downloads/action.ts @@ -7,6 +7,15 @@ import { getPrivacyPolicyVersion } from '@actions/utils/environment/environmentA import { handleActionsFunctionError } from '@actions/utils/errors' import { createOrUpdateContact, setMarketingOptIn } from '@actions/utils/hubspot' +const optionalTrimmedString = z.preprocess(value => { + if (typeof value !== 'string') { + return value + } + + const trimmedValue = value.trim() + return trimmedValue.length > 0 ? trimmedValue : undefined +}, z.string().optional()) + export const inputSchema = z.object({ firstName: z.string().trim().min(1), lastName: z.string().trim().min(1), @@ -15,8 +24,8 @@ export const inputSchema = z.object({ .trim() .min(1) .refine(value => emailValidator.validate(value), 'Invalid email address'), - jobTitle: z.string().trim().min(1), - companyName: z.string().trim().min(1), + jobTitle: optionalTrimmedString, + companyName: optionalTrimmedString, consent: z.boolean().optional(), DataSubjectId: z.uuid().optional(), }) @@ -59,8 +68,8 @@ export const downloads = { console.log('Download form submission:', { name: `${input.firstName} ${input.lastName}`, email: input.workEmail, - jobTitle: input.jobTitle, - company: input.companyName, + jobTitle: input.jobTitle ?? null, + company: input.companyName ?? null, timestamp: new Date().toISOString(), }) diff --git a/src/components/Pages/Downloads/client/__tests__/__fixtures__/downloadForm.fixture.astro b/src/components/Pages/Downloads/client/__tests__/__fixtures__/downloadForm.fixture.astro index b57707854..796703f05 100644 --- a/src/components/Pages/Downloads/client/__tests__/__fixtures__/downloadForm.fixture.astro +++ b/src/components/Pages/Downloads/client/__tests__/__fixtures__/downloadForm.fixture.astro @@ -19,16 +19,6 @@ import Button from '@components/Button/index.astro' -
- - -
- -
- - -
-