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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ jobs:
# catches a hang in setup or in either install without holding the machine for
# the six-hour platform default.
timeout-minutes: 10
# A boolean rather than the token, because `secrets` is not an allowed
# context in a step's `if`. The token itself stays on the one step that
# needs it, out of the environment of the install and of every action here.
env:
HAS_CHROMATIC: ${{ secrets.CHROMATIC_PROJECT_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -160,6 +165,37 @@ jobs:
with:
path: dist

# Last, because every step above carries no status function and so inherits
# success(): a Chromatic failure placed earlier would skip the two uploads,
# the scan and the Pages artifact. It reads the archives the end-to-end
# sweep already wrote, so it rebuilds and re-runs nothing.
#
# Pull requests and main only. Both event types fire on a branch with an
# open pull request, in different concurrency groups, so without this every
# commit is captured and metered twice.
#
# Exits once the build is uploaded rather than waiting out the capture, so
# a slow queue cannot spend the job's budget. The accept gate is
# Chromatic's own pull request check rather than this exit code. The token
# is guarded rather than the actor, because this repository is public and a
# fork pull request gets no secret at all.
#
# The action rather than the CLI, because on a pull request the checkout is
# a merge ref and the CLI files the build against a commit that exists in
# no branch and is later collected. The action reads the head commit off
# the event payload. It carries its own copy of the CLI, so keep this pin
# and the one in package.json on the same version.
- name: Chromatic
if: >-
env.HAS_CHROMATIC == 'true'
&& (github.event_name == 'pull_request' || github.ref == 'refs/heads/main')
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
uses: chromaui/action@2a0b63f30233c48591844a46d451b9cf68128186 # v18.7.2
with:
playwright: true
exitOnceUploaded: true

# A job rather than its own workflow, because `needs` is what makes the publish
# wait on the gate. The write permissions sit here so the job running untrusted
# pull request code keeps its read-only token.
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,9 @@ src/__screenshots__/
# test-results/, which the end-to-end runner clears before every run, and
# rewritten each time rather than describing this tree.
/junit

# The Chromatic CLI's own artifacts, written into the repository root. The build
# log is named for the archive build script, which is the one this project runs.
chromatic.log
chromatic-diagnostics.json
build-archive.log
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ The static head of the document stays in the base language too. Its title, its d
- [Sass](https://sass-lang.com/) for the CSS Modules stylesheets
- [React Icons](https://react-icons.github.io/react-icons/)
- [axe-core](https://github.com/dequelabs/axe-core) for the accessibility sweeps
- [Chromatic](https://www.chromatic.com/) for the visual regression snapshots
- [ESLint](https://eslint.org/), [Stylelint](https://stylelint.io/), and [Prettier](https://prettier.io/)

### Build target
Expand Down Expand Up @@ -387,27 +388,28 @@ it("sorts by population descending on the second activation", async () => {

The assertions read `aria-sort`, the same attribute a screen reader announces, so a passing test is evidence the announcement is right.

A second suite under `e2e/` runs in a real browser against a production build, covering four things a simulated DOM cannot show: that reopening a link restores the search, sort and page it carries; that Back and Forward move through history the way the shareable-link design intends; that the theme and the language are stamped before the first paint rather than after the page loads; and that the dataset arrives over the network as a separate content-hashed asset.
A second suite under `e2e/` runs in a real browser against a production build, covering five things a simulated DOM cannot show: that reopening a link restores the search, sort and page it carries; that Back and Forward move through history the way the shareable-link design intends; that the theme and the language are stamped before the first paint rather than after the page loads; that the dataset arrives over the network as a separate content-hashed asset; and how the table actually renders, captured as snapshots for visual comparison.

The pipeline sends three reports to [Codecov](https://codecov.io/gh/funkadelic/YART): the coverage the hundred percent gate is measured on, a JUnit report from each of the three suites, and the size of every emitted asset. A test that fails intermittently is flagged as a flake. The asset sizes come from Codecov's standalone analyzer, which reports assets and not individual modules.

## Scripts

| Script | What it does |
| ------------------------- | --------------------------------------------------------------------- |
| `npm run dev` | Start the dev server with hot reload |
| `npm run build` | Build the production bundle |
| `npm run preview` | Serve the built bundle locally |
| `npm test` | Run the test suite once |
| `npm run test:watch` | Run the test suite in watch mode |
| `npm run test:coverage` | Run the test suite once with coverage, which CI enforces at 100% |
| `npm run test:browser` | Run the accessibility checks in a real Chromium |
| `npm run test:e2e` | Run the end-to-end suite in a real Chromium against a built bundle |
| `npm run typecheck` | Check types without emitting output |
| `npm run lint` | Run ESLint then Stylelint; a warning fails it (`lint:fix` to autofix) |
| `npm run format` | Run Prettier |
| `npm run format:check` | Check formatting without rewriting anything |
| `npm run generate:cities` | Regenerate the committed dataset asset from the upstream CSV export |
| Script | What it does |
| ------------------------- | -------------------------------------------------------------------------- |
| `npm run dev` | Start the dev server with hot reload |
| `npm run build` | Build the production bundle |
| `npm run preview` | Serve the built bundle locally |
| `npm test` | Run the test suite once |
| `npm run test:watch` | Run the test suite in watch mode |
| `npm run test:coverage` | Run the test suite once with coverage, which CI enforces at 100% |
| `npm run test:browser` | Run the accessibility checks in a real Chromium |
| `npm run test:e2e` | Run the end-to-end suite in a real Chromium against a built bundle |
| `npm run chromatic` | Upload the snapshots a full `npm run test:e2e` archived, for visual review |
| `npm run typecheck` | Check types without emitting output |
| `npm run lint` | Run ESLint then Stylelint; a warning fails it (`lint:fix` to autofix) |
| `npm run format` | Run Prettier |
| `npm run format:check` | Check formatting without rewriting anything |
| `npm run generate:cities` | Regenerate the committed dataset asset from the upstream CSV export |

`npm run test:browser` and `npm run test:e2e` both drive a real Chromium. `npm ci` downloads neither that browser nor the system libraries it needs, so a clean clone fetches both once with `npx playwright install --with-deps --only-shell chromium`, whose `--with-deps` half needs `sudo` on Linux. CI runs that same command, so every path installs the same binary.

Expand Down
84 changes: 84 additions & 0 deletions e2e/visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from "@chromatic-com/playwright";

/**
* Five deliberate view states, one per test, for visual regression.
*
* The fixture captures one snapshot at the end of each test, so each test
* settles its view and ends without a capture call.
*/

// The engine fetches the real multi-megabyte dataset over the preview server,
// so the wait matches the one the other end-to-end specs declare.
const DATASET_READY_TIMEOUT_MS = 20_000;

// The two storage keys the blocking script in index.html reads, restated
// rather than imported so this file cannot pass for whatever value the
// subject happens to hold.
const THEME_STORAGE_KEY = "yart-theme";
const LOCALE_STORAGE_KEY = "yart-locale";

// The one catalog that ships reading right to left.
const RTL_CATALOG_ID = "ar-XB";

// The empty-results sentence from the English catalog.
const EMPTY_MESSAGE = "No cities found";

test("default view", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("table")).toBeVisible({
timeout: DATASET_READY_TIMEOUT_MS,
});
});

test("searched, sorted and paged view", async ({ page }) => {
await page.goto("/?q=san&sort=-population&page=2&size=25");
await expect(page.getByRole("table")).toBeVisible({
timeout: DATASET_READY_TIMEOUT_MS,
});
// The row count settles after the table appears, so the snapshot is of the
// finished page rather than a mid-render one.
await expect(page.locator("tbody tr")).toHaveCount(25);
});

test("dark theme", async ({ page }) => {
// Seeded before the first navigation so the blocking script in index.html
// stamps the theme on the first frame and there is no light flash to capture.
await page.addInitScript(
({ key, value }: { key: string; value: string }) => {
window.localStorage.setItem(key, value);
},
{ key: THEME_STORAGE_KEY, value: "dark" },
);
await page.goto("/");
await expect(page.getByRole("table")).toBeVisible({
timeout: DATASET_READY_TIMEOUT_MS,
});
// A snapshot that silently captured the light theme fails here rather than
// passing as a new baseline.
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});

test("right-to-left locale", async ({ page }) => {
// Seeded before the first navigation, for the same reason as the theme above.
await page.addInitScript(
({ key, value }: { key: string; value: string }) => {
window.localStorage.setItem(key, value);
},
{ key: LOCALE_STORAGE_KEY, value: RTL_CATALOG_ID },
);
await page.goto("/");
await expect(page.getByRole("table")).toBeVisible({
timeout: DATASET_READY_TIMEOUT_MS,
});
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
});

test("empty results", async ({ page }) => {
await page.goto("/?q=zzzzzz");
// The empty branch replaces the table outright, so waiting on a table role
// here would time out. Matched exactly, because the live region beside it
// opens with the same words.
await expect(page.getByText(EMPTY_MESSAGE, { exact: true })).toBeVisible({
timeout: DATASET_READY_TIMEOUT_MS,
});
});
9 changes: 5 additions & 4 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ const TEST_SCAFFOLDING_IMPORT = {
};

export default defineConfig([
// Build output, not authored source. `eslint .` walks the working tree, so
// without this the gate reports parse errors for an emitted bundle and a
// coverage report.
// Build output and test-runner output, not authored source. `eslint .` walks
// the working tree, so without this the gate reports parse errors for an
// emitted bundle, a coverage report, and the page resources the visual spec
// archives under test-results/.
{
ignores: ["dist/", "coverage/"],
ignores: ["dist/", "coverage/", "test-results/", "playwright-report/"],
},
{
files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"],
Expand Down
Loading
Loading