diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 9984da882..2d3bfaec7 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -72,13 +72,63 @@ jobs: - name: Comment preview URL on PR if: always() && steps.vercel-preview.outcome == 'success' uses: actions/github-script@v8 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} with: script: | - const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'; const workflowRun = context.payload.workflow_run; const pr = workflowRun?.pull_requests?.[0]; - if (!pr || !previewUrl) { - core.warning('Missing pull request metadata or preview URL; skipping preview success comment.'); + if (!pr) { + core.warning('Missing pull request metadata; skipping preview success comment.'); + return; + } + + const rawPreviewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'.trim(); + const isVercelUrl = (url) => typeof url === 'string' && /vercel\.(app|com)/.test(url); + const fetchDeploymentUrl = async () => { + if (!process.env.VERCEL_TOKEN || !process.env.VERCEL_PROJECT_ID) { + core.info('Missing Vercel credentials; cannot query deployment API.'); + return null; + } + if (typeof fetch !== 'function') { + core.info('Fetch API unavailable in this runtime.'); + return null; + } + const query = new URLSearchParams({ + projectId: process.env.VERCEL_PROJECT_ID, + 'meta-githubCommitSha': workflowRun?.head_sha ?? '', + limit: '1' + }); + if (process.env.VERCEL_ORG_ID) { + query.set('teamId', process.env.VERCEL_ORG_ID); + } + const response = await fetch(`https://api.vercel.com/v6/deployments?${query.toString()}`, { + headers: { + Authorization: `Bearer ${process.env.VERCEL_TOKEN}` + } + }); + if (!response.ok) { + core.warning(`Unable to fetch deployment info (status ${response.status}).`); + return null; + } + const data = await response.json(); + const deployment = data?.deployments?.[0]; + if (deployment?.url) { + return `https://${deployment.url}`; + } + if (deployment?.inspectorUrl) { + return deployment.inspectorUrl.startsWith('http') + ? deployment.inspectorUrl + : `https://${deployment.inspectorUrl}`; + } + return null; + }; + + let previewUrl = isVercelUrl(rawPreviewUrl) ? rawPreviewUrl : await fetchDeploymentUrl(); + if (!isVercelUrl(previewUrl)) { + core.warning('Unable to resolve Vercel preview URL; skipping preview success comment.'); return; } @@ -89,7 +139,13 @@ jobs: const commitUrl = sha ? `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}` : `https://github.com/${context.repo.owner}/${context.repo.repo}`; - const actor = workflowRun.actor ?? 'workflow_run'; + const actorLogin = typeof workflowRun.actor === 'string' + ? workflowRun.actor + : workflowRun.actor?.login; + const actor = actorLogin ?? context.actor ?? 'workflow_run'; + const actorLink = workflowRun.actor?.html_url || (actorLogin ? `https://github.com/${actorLogin}` : null); + const actorDisplay = actor.startsWith('@') ? actor : `@${actor}`; + const triggeredBy = actorLink ? `[${actorDisplay}](${actorLink})` : actorDisplay; const body = [ commentTag, @@ -99,9 +155,9 @@ jobs: '| --- | --- |', `| Branch | \`${branch}\` |`, `| Commit | [${shortSha}](${commitUrl}) |`, - `| Preview | [Open preview](${previewUrl}) |`, + `| Preview | Open preview |`, '', - `_Triggered by @${actor}_` + `_Triggered by ${triggeredBy}_` ].join('\n'); const comments = await github.paginate(github.rest.issues.listComments, { diff --git a/.husky/prepare.js b/.husky/prepare.js new file mode 100644 index 000000000..8c520f5bf --- /dev/null +++ b/.husky/prepare.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/* eslint-disable no-undef */ +/** + * Husky prepare script to install git hooks. It's designed to quiet warnings on + * CI environments where .git directory may be missing when "prepare" script runs + * (e.g., during "npm install" step). + */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { execSync } from 'node:child_process' + +const projectRoot = process.cwd() +const gitDirectory = join(projectRoot, '.git') + +if (!existsSync(gitDirectory)) { + console.warn(`✅ Skipping Husky install: missing .git directory at ${gitDirectory}`) + process.exit(0) +} + +try { + console.log(`Running Husky install from ${projectRoot}`) + execSync('husky', { stdio: 'inherit', cwd: projectRoot }) + console.log('✅ Husky install complete') +} catch (error) { + console.error('❌ Husky install failed') + const status = typeof error === 'object' && error && 'status' in error && typeof error.status === 'number' + ? error.status + : 1 + process.exit(status) +} diff --git a/.vscode/settings.json b/.vscode/settings.json index ab4b772e1..85a9a09d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -96,6 +96,7 @@ "squoosh", "tanabata", "TIMESTAMPTZ", + "tktco", "Trino", "TRUNC", "Tscompile", diff --git a/@types/window.d.ts b/@types/window.d.ts index ab5f2f8de..bf1702aa8 100644 --- a/@types/window.d.ts +++ b/@types/window.d.ts @@ -89,6 +89,7 @@ declare global { updateConsent?: (_category: 'analytics' | 'marketing' | 'functional', _value: boolean) => void cacheEmbed?: (_key: string, _data: unknown, _ttl: number) => void saveMastodonInstance?: (_domain: string) => void + setOverlayPauseState?: (_source: string, _isPaused: boolean) => void /** * Custom evaluation error injected during Playwright tests diff --git a/_TODO.md b/_TODO.md index a1e01d3bb..203f11f57 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,23 +1,10 @@ # TODO -## Pause and Play - -Next, I'd like to add a "pause" and "play" icon to src/components/Animations/Computers -There are icons with those names already configured for the Icon component. -There are hooks for pause and play already setup in the component. -The icon should be displayed in the low right hand corner of the animation, with 4px of padding from the bottom and right side. It should overlay the animation, not expand the bounding box of the animation. - ## Performance Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md -## GitHub - -- Make sure actions workflows are working correctly after performance tests pass and whole suite is green -- Change Dependabut to open a single PR with all dependency updates -- Add 'hotfix' branch and add branch protection rules - ## Analytics Vercel Analytics @@ -35,6 +22,8 @@ See note in src/components/scripts/sentry/client.ts - "User Feedback - allow use docs/CONTACT_FORM.md +Where to upload to? + ## Search Add Upstash Search as a Vercel Marketplace Integration. diff --git a/package-lock.json b/package-lock.json index 8065c2dca..cc5e34f12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jsdoc": "61.4.1", + "eslint-plugin-jsdoc": "61.4.2", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-security": "3.0.1", "eslint-plugin-yml": "1.19.0", @@ -12558,9 +12558,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "61.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.4.1.tgz", - "integrity": "sha512-3c1QW/bV25sJ1MsIvsvW+EtLtN6yZMduw7LVQNVt72y2/5BbV5Pg5b//TE5T48LRUxoEQGaZJejCmcj3wCxBzw==", + "version": "61.4.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.4.2.tgz", + "integrity": "sha512-WzZNvefoUaG/JWikVFhNLYqE2BEd6LQD2ZyfJOe1Ld3Cir05csDMMf0AihGwrSbB/e7fHRSfQOZ4F/hik9fQww==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index abcfe55f4..410a57c18 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", "format:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", "format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"", - "lint": "npm run lint:base && npm run lint:actions", + "lint": "npm run lint:base && npm run lint:actions && npm run check", "lint:base": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code", "lint:code": "npx eslint \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" \"test/**/*.{js,ts,tsx,astro}\"", "lint:tsc:check": "tsc --noEmit -p tsconfig.json --pretty false", @@ -60,7 +60,7 @@ "test:e2e:full": "dotenv -e .env.development -- cross-env FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test", "test:unit": "FORCE_COLOR=1 npx vitest run", "upgrade": "npx @astrojs/upgrade", - "prepare": "node -e \"const fs=require('node:fs');if(!fs.existsSync('.git')){console.log('Skipping Husky install (missing .git directory)');process.exit(0);}\" && husky" + "prepare": "node .husky/prepare.js" }, "dependencies": { "@astrojs/check": "0.9.6", @@ -158,7 +158,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jsdoc": "61.4.1", + "eslint-plugin-jsdoc": "61.4.2", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-security": "3.0.1", "eslint-plugin-yml": "1.19.0", diff --git a/playwright.config.ts b/playwright.config.ts index 417758296..78ae68464 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,7 +4,10 @@ import { defineConfig, devices } from '@playwright/test' * Read environment variables from file. * https://github.com/motdotla/dotenv */ -import 'dotenv/config' +import dotenv from 'dotenv' +import { isCI } from 'src/lib/config/environmentServer' + +if ( !isCI() ) dotenv.config({ path: '.env.development' }) /** * See https://playwright.dev/docs/test-configuration. diff --git a/src/components/Animations/Computers/client/__tests__/index.spec.ts b/src/components/Animations/Computers/client/__tests__/index.spec.ts index 8ed220680..4fc281adb 100644 --- a/src/components/Animations/Computers/client/__tests__/index.spec.ts +++ b/src/components/Animations/Computers/client/__tests__/index.spec.ts @@ -171,12 +171,19 @@ describe('ComputersAnimationElement', () => { const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0] const pauseHandler = controllerArgs?.onPause const resumeHandler = controllerArgs?.onPlay + const toggleButton = element.querySelector('[data-animation-toggle]') pauseHandler?.() + expect(element.getAttribute('data-animation-state')).toBe('paused') + expect(toggleButton?.getAttribute('aria-pressed')).toBe('true') + expect(toggleButton?.getAttribute('aria-label')).toBe('Play animation') resumeHandler?.() expect(timelineMock.pause).toHaveBeenCalled() expect(timelineMock.play).toHaveBeenCalled() + expect(element.getAttribute('data-animation-state')).toBe('playing') + expect(toggleButton?.getAttribute('aria-pressed')).toBe('false') + expect(toggleButton?.getAttribute('aria-label')).toBe('Pause animation') expect(getBreadcrumbOperations()).toEqual(expect.arrayContaining(['pause', 'resume'])) }) }) @@ -221,10 +228,31 @@ describe('ComputersAnimationElement', () => { element.initialize() element.pause() + expect(element.getAttribute('data-animation-state')).toBe('paused') element.resume() expect(timelineMock.pause).toHaveBeenCalledTimes(1) expect(timelineMock.play).toHaveBeenCalledTimes(1) + expect(element.getAttribute('data-animation-state')).toBe('playing') + }) + }) + + it('requests pause and play through the animation controller when the toggle is clicked', async () => { + await renderComputersAnimation(async ({ element }) => { + element.initialize() + + const toggleButton = element.querySelector('[data-animation-toggle]') + const controllerHandle = getLastControllerHandle() + + expect(toggleButton).toBeTruthy() + + toggleButton?.click() + expect(controllerHandle?.requestPause).toHaveBeenCalledTimes(1) + + element.pause() + + toggleButton?.click() + expect(controllerHandle?.requestPlay).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/Animations/Computers/client/index.ts b/src/components/Animations/Computers/client/index.ts index abe742796..2725851c9 100644 --- a/src/components/Animations/Computers/client/index.ts +++ b/src/components/Animations/Computers/client/index.ts @@ -2,9 +2,11 @@ import { LitElement } from 'lit' import { gsap } from 'gsap' import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' +import { addButtonEventListeners } from '@components/scripts/elementListeners' import { createAnimationController, type AnimationControllerHandle, + type AnimationPlayState, } from '@components/scripts/store' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' @@ -41,10 +43,14 @@ export class ComputersAnimationElement extends LitElement { private timeline: Timeline | null = null private initialized = false private animationController: AnimationControllerHandle | undefined + private toggleButton: HTMLButtonElement | null = null private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.initialize() } + private readonly toggleClickHandler = (event: Event) => { + this.handleToggleClick(event) + } override createRenderRoot() { return this @@ -78,9 +84,22 @@ export class ComputersAnimationElement extends LitElement { try { this.startAnimation() + this.toggleButton = this.querySelector('[data-animation-toggle]') as HTMLButtonElement | null + + if (this.toggleButton) { + addButtonEventListeners(this.toggleButton, this.toggleClickHandler, this) + } + + const defaultState = this.getDefaultAnimationState() + this.setAnimationState(defaultState) + if (defaultState === 'paused') { + this.timeline?.pause(0) + } + this.animationController = createAnimationController({ animationId: 'computers-animation', debugLabel: SCRIPT_NAME, + defaultState, onPause: () => { this.pause() }, @@ -100,6 +119,7 @@ export class ComputersAnimationElement extends LitElement { try { this.timeline?.pause() + this.setAnimationState('paused') } catch (error) { handleScriptError(error, context) } @@ -111,6 +131,7 @@ export class ComputersAnimationElement extends LitElement { try { this.timeline?.play() + this.setAnimationState('playing') } catch (error) { handleScriptError(error, context) } @@ -131,12 +152,97 @@ export class ComputersAnimationElement extends LitElement { this.timeline = null } + this.resetToggleButton() + this.removeAttribute('data-animation-state') this.initialized = false } catch (error) { handleScriptError(error, context) } } + private setAnimationState(state: AnimationPlayState): void { + this.setAttribute('data-animation-state', state) + this.updateToggleButton(state) + } + + private updateToggleButton(state: AnimationPlayState): void { + if (!this.toggleButton) return + + this.toggleButton.setAttribute('aria-pressed', state === 'paused' ? 'true' : 'false') + this.toggleButton.setAttribute('aria-label', state === 'paused' ? 'Play animation' : 'Pause animation') + this.toggleButton.dataset['animationState'] = state + + const pauseIcon = this.toggleButton.querySelector('[data-animation-icon="pause"]') + const playIcon = this.toggleButton.querySelector('[data-animation-icon="play"]') + + pauseIcon?.classList.toggle('hidden', state === 'paused') + playIcon?.classList.toggle('hidden', state === 'playing') + } + + private resetToggleButton(): void { + if (!this.toggleButton) return + + this.toggleButton.removeAttribute('data-animation-state') + this.toggleButton.setAttribute('aria-label', 'Pause animation') + this.toggleButton.setAttribute('aria-pressed', 'false') + + const pauseIcon = this.toggleButton.querySelector('[data-animation-icon="pause"]') + const playIcon = this.toggleButton.querySelector('[data-animation-icon="play"]') + + pauseIcon?.classList.remove('hidden') + playIcon?.classList.add('hidden') + + this.toggleButton = null + } + + private handleToggleClick(event: Event): void { + event.preventDefault() + + if (!this.toggleButton) return + + const state = this.getAnimationState() + + if (state === 'playing') { + if (this.animationController) { + this.animationController.requestPause() + } else { + this.pause() + } + return + } + + if (this.animationController) { + this.animationController.requestPlay() + return + } + + this.resume() + } + + private getAnimationState(): AnimationPlayState { + const state = this.getAttribute('data-animation-state') as AnimationPlayState | null + return state ?? 'playing' + } + + private getDefaultAnimationState(): AnimationPlayState { + if (typeof window === 'undefined') { + return 'playing' + } + + try { + if (typeof window.matchMedia === 'function') { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + if (mediaQuery.matches) { + return 'paused' + } + } + } catch { + // Best effort only; fall through to playing + } + + return 'playing' + } + private startAnimation() { if (typeof document === 'undefined') return if (document.getElementById('heroAnimation') == undefined) return diff --git a/src/components/Animations/Computers/index.astro b/src/components/Animations/Computers/index.astro index b63537600..4d4e66837 100644 --- a/src/components/Animations/Computers/index.astro +++ b/src/components/Animations/Computers/index.astro @@ -2,10 +2,25 @@ /** * Animation of laptop, table, and desktop for home page Hero */ +import Icon from '@components/Icon/index.astro' --- -
+
+
({ + getOptionalEnv: vi.fn(() => undefined), +})) + // Mock child_process vi.mock('node:child_process', () => ({ execSync: vi.fn(), })) +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), +})) + +import { getOptionalEnv } from '../../../lib/config/environmentServer' + describe('PrivacyPolicyVersion Integration', () => { let consoleLogSpy: ReturnType let consoleWarnSpy: ReturnType beforeEach(() => { - // Spy on console methods + vi.mocked(getOptionalEnv).mockReturnValue(undefined) + vi.mocked(existsSync).mockReturnValue(true) + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -27,6 +40,7 @@ describe('PrivacyPolicyVersion Integration', () => { // Restore console methods consoleLogSpy.mockRestore() consoleWarnSpy.mockRestore() + vi.useRealTimers() }) describe('getPrivacyPolicyVersionFromGit', () => { @@ -61,7 +75,7 @@ describe('PrivacyPolicyVersion Integration', () => { }) expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Privacy policy version set to: 2024-03-15'), + expect.stringContaining('Privacy policy version set from git: 2024-03-15'), ) }) @@ -89,44 +103,87 @@ describe('PrivacyPolicyVersion Integration', () => { }) }) - it('should throw BuildError when git command fails', async () => { - // Mock git command failure + it('logs warning and falls back when git command fails', async () => { vi.mocked(execSync).mockImplementation(() => { throw new TestError('Git command failed') }) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') const mockUpdateConfig = vi.fn() const integration = privacyPolicyVersion() - await expect( - integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, - }), - ).rejects.toThrow('Could not get privacy policy version from git') + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) - expect(consoleWarnSpy).not.toHaveBeenCalled() + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-12-06"', + }, + }, + }) + expect(consoleWarnSpy).toHaveBeenCalled() }) - it('should throw BuildError when git returns empty string', async () => { - // Mock git command returning empty string + it('logs warning and falls back when git returns empty string', async () => { vi.mocked(execSync).mockReturnValue('') + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-05-01T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') const mockUpdateConfig = vi.fn() const integration = privacyPolicyVersion() - await expect( - integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, - }), - ).rejects.toThrow('No git commits found for privacy policy file') + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-05-01"', + }, + }, + }) + expect(consoleWarnSpy).toHaveBeenCalled() + }) + + it('skips git lookup entirely when repository metadata is missing', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-15T00:00:00Z')) + + const { privacyPolicyVersion } = await import('../index') + + const mockUpdateConfig = vi.fn() + const integration = privacyPolicyVersion() + + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(execSync).not.toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[privacy-policy-version] Git metadata not found. Skipping git lookup.', + ) + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2026-01-15"', + }, + }, + }) }) }) @@ -189,5 +246,25 @@ describe('PrivacyPolicyVersion Integration', () => { expect.any(Object), ) }) + + it('executes git commands from the project root directory', async () => { + vi.mocked(execSync).mockReturnValue('2024-03-15') + + const { privacyPolicyVersion } = await import('../index') + + const mockUpdateConfig = vi.fn() + const integration = privacyPolicyVersion() + + await integration.hooks['astro:config:setup']?.({ + updateConfig: mockUpdateConfig, + // @ts-expect-error - Partial mock + config: {}, + }) + + expect(execSync).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ cwd: process.cwd() }), + ) + }) }) }) diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index 6e95aa1f3..72f29c689 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -15,44 +15,89 @@ */ import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import type { AstroIntegration } from 'astro' -import { BuildError } from '../../lib/errors/BuildError' +import { getOptionalEnv } from '../../lib/config/environmentServer' + +const PROJECT_ROOT = process.cwd() +const PRIVACY_POLICY_PATH = join(PROJECT_ROOT, 'src', 'pages', 'privacy', 'index.astro') +const GIT_DIRECTORY_PATH = join(PROJECT_ROOT, '.git') + +export const toIsoDateString = (date: Date): string => date.toISOString().slice(0, 10) /** * Get privacy policy version from git commit date * @param filePath - Path to privacy policy file (relative to project root) * @returns ISO date string (YYYY-MM-DD) of last commit - * @throws {BuildError} If git command fails or returns empty result */ -function getPrivacyPolicyVersionFromGit(filePath: string): string { +function getPrivacyPolicyVersionFromGit(filePath: string): string | null { try { // Get last commit date for privacy policy file in YYYY-MM-DD format const lastCommitDate = execSync( `git log -1 --format=%cd --date=format:%Y-%m-%d -- ${filePath}`, - { encoding: 'utf-8' }, + { encoding: 'utf-8', cwd: PROJECT_ROOT }, ).trim() if (lastCommitDate) { return lastCommitDate } - // If no commits found (new file), throw error - throw new BuildError( - `No git commits found for privacy policy file: ${filePath}`, - { phase: 'config-setup', filePath }, + console.warn( + `[privacy-policy-version] No git commits found for privacy policy file: ${filePath}. Falling back to current date.`, ) + return null } catch (error) { - // Git not available or command failed - if (error instanceof BuildError) { - throw error - } - throw new BuildError( - `Could not get privacy policy version from git: ${error instanceof Error ? error.message : String(error)}`, - { phase: 'config-setup', tool: 'git', cause: error }, + console.warn( + `[privacy-policy-version] Could not get privacy policy version from git: ${error instanceof Error ? error.message : String(error)}`, + ) + return null + } +} + +/** + * Determine whether git metadata is available before issuing git commands. + * Vercel preview builds, for example, do not clone the repo with git history, + * so attempting to run git commands will fail immediately. Checking for a git + * directory lets us skip the expensive call entirely. + */ +function hasGitRepository(): boolean { + try { + return existsSync(GIT_DIRECTORY_PATH) + } catch (error) { + console.warn( + `[privacy-policy-version] Unable to verify git repository: ${error instanceof Error ? error.message : String(error)}`, ) + return false } } +/** + * Resolve privacy policy version using env, git metadata, or current date fallback. + */ +export function resolvePrivacyPolicyVersion(): string { + const rawEnvVersion = getOptionalEnv('PRIVACY_POLICY_VERSION') + const envVersion = typeof rawEnvVersion === 'string' ? rawEnvVersion.trim() : '' + if (envVersion) { + console.log(`✅ Privacy policy version sourced from env: ${envVersion}`) + return envVersion + } + + if (hasGitRepository()) { + const gitVersion = getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_PATH) + if (gitVersion) { + console.log(`✅ Privacy policy version set from git: ${gitVersion}`) + return gitVersion + } + } else { + console.warn('[privacy-policy-version] Git metadata not found. Skipping git lookup.') + } + + const fallback = toIsoDateString(new Date()) + console.log(`⚠️ Privacy policy version fallback applied: ${fallback}`) + return fallback +} + /** * Astro integration that injects privacy policy version as PRIVACY_POLICY_VERSION */ @@ -61,11 +106,7 @@ export function privacyPolicyVersion(): AstroIntegration { name: 'privacy-policy-version', hooks: { 'astro:config:setup': async ({ updateConfig }) => { - // Get version from git commit date - const privacyPolicyPath = 'src/pages/privacy/index.astro' - const version = getPrivacyPolicyVersionFromGit(privacyPolicyPath) - - console.log(`✅ Privacy policy version set to: ${version}`) + const version = resolvePrivacyPolicyVersion() // Inject as Vite define so it's available as import.meta.env.PRIVACY_POLICY_VERSION updateConfig({ diff --git a/src/lib/config/environmentServer.ts b/src/lib/config/environmentServer.ts index 10a8625a0..0aef72f5c 100644 --- a/src/lib/config/environmentServer.ts +++ b/src/lib/config/environmentServer.ts @@ -66,3 +66,7 @@ export function getSentryAuthToken(): string { } return token } + +export const getOptionalEnv = (key: string): string | undefined => { + return process.env[key] +} diff --git a/src/lib/config/pwa.ts b/src/lib/config/pwa.ts index 05692e1a8..6c3da24c8 100644 --- a/src/lib/config/pwa.ts +++ b/src/lib/config/pwa.ts @@ -23,6 +23,9 @@ export const pwaConfig: PwaOptions = { }, manifestFilename: 'manifest.json', registerType: 'autoUpdate', + /** + * Options for manifest.json generation + */ manifest: { background_color: '#f3f4f6', description: contactData.company.description, @@ -57,6 +60,9 @@ export const pwaConfig: PwaOptions = { }, ], }, + /** + * Options for Workbox service worker generation + */ workbox: { // ID to be prepended to cache names cacheId: 'webstackbuilders', diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts new file mode 100644 index 000000000..309c94210 --- /dev/null +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { APIRoute } from 'astro' +import { GET as runAll } from '@pages/api/cron/run-all' + +const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) +const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com')) + +vi.mock('@pages/api/_environment/environmentApi', async () => { + const actual = await vi.importActual( + '@pages/api/_environment/environmentApi', + ) + return { + ...actual, + getCronSecret: getCronSecretMock, + getSiteUrl: getSiteUrlMock, + } +}) + +const buildContext = (request: Request) => ({ + request, + clientAddress: '127.0.0.1', + cookies: { + get: () => undefined, + }, +}) + +describe('cron runner', () => { + let fetchMock: ReturnType + let warnSpy: ReturnType + + const run = (request: Request) => runAll(buildContext(request) as unknown as Parameters[0]) + + beforeEach(() => { + vi.clearAllMocks() + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.unstubAllGlobals() + warnSpy.mockRestore() + }) + + const createResponse = (path: string, overrides?: Partial) => + ({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'x-vercel-elapsed-time': '123' }), + json: vi.fn().mockResolvedValue({ path }), + text: vi.fn().mockResolvedValue(''), + ...overrides, + }) as unknown as Response + + it('rejects unauthorized requests', async () => { + const request = new Request('https://example.com/api/cron/run-all') + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error.code).toBe('UNAUTHORIZED') + expect(fetchMock).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + }) + + it('calls downstream cron endpoints sequentially', async () => { + fetchMock + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-confirmations')) + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) + .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) + + const request = new Request('https://example.com/api/cron/run-all', { + method: 'GET', + headers: { + authorization: 'Bearer cron-secret', + }, + }) + + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(Array.isArray(body.results)).toBe(true) + expect(body.results).toHaveLength(3) + expect(fetchMock).toHaveBeenCalledTimes(3) + + const headersUsed = fetchMock.mock.calls[0]?.[1]?.headers as Record + expect(headersUsed['Authorization'] ?? headersUsed['authorization']).toBe('Bearer cron-secret') + }) + + it('surfaces downstream failure details', async () => { + fetchMock + .mockResolvedValueOnce( + createResponse('/api/cron/cleanup-confirmations', { + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers(), + }), + ) + .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) + .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) + + const request = new Request('https://example.com/api/cron/run-all', { + method: 'GET', + headers: { + authorization: 'Bearer cron-secret', + }, + }) + + const response = await run(request) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.code).toBe('CRON_RUNNER_TARGET_FAILED') + expect(body.error.message).toBe('Cron runner failed to execute downstream jobs') + }) +}) diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts new file mode 100644 index 000000000..39ed8c08d --- /dev/null +++ b/src/pages/api/cron/run-all.ts @@ -0,0 +1,116 @@ +import type { APIRoute } from 'astro' +import { getCronSecret } from '@pages/api/_environment/environmentApi' +import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' +import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { createApiFunctionContext } from '@pages/api/_utils/requestContext' + +export const prerender = false + +const ROUTE = '/api/cron/run-all' +const CRON_ENDPOINTS = [ + '/api/cron/cleanup-confirmations', + '/api/cron/cleanup-dsar-requests', + '/api/cron/ping-integrations', +] + +const buildErrorResponse = ( + error: unknown, + context: ReturnType['context'], + fallbackMessage: string, +) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage }) + +async function triggerCronEndpoint(path: string) { + const url = new URL(path, getSiteUrl()) + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${getCronSecret()}`, + }, + }) + + const elapsedMs = response.headers.get('x-vercel-elapsed-time') + const duration = typeof elapsedMs === 'string' ? Number(elapsedMs) : undefined + + let body: unknown + + try { + body = await response.json() + } catch { + body = await response.text() + } + + if (!response.ok) { + throw new ApiFunctionError( + `Cron runner failed for ${path}: ${response.status} ${response.statusText}`, + { + status: response.status, + code: 'CRON_RUNNER_TARGET_FAILED', + route: ROUTE, + operation: path, + details: { + body, + }, + }, + ) + } + + return { + path, + status: response.status, + durationMs: typeof duration === 'number' && Number.isFinite(duration) ? duration : undefined, + body, + } +} + +export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { + const { context: apiContext } = createApiFunctionContext({ + route: ROUTE, + operation: 'GET', + request, + clientAddress, + cookies, + }) + + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${getCronSecret()}`) { + console.warn('Unauthorized cron runner attempt - invalid CRON_SECRET') + apiContext.extra = { + ...(apiContext.extra || {}), + authHeader: authHeader ? 'PRESENT' : 'MISSING', + clientAddress, + } + return buildErrorResponse( + new ApiFunctionError({ + message: 'Unauthorized', + status: 401, + code: 'UNAUTHORIZED', + }), + apiContext, + 'Unauthorized cron access', + ) + } + + try { + const results = await Promise.all(CRON_ENDPOINTS.map(triggerCronEndpoint)) + + return new Response( + JSON.stringify({ + success: true, + results, + timestamp: new Date().toISOString(), + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + } catch (error) { + apiContext.extra = { + ...(apiContext.extra || {}), + authHeader: 'REDACTED', + } + return buildErrorResponse(error, apiContext, 'Cron runner failed to execute downstream jobs') + } +} diff --git a/src/pages/testing/animations-computers.astro b/src/pages/testing/animations-computers.astro new file mode 100644 index 000000000..367cff71b --- /dev/null +++ b/src/pages/testing/animations-computers.astro @@ -0,0 +1,25 @@ +--- +import BaseLayout from '@layouts/BaseLayout.astro' +import ComputersAnimation from '@components/Animations/Computers/index.astro' + +const pageTitle = 'Computers Animation Fixture' +const pageDescription = 'Isolated test route for the hero computers animation and its lifecycle controller.' +const pagePath = '/testing/animations-computers' +--- + + +
+

+ This dedicated route hosts the hero computers animation inside a stable shell so automated tests can + validate pause and play semantics without relying on home page content or layout changes. +

+

+ The animation below uses the same lifecycle controller and accessibility as production, + ensuring coverage reflects the real user experience. +

+ + +
+ +
+ diff --git a/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts b/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts index 992a54775..9f85e25c2 100644 --- a/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts +++ b/test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts @@ -69,7 +69,10 @@ export class BreadCrumbPage extends BasePage { return } - const navigationPromise = this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }) + const navigationPromise = this.page.waitForURL( + (url: URL) => url.pathname === targetHref, + { waitUntil: 'domcontentloaded', timeout: 15000 }, + ) await this.click(`a[href="${targetHref}"]`) await navigationPromise await this.waitForLoadState('networkidle') diff --git a/test/e2e/specs/04-components/animations-computers.spec.ts b/test/e2e/specs/04-components/animations-computers.spec.ts new file mode 100644 index 000000000..35821e2dd --- /dev/null +++ b/test/e2e/specs/04-components/animations-computers.spec.ts @@ -0,0 +1,91 @@ +import { BasePage, test, expect } from '@test/e2e/helpers' +import type { Page } from '@playwright/test' + +type AnimationPlayState = 'playing' | 'paused' | null + +const selectors = { + host: 'computers-animation', + toggle: '[data-animation-toggle]', +} + +const overlaySource = 'e2e-computers-animation' + +interface FixtureOptions { + reducedMotion?: 'no-preference' | 'reduce' +} + +async function loadComputersFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { + const page = await BasePage.init(playwrightPage) + await page.page.emulateMedia({ reducedMotion: options.reducedMotion ?? 'no-preference' }) + await page.goto('/testing/animations-computers') + await page.waitForSelector(selectors.host) + return page +} + +async function getAnimationState(page: BasePage): Promise { + return await page.evaluate(() => { + return document.querySelector('computers-animation')?.getAttribute('data-animation-state') as AnimationPlayState + }) +} + +async function waitForAnimationState(page: BasePage, expected: Exclude): Promise { + await expect.poll(async () => await getAnimationState(page)).toBe(expected) +} + +async function getToggleAttributes(page: BasePage): Promise<{ label: string | null; pressed: string | null }> { + return await page.evaluate((toggleSelector) => { + const button = document.querySelector(toggleSelector) + return { + label: button?.getAttribute('aria-label') ?? null, + pressed: button?.getAttribute('aria-pressed') ?? null, + } + }, selectors.toggle) +} + +async function setOverlayPause(page: BasePage, isPaused: boolean): Promise { + await page.evaluate(({ paused, source }) => { + window.setOverlayPauseState?.(source, paused) + }, { paused: isPaused, source: overlaySource }) +} + +test.describe('Computers Animation Component', () => { + test('plays by default when no pause sources exist', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage) + + await waitForAnimationState(page, 'playing') + + const toggle = page.locator(selectors.toggle) + await expect(toggle).toHaveAttribute('aria-label', 'Pause animation') + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + }) + + test('honors prefers-reduced-motion by starting paused', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage, { reducedMotion: 'reduce' }) + + await waitForAnimationState(page, 'paused') + + const toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Play animation') + expect(toggleAttributes.pressed).toBe('true') + }) + + test('responds to overlay pause and resume actions from the animation store', async ({ page: playwrightPage }) => { + const page = await loadComputersFixture(playwrightPage) + + await waitForAnimationState(page, 'playing') + + await setOverlayPause(page, true) + await waitForAnimationState(page, 'paused') + + let toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Play animation') + expect(toggleAttributes.pressed).toBe('true') + + await setOverlayPause(page, false) + await waitForAnimationState(page, 'playing') + + toggleAttributes = await getToggleAttributes(page) + expect(toggleAttributes.label).toBe('Pause animation') + expect(toggleAttributes.pressed).toBe('false') + }) +}) diff --git a/test/e2e/specs/07-performance/PERFORMANCE.md b/test/e2e/specs/07-performance/PERFORMANCE.md index 86cc3395e..3b476c76d 100644 --- a/test/e2e/specs/07-performance/PERFORMANCE.md +++ b/test/e2e/specs/07-performance/PERFORMANCE.md @@ -1,6 +1,8 @@ # Performance +continue + Latest run (Dec 2, 2025): - Mobile Chrome LCP measured 4.3 s (threshold 2.5 s) diff --git a/test/unit/integrations/privacy-policy-version.spec.ts b/test/unit/integrations/privacy-policy-version.spec.ts new file mode 100644 index 000000000..a0dc915a4 --- /dev/null +++ b/test/unit/integrations/privacy-policy-version.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, afterEach, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})) + +vi.mock('../../../src/lib/config/environmentServer', () => ({ + getOptionalEnv: vi.fn(), +})) + +import { execSync } from 'node:child_process' + +import { getOptionalEnv } from '../../../src/lib/config/environmentServer' +import { resolvePrivacyPolicyVersion } from '../../../src/integrations/PrivacyPolicyVersion/index' + +describe('resolvePrivacyPolicyVersion', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('returns PRIVACY_POLICY_VERSION when provided', () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce('2024-01-02') + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2024-01-02') + expect(execSync).not.toHaveBeenCalled() + }) + + it('falls back to git metadata when env var is absent', () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) + vi.mocked(execSync).mockReturnValue('2023-05-05\n' as never) + + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2023-05-05') + }) + + it('falls back to the current date when git is unavailable', () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T12:00:00Z')) + vi.mocked(execSync).mockImplementation(() => { + throw new Error('git missing') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const version = resolvePrivacyPolicyVersion() + + expect(version).toBe('2025-12-06') + }) +}) diff --git a/vercel.json b/vercel.json index feb6386bc..fd5867fb6 100644 --- a/vercel.json +++ b/vercel.json @@ -4,16 +4,8 @@ "regions": ["iad1"], "crons": [ { - "path": "/api/cron/cleanup-confirmations", + "path": "/api/cron/run-all", "schedule": "0 2 * * *" - }, - { - "path": "/api/cron/cleanup-dsar-requests", - "schedule": "0 3 * * *" - }, - { - "path": "/api/cron/ping-integrations", - "schedule": "0 4 * * *" } ] }