Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
pull-requests: write
issues: write
id-token: write

steps:
Expand Down
137 changes: 137 additions & 0 deletions .github/workflows/playwright-scheduled.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Playwright scheduled

# Runs the Playwright suite weekly and on manual dispatch.
# Does NOT gate pull requests — pull-request-check.yml is unrelated.
# See QA_PLAN.md §5 for the full specification.

on:
schedule:
# Monday 06:00 UTC = Monday 10:00 Yerevan (UTC+4).
- cron: '0 6 * * 1'
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
type: choice
required: true
default: staging
options:
- staging
- production
scope:
description: 'Which tier to run (ignored if spec_path is provided)'
type: choice
required: true
default: all
options:
- all
- P0
- P1
- P2
spec_path:
description: 'Optional specific spec file or glob (overrides scope)'
type: string
required: false
default: ''
browser:
description: 'Browser to run against'
type: choice
required: true
default: chromium
options:
- chromium
- firefox
- webkit
- all

permissions:
contents: read

jobs:
playwright:
name: Playwright (${{ github.event.inputs.environment || 'staging' }} / ${{ github.event.inputs.scope || 'all' }} / ${{ github.event.inputs.browser || 'chromium' }})
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: 'true'
# scheduled run. Leave unset → the job will fail fast with a clear
# message instead of silently hitting localhost.
#
# Suggested setup:
# If the environment needs auth headers or cookies to reach the app,
# add those as repo secrets and wire them through `env:` here.
PLAYWRIGHT_STAGING_URL: ${{ vars.PLAYWRIGHT_STAGING_URL }}
PLAYWRIGHT_PRODUCTION_URL: ${{ vars.PLAYWRIGHT_PRODUCTION_URL }}
# Disable the webServer block in playwright.config.ts — in CI we run
# against a deployed URL, never against a freshly-spawned `yarn dev`.
PLAYWRIGHT_NO_SERVER: '1'
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Install Playwright browsers
run: yarn playwright install --with-deps ${{ github.event.inputs.browser == 'all' && 'chromium firefox webkit' || github.event.inputs.browser || 'chromium' }}

- name: Resolve base URL
id: resolve
run: |
ENV_INPUT="${{ github.event.inputs.environment || 'staging' }}"
if [ "$ENV_INPUT" = "production" ]; then
URL="$PLAYWRIGHT_PRODUCTION_URL"
else
URL="$PLAYWRIGHT_STAGING_URL"
fi
if [ -z "$URL" ]; then
echo "::error ::Base URL for environment '$ENV_INPUT' is not configured. Set the PLAYWRIGHT_${ENV_INPUT^^}_URL repository variable."
exit 1
fi
echo "base_url=$URL" >> "$GITHUB_OUTPUT"

- name: Resolve scope path
id: scope
run: |
SPEC_PATH="${{ github.event.inputs.spec_path }}"
SCOPE="${{ github.event.inputs.scope || 'all' }}"
if [ -n "$SPEC_PATH" ]; then
echo "path=$SPEC_PATH" >> "$GITHUB_OUTPUT"
else
case "$SCOPE" in
P0) echo "path=tests/p0/" >> "$GITHUB_OUTPUT" ;;
P1) echo "path=tests/p1/" >> "$GITHUB_OUTPUT" ;;
P2) echo "path=tests/p2/" >> "$GITHUB_OUTPUT" ;;
*) echo "path=tests/" >> "$GITHUB_OUTPUT" ;;
esac
fi

- name: Resolve Playwright projects
id: projects
run: |
BROWSER="${{ github.event.inputs.browser || 'chromium' }}"
if [ "$BROWSER" = "all" ]; then
echo "args=--project=chromium --project=firefox --project=webkit" >> "$GITHUB_OUTPUT"
else
echo "args=--project=$BROWSER" >> "$GITHUB_OUTPUT"
fi

- name: Run Playwright
env:
PLAYWRIGHT_BASE_URL: ${{ steps.resolve.outputs.base_url }}
run: |
yarn playwright test ${{ steps.scope.outputs.path }} ${{ steps.projects.outputs.args }}

- name: Upload HTML report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: playwright-report
retention-days: 14
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.vscode
.history
.idea/
.codex

# Logs
logs
Expand Down Expand Up @@ -119,3 +120,11 @@ package-lock.json

# Playwright
playwright-report/
test-results/
blob-report/
.playwright/

# Local QA planning notes (do not commit)
QA_PLAN.md
QA_RECON.md
TODO.md
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ interface CardProps {

const Card = ({ title, isActive, className }: CardProps) => {
return (
<div className={cn(styles.Card, className, { [styles.active]: isActive })}>
<div
className={cn(styles.card, className, { [styles.cardItem]: isActive })}
>
{title}
</div>
);
Expand Down Expand Up @@ -154,23 +156,23 @@ No Tailwind, no CSS-in-JS, no inline styles (except single dynamic properties li
import cn from 'classnames';
import styles from './Thing.module.scss';

<div className={cn(styles.Wrapper, { [styles.active]: isActive })} />;
<div className={cn(styles.wrapper, { [styles.active]: isActive })} />;
```

### Conditional classes

Always use `classnames` (imported as `cn`):

```tsx
className={cn(styles.Button, {
className={cn(styles.button, {
[styles.primary]: variant === 'primary',
[styles.disabled]: disabled,
})}
```

### SCSS class naming

PascalCase for new code: `.Card`, `.Wrapper`, `.Title`. The codebase mixes PascalCase and camelCase — prefer PascalCase going forward.
camelCase for new code: `.card`, `.cardItem`, `.wrapper`, `.title`. The codebase mixes PascalCase and camelCase — prefer camelCase going forward.

### Global styles

Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
"test:firefox": "cypress run --browser firefox",
"test:edge": "cypress run --browser edge",
"test:all": "npm run test:chrome && npm run test:firefox && npm run test:edge",
"test:e2e": "playwright test --project=chromium",
"test:e2e:ui": "playwright test --project=chromium --ui",
"test:e2e:p0": "playwright test tests/p0 --project=chromium",
"test:e2e:p1": "playwright test tests/p1 --project=chromium",
"test:e2e:p2": "playwright test tests/p2 --project=chromium",
"test:e2e:report": "playwright show-report",
"prepare": "husky install"
},
"dependencies": {
Expand Down Expand Up @@ -67,9 +73,11 @@
]
},
"devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@babel/core": "7.19.3",
"@cypress/react": "^9.0.1",
"@cypress/vite-dev-server": "^6.0.3",
"@playwright/test": "^1.59.1",
"@types/amplitude-js": "8.16.2",
"@types/classnames": "2.2.11",
"@types/lodash.debounce": "4.0.7",
Expand Down
52 changes: 52 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { defineConfig, devices } from '@playwright/test';

const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3005';
const isCI = !!process.env.CI;

export default defineConfig({
testDir: './tests',
testIgnore: ['**/fixtures/**', '**/helpers/**'],
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 1,
// Cap local workers. `next dev` compiles routes on-demand; unbounded
// parallelism starves the server and the first-hit latency blows past
// navigationTimeout on pages the compiler hasn't warmed yet. 2 is
// deliberately conservative — the suite still runs under 3 min.
workers: isCI ? 1 : 2,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
locale: 'en-US',
trace: 'on-first-retry',
testIdAttribute: 'data-testid',
actionTimeout: 15_000,
// Raised from 30s — accommodates `next dev`'s compile-on-first-hit cost
// when multiple workers each land on cold routes simultaneously.
navigationTimeout: 60_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: process.env.PLAYWRIGHT_NO_SERVER
? undefined
: {
command: 'yarn dev',
url: baseURL,
reuseExistingServer: !isCI,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
},
});
2 changes: 0 additions & 2 deletions src/components/ArticleSection/ArticleSection.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@
max-width: 1140px;
margin: 0 auto;
padding: 40px 0;
h2 {
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/components/contributors/Contributor/Contributor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const Contributor: FC<ContributorProps> = ({
}) => {
return (
<div
data-testid="contributor-card"
className={cn(styles.contributorCard, {
[styles.active]: isActive,
[styles.inactive]: !isActive,
Expand Down
2 changes: 2 additions & 0 deletions src/components/tools/ToolContainer/ToolContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const ToolContainer: FC<ToolContainerProps> = ({
return (
<>
<div
data-testid="tool-card"
data-in-development={isInDevelopment ? 'true' : 'false'}
className={cn(styles.container, {
[styles.darkTheme]: darkTheme,
})}
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/ContributorsLayout/ContributorsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const ContributorsLayout = forwardRef<HTMLElement, ContributorsLayoutProps>(
isDarkTheme={isDarkTheme}
locale={locale}
/>
<div className={styles.list}>
<div className={styles.list} data-testid="contributors-list">
{contributorsChangedOrder.contributors?.data.map(
(contributor, index) => {
const { name, japaneseLetter, role, socialLink, isActive } =
Expand Down
20 changes: 20 additions & 0 deletions tests/fixtures/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { BrowserContext } from '@playwright/test';

const ANALYTICS_HOSTS = [
'mixpanel.com',
'api-js.mixpanel.com',
'api.mixpanel.com',
'google-analytics.com',
'googletagmanager.com',
'analytics.ahrefs.com',
];

export async function blockAnalytics(context: BrowserContext): Promise<void> {
await context.route('**/*', route => {
const host = new URL(route.request().url()).hostname;
if (ANALYTICS_HOSTS.some(blocked => host.endsWith(blocked))) {
return route.fulfill({ status: 204, body: '' });
}
return route.continue();
});
}
28 changes: 28 additions & 0 deletions tests/fixtures/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test as baseTest } from '@playwright/test';

import { blockAnalytics } from './analytics';
import { dismissCookieBanner } from './cookieBanner';

const CANCEL_ROUTE_ERROR = /Cancel rendering route/i;

type Fixtures = {
dismissCookieBanner: () => Promise<void>;
};

export const test = baseTest.extend<Fixtures>({
context: async ({ context }, run) => {
await blockAnalytics(context);
await run(context);
},
page: async ({ page }, run) => {
page.on('pageerror', err => {
if (CANCEL_ROUTE_ERROR.test(err.message)) return;
});
await run(page);
},
dismissCookieBanner: async ({ page }, run) => {
await run(() => dismissCookieBanner(page));
},
});

export { expect };
17 changes: 17 additions & 0 deletions tests/fixtures/cookieBanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Page } from '@playwright/test';

const ACCEPT_SELECTOR = '[data-cy="cookie-box-accept"]';
const BANNER_SELECTOR = '[data-cy="cookie-box"]';

export async function dismissCookieBanner(page: Page): Promise<void> {
const banner = page.locator(BANNER_SELECTOR).first();
try {
await banner.waitFor({ state: 'visible', timeout: 3_000 });
} catch {
return;
}
await page.locator(ACCEPT_SELECTOR).first().click();
await banner
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
}
4 changes: 4 additions & 0 deletions tests/helpers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Test helpers

Shared utilities used across Playwright specs. Keep this folder minimal —
anything that's only needed by one spec belongs in that spec.
Loading
Loading