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
6 changes: 6 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ node_modules

# ignore mocks
**/mock.ts

# ignore Playwright's output, for the same reason as dist: the HTML report embeds the minified trace
# viewer. Both directories are mounted out of the container by tools/e2e/docker-compose.yml and turn
# up as soon as anyone runs the suite locally — but, like dist, never on the fresh checkout CI lints.
/playwright-report/
/test-results/
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Deliberately narrow. `* text=auto` is tempting but would renormalize every tracked file in a
# single commit, so line endings are left alone except where they are load-bearing.

# The Docker build inputs are consumed by a Linux shell. A contributor with core.autocrlf=true
# would otherwise commit CRLF into the Dockerfile's `RUN` continuations and the yarn shim it
# writes.
tools/e2e/** text eol=lf

# Git already detects these as binary; declaring it means no future filter or `text=auto` change
# can start mangling the screenshot baselines, which are compared byte-for-byte at threshold: 0.
*.png binary
22 changes: 17 additions & 5 deletions .github/workflows/e2e-approve-snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ jobs:
approve_snapshots:
if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/approve-snapshots') && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Same as e2e.yml: the container is rebuilt from scratch on every run, so the old 30-minute
# budget no longer covers a cold start.
timeout-minutes: 60
steps:
- uses: xt0rted/pull-request-comment-branch@e8b8daa837e8ea7331c0003c9c316a64c6d8b0b1 # v3.0.0
id: comment-branch
Expand All @@ -27,11 +29,21 @@ jobs:
- uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
with:
message: 🔄 [Updating](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) snapshots.
- uses: ./.github/workflows/actions/setup-node
- run: yarn run e2e:setup
# Regenerated in the same container that e2e.yml compares against. Doing it on the bare
# runner instead would mean the baselines are written by one renderer and checked by another,
# and this workflow would happily commit screenshots that fail the very next run.
#
# npm rather than yarn: no setup-node here, so the repository's Yarn 4 release is not on
# PATH. The container does its own install. e2e:docker:update-snapshots additionally mounts
# packages/components, which is how the rewritten PNGs reach the working tree for the commit
# step below — and which already exists from the checkout, unlike the report directories that
# tools/e2e/run.js creates.
- id: update-snapshots
run: |
yarn run e2e:components --update-snapshots
run: npm run e2e:docker:update-snapshots
env:
# As in e2e.yml: the compose default is tuned for developer machines, the runner wants
# its own core count.
PLAYWRIGHT_WORKERS: 100%
- uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
id: commit-and-push
with:
Expand Down
43 changes: 37 additions & 6 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,51 @@ on:
pull_request:

permissions:
contents: write
contents: read
pull-requests: write

jobs:
# Runs in the container built from tools/e2e/, not on the runner directly. The screenshots are
# compared with threshold: 0 against baselines that carry no {platform} suffix, so the thing that
# produces them has to be pinned; a bare runner is only pinned by whatever `ubuntu-latest` happens
# to mean this week. The same image is what `yarn run e2e:docker` gives a developer locally, which
# is the point — a failure here is reproducible off CI.
#
# Regeneration must go through the same image: see .github/workflows/e2e-approve-snapshots.yml.
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
# 30 minutes was sized for a job that only downloaded browsers. GitHub-hosted runners keep no
# Docker layer cache between runs, so every run now also pulls the base image and rebuilds the
# Node, font and `yarn install` layers from scratch.
timeout-minutes: 60
permissions:
contents: read # for actions/checkout to read the repository
pull-requests: write # for thollander/actions-comment-pull-request to comment on PRs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/setup-node
- run: yarn run e2e:setup
# No setup-node and no browser install: node is already on the runner, tools/e2e/run.js only
# reads package.json, and the browsers come baked into the image. That removes the ~174 MB
# `playwright install` download this job used to nurse through a timeout — but it is not a
# net time saving and should not be read as one, since the image build replaces it.
#
# What is bought with that is reproducibility, not speed. If the wall clock ever does become
# the problem, the answer is a prebuilt image pulled from GHCR by tag — not
# `cache-to: type=gha`, which would push well over a gigabyte of layers into the same 10 GB
# Actions cache that every other job's yarn cache is competing for.
#
# npm rather than yarn: without setup-node the repository's Yarn 4 release is never put on
# PATH, and the runner's own `yarn` is v1, which cannot read this manifest. Nothing is
# installed here either — the container does its own yarn install.
#
# The bind-mount targets are created by tools/e2e/run.js rather than by a step here, so that a
# local run gets the same treatment; see the comment there.
- id: run-e2e-tests
run: |
yarn run e2e:components
run: npm run e2e:docker
env:
# Back to the runner's own setting. The compose file caps workers for developer machines,
# where a container sees far more cores than one dev server can be driven from; a 4-vCPU
# runner has the opposite problem and wants all of them.
PLAYWRIGHT_WORKERS: 100%
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() }}
id: upload-report
Expand Down
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ apps/docs/src/app/components/design-tokens-viewers/data/*.ts

# ignore mocks
**/mock.ts

# ignore Playwright's output — see .eslintignore
/playwright-report/
/test-results/
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,17 @@ npx jest <TEST_PATH_PATTERN> # Run specific Jest tests (e.g., npx jest pac
yarn run e2e:setup # Install Playwright browsers (run once)
yarn run e2e:components # Run all E2E tests
npx playwright test <TEST_PATH_PATTERN> # Run specific E2E tests (e.g., npx playwright test packages/components/button/e2e.playwright-spec.ts)

# Screenshots differ across operating systems — always use Docker for anything visual:
yarn run e2e:docker # Run E2E tests in Docker (matches CI)
yarn run e2e:docker:update-snapshots # Run E2E tests in Docker and update the baselines
```

The committed baselines under `__screenshots__` are compared with `threshold: 0` and have no
platform suffix, so a native run outside Linux fails on font rasterization alone. `e2e:components`
is still useful for the assertion-based specs; use `e2e:docker` whenever screenshots are involved,
and never regenerate a baseline any other way.

### Linting

```bash
Expand Down
60 changes: 60 additions & 0 deletions docs/guides/06-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,63 @@ yarn run e2e:setup
```bash
yarn run e2e:components
```

### Visual regression tests and Docker

The screenshot baselines committed under `__screenshots__` are compared with `threshold: 0` and carry
no platform suffix, so they are tied to one operating system and one browser build. Running the suite
natively on Windows or macOS compares your machine's font rasterization against Linux bytes and fails
regardless of whether anything actually changed.

Run anything visual in Docker instead. The image is built from the Playwright release matching
`@playwright/test` in `package.json`, which is what CI runs too:

```bash
yarn run e2e:docker
```

To accept intentional visual changes, regenerate the baselines the same way and commit the result:

```bash
yarn run e2e:docker:update-snapshots
```

Arguments are passed through, replacing the container's command — for example, to run one component:

```bash
yarn run e2e:docker yarn playwright test packages/components/button
```

The container always runs with `CI=true`, so that Playwright behaves the way it does on the runner.
Two consequences matter when debugging inside it: `test.only` is rejected outright rather than
honoured (`forbidOnly`), and a failing test is retried twice before being reported. Narrow a run with
a path and `-g` instead of `test.only`:

```bash
yarn run e2e:docker yarn playwright test packages/components/select -g "single select"
```

Requires Docker with Compose v2. On Windows carrying Docker Engine inside WSL rather than Docker
Desktop, `docker.exe` is often missing from the Windows PATH altogether — the Linux binary cannot be
projected onto it — but the wrapper also falls back to WSL when a `docker.exe` is present yet broken
(no Compose v2 plugin, a stale install). Either way it forwards the run through `wsl.exe` and
translates the paths it passes, so the commands above work unchanged from PowerShell. It looks for
Docker inside WSL's default distribution; set `WSL_DISTRIBUTION` to a distribution name if Docker
lives elsewhere. That check only confirms the CLI and Compose v2 plugin are present, not that the
daemon itself is reachable — a stopped daemon, or a WSL user outside the `docker` group, still
surfaces later, when the actual `docker compose run` fails.

### Worker count

A container reports every core on the host, and Playwright sizes its worker pool from that. Since all
workers drive one shared Angular dev server, the useful ceiling comes from that server rather than
from the core count — on a 32-core machine `workers: '100%'` means 64 browsers, and the suite
collapses into timeouts that look like failures but are not. The compose file therefore caps workers
at 8. Override it when a machine wants something different:

```bash
PLAYWRIGHT_WORKERS=16 yarn run e2e:docker
```

Baselines can also be regenerated without a local Docker install by commenting `/approve-snapshots`
on a pull request.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"@messageformat/core": "^3.4.0",
"@microsoft/api-extractor": "7.56.0",
"@octokit/rest": "^18.9.1",
"@playwright/test": "^1.55.0",
"@playwright/test": "1.55.0",
"@prettier/plugin-xml": "^3.4.2",
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-json": "^6.0.0",
Expand Down Expand Up @@ -284,6 +284,8 @@
"dev:e2e": "ng serve dev-e2e",
"e2e:setup": "playwright install chromium --with-deps && playwright install webkit --with-deps",
"e2e:components": "playwright test",
"e2e:docker": "node tools/e2e/run.js",
"e2e:docker:update-snapshots": "node tools/e2e/run.js yarn run e2e:components --update-snapshots",
"-----API-----": "--------------------------------------------------------------------------------------------",
"approve-api": "ts-node --project tools/api-extractor/tsconfig.json tools/api-extractor/api-extractor.ts",
"check-api": "yarn run approve-api onlyCheck",
Expand Down
32 changes: 32 additions & 0 deletions packages/components/code-block/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,41 @@ import { e2eEnableDarkTheme } from 'packages/e2e/utils';
test.describe('KbqCodeBlockModule', () => {
test.describe('E2eCodeBlockStates', () => {
const getComponent = (page: Page) => page.getByTestId('e2eCodeBlockStates');
const codeBlock = 'code.kbq-code-block__code';

/**
* Highlighting lands after the initial render: KbqCodeBlockHighlight reaches highlight.js through
* a dynamic import, then rewrites each block's innerHTML and stamps `data-language` on the element
* as it finishes. Nothing the component renders blocks on that, so a screenshot taken straight
* after navigation can catch the page part-highlighted — some blocks already carrying their
* line-number table, others still plain text.
*
* Left to toHaveScreenshot's own retries this does not fail informatively. The half-applied state
* changes the element's height rather than a few pixels, so it surfaces as `Expected an image
* 1556px by 3540px, received 1556px by 3232px` — which reads like a layout regression, not a race.
* It also only appears under load: eight workers against one dev server reproduced it here, four
* did not.
*
* The first assertion is the guard. Without it the second passes trivially against a page that has
* not rendered any code blocks yet.
*/
const waitForHighlighting = async (page: Page) => {
await expect(page.locator(codeBlock).first()).toBeAttached();
await expect(page.locator(`${codeBlock}:not([data-language])`)).toHaveCount(0);
};

test('states', async ({ page }) => {
/**
* This is the heaviest page in the suite: fifteen code blocks, each highlighted and rebuilt
* into a line-numbered table, then captured twice at roughly 1556x3540. The 15s default in
* playwright.config.ts is sized for pages a fraction of that, and once several workers share
* one dev server this test lands between 9s and 17s — so it does not fail on a wrong render
* but on the budget, with no screenshot taken at all to explain why. Tripling it via slow()
* costs nothing when the test passes and leaves the assertions untouched.
*/
test.slow();
await page.goto('/E2eCodeBlockStates');
await waitForHighlighting(page);
await expect(getComponent(page)).toHaveScreenshot('01-light.png');
await e2eEnableDarkTheme(page);
await expect(getComponent(page)).toHaveScreenshot('01-dark.png');
Expand Down
Binary file modified packages/components/icon/__screenshots__/02-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 23 additions & 2 deletions packages/components/icon/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,30 @@ test.describe('KbqIconModule', () => {
test.describe('E2eIconSvg', () => {
const getComponent = (page: Page) => page.getByTestId('e2eIconSvg');

test('svg icons - dropdown open', async ({ page }) => {
/**
* Every icon on this page arrives over HTTP: E2eIconSvg registers a resolver mapping each name
* onto /assets/SVGIcons/<name>.svg, and KbqIcon injects the response into its host element once
* it lands. Until then the host is empty and occupies no space, so the text around it sits where
* it will not stay — a capture taken too early differs as a whole-page horizontal shift rather
* than as one wrong-looking icon, which is misleading enough to be worth ruling out here.
*
* Safe to require of every icon because every icon on this page resolves to inline SVG; none
* falls back to the font-class path, so this cannot hang on one that was never going to load.
* That is a property of E2eIconSvg's resolver provider, not of KbqIcon — the sibling page above
* has no such provider and renders its icons as font classes, so the same wait there would never
* be satisfied.
*
* The first assertion is the guard: without it the second passes trivially against a page that
* has not rendered yet.
*/
const waitForIcons = async (page: Page) => {
await expect(page.locator('.kbq-icon').first()).toBeAttached();
await expect(page.locator('.kbq-icon:not(:has(svg))')).toHaveCount(0);
};

test('svg icons', async ({ page }) => {
await page.goto('/E2eIconSvg');
await page.getByTestId('e2eIconSvgDropdownTrigger').click();
await waitForIcons(page);
await expect(getComponent(page)).toHaveScreenshot('02-light.png');
});
});
Expand Down
16 changes: 0 additions & 16 deletions packages/components/icon/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqComponentColors } from '@koobiq/components/core';
import { KbqDropdownModule } from '@koobiq/components/dropdown';
import { KbqFileUploadModule } from '@koobiq/components/file-upload';
import { KbqFormFieldModule, PasswordRules } from '@koobiq/components/form-field';
import {
Expand Down Expand Up @@ -170,7 +169,6 @@ export class E2eIconStateAndStyle {
KbqTagsModule,
KbqButtonModule,
KbqSplitButtonModule,
KbqDropdownModule,
KbqFileUploadModule,
KbqLinkModule,
KbqFormFieldModule,
Expand Down Expand Up @@ -351,20 +349,6 @@ export class E2eIconStateAndStyle {
<kbq-cleaner />
</kbq-form-field>
</div>

<div>
<button kbq-button data-testid="e2eIconSvgDropdownTrigger" [kbqDropdownTriggerFor]="dropdown">
Dropdown
<i kbq-icon="kbq-chevron-down-s_16"></i>
</button>

<kbq-dropdown #dropdown="kbqDropdown">
<button kbq-dropdown-item>
<i kbq-icon="kbq-circle-check_16"></i>
Item with icon
</button>
</kbq-dropdown>
</div>
</div>
</div>
`,
Expand Down
21 changes: 21 additions & 0 deletions packages/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,24 @@ yarn run e2e:components
# Run a specific E2E test file
yarn playwright test packages/components/button/e2e.playwright-spec.ts
```

## Screenshots

The baselines under each component's `__screenshots__` directory are compared with `threshold: 0` and
have no platform suffix, so they belong to one operating system and one browser build. The commands
above only compare them meaningfully on Linux; anywhere else they fail on font rasterization alone.

Run anything visual in Docker, which uses the Playwright image matching `@playwright/test` and is what
CI runs as well:

```bash
# Run the suite in Docker
yarn run e2e:docker

# Accept intentional visual changes and rewrite the baselines
yarn run e2e:docker:update-snapshots
```

Requires Docker with Compose v2 — see [Testing → Visual regression tests and Docker](../../docs/guides/06-testing.md#visual-regression-tests-and-docker)
for what the wrapper does on Windows when Docker only runs inside WSL. Without a local Docker
install, comment `/approve-snapshots` on a pull request to regenerate the baselines in CI.
Loading