From 2c124ed875f31f78b85873fe9343b5e8c5bad150 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 19:34:44 +0000 Subject: [PATCH 1/6] build(site): add Playwright's test runner for browser tests of the explorer A dev dependency only, at an exact version, and not part of the published package. The browser itself is not an npm dependency: Playwright downloads it on request, which CI does through a cache keyed on this version. --- package.json | 1 + pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/package.json b/package.json index fe9660d..5eb4e0c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@commitlint/config-conventional": "21.2.4", "@eslint/js": "10.0.1", "@exadev/eslint-config": "3.1.0", + "@playwright/test": "1.64.1", "@semantic-release/changelog": "7.0.0", "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/git": "11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b8209..984ea7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@exadev/eslint-config': specifier: 3.1.0 version: 3.1.0(@eslint/json@2.1.1)(eslint@10.13.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.72.0(eslint@10.13.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 '@semantic-release/changelog': specifier: 7.0.0 version: 7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) @@ -648,6 +651,11 @@ packages: '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2371,6 +2379,16 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.31: resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} @@ -3502,6 +3520,10 @@ snapshots: '@oxc-project/types@0.155.0': {} + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -5137,6 +5159,12 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss@8.5.31: dependencies: nanoid: 3.3.20 From 3dc9ff6308512e970411794019c52bd35531c004 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 19:34:46 +0000 Subject: [PATCH 2/6] test(site): drive the built explorer in a browser The checks that were run by hand against the built site are now Playwright tests in e2e/, in TypeScript under the repository's lint rules, with assertions in place of logging. The harness builds the site and serves it with vite preview itself and stops it afterwards, on a fixed port that fails if something already holds it. Retries are off, since a test that passes only on a retry is a flake to fix. The tests cover the operations and cancelling a count that never finishes, the form and the JSON view as one model, every kind of node the schema defines, share links, gists, saved models, import and export, the operation descriptions, keyboard-only editing, phone width and the colour schemes. The unit run excludes e2e/, so `pnpm test` stays browserless, and `pnpm typecheck` covers the new project. --- .gitignore | 2 + e2e/explorer.ts | 175 ++++++++++++ e2e/form.e2e.ts | 608 ++++++++++++++++++++++++++++++++++++++++++ e2e/help.e2e.ts | 125 +++++++++ e2e/operations.e2e.ts | 191 +++++++++++++ e2e/sharing.e2e.ts | 169 ++++++++++++ e2e/tsconfig.json | 9 + eslint.config.ts | 4 +- package.json | 3 +- playwright.config.ts | 44 +++ vitest.config.ts | 4 +- 11 files changed, 1330 insertions(+), 4 deletions(-) create mode 100644 e2e/explorer.ts create mode 100644 e2e/form.e2e.ts create mode 100644 e2e/help.e2e.ts create mode 100644 e2e/operations.e2e.ts create mode 100644 e2e/sharing.e2e.ts create mode 100644 e2e/tsconfig.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 7ed54e8..7807b37 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ coverage/ .eslintcache .turbo/ reports/ +test-results/ +playwright-report/ # The instruction files are tracked symlinks to README.md; a global ignore may otherwise exclude them. !CLAUDE.md diff --git a/e2e/explorer.ts b/e2e/explorer.ts new file mode 100644 index 0000000..e20f023 --- /dev/null +++ b/e2e/explorer.ts @@ -0,0 +1,175 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import type { Json, JsonObject, JsonStep } from '../site/src/json'; +import { getIn, isJsonObject, parseJson } from '../site/src/json'; + +/** The most a check of the model, or an operation on it, may take to show its result, in milliseconds. It bounds a hang: the page's own work is counted in combinations, and the models here are small. */ +export const RESULT_TIMEOUT_MS = 20_000; + +/** The model of the page's first example, which the page starts on: two rules' worth of structure over three dimensions. */ +export const STARTING_EXAMPLE = 'A rule: bun is not offered on Windows'; + +/** How much of an unnamed control's markup is put in a failure message, so the control can be found. */ +const REPORTED_HTML_LENGTH = 80; + +/** A text node of a rule's tree, as the form writes it. */ +export function textLiteral(value: string): JsonObject { + return { kind: 'textLiteral', value }; +} + +/** A reference to a dimension, as the form writes it. */ +export function reference(key: string): JsonObject { + return { kind: 'reference', key }; +} + +/** The comparison of a dimension with a text. */ +export function textEquals(key: string, value: string): JsonObject { + return { kind: 'textCompare', op: 'equals', left: reference(key), right: textLiteral(value) }; +} + +/** Runs an action on each item, one after the other: the page is one stateful thing, so the steps of a test cannot overlap. */ +export async function inOrder(items: readonly T[], action: (item: T) => Promise): Promise { + await items.reduce(async (previous, item) => { + await previous; + await action(item); + }, Promise.resolve()); +} + +/** The addresses of the requests a page makes outside the origin of the base address, as the page makes them: the explorer is meant to make none besides the page's own files, the optional gist fetch aside. The list is filled as the page runs. */ +export function trackExternalRequests(page: Page, baseURL: string | undefined): string[] { + const origin = new URL(String(baseURL)).host; + const outside: string[] = []; + page.on('request', (request) => { + const { protocol, host } = new URL(request.url()); + if (protocol !== 'data:' && protocol !== 'blob:' && host !== origin) outside.push(request.url()); + }); + + return outside; +} + +/** What the tests know of the explorer's page: its controls by the field id the form gives each, and the few things every test starts with. */ +export class Explorer { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** Opens the page, at a path under its base address, and waits for the model it starts on to be checked. */ + static async open(page: Page, path = ''): Promise { + const explorer = new Explorer(page); + await page.goto(path); + await explorer.ready(); + + return explorer; + } + + /** The control with a field id: every control of the form has one, the same on every draw. */ + field(id: string): Locator { + return this.page.locator(`[data-field=${JSON.stringify(id)}]`); + } + + /** The element with an id. */ + id(id: string): Locator { + return this.page.locator(`#${id}`); + } + + /** Waits until the model in the editor has been checked and found valid. */ + async ready(): Promise { + await expect(this.page.locator('#validation .valid')).toBeVisible({ timeout: RESULT_TIMEOUT_MS }); + } + + /** The Advanced view, opened if it is closed. */ + async advanced(): Promise { + const open = await this.id('advanced').evaluate((element) => element instanceof HTMLDetailsElement && element.open); + if (!open) await this.page.locator('#advanced summary').click(); + + return this.id('model'); + } + + /** Replaces the model's JSON text, as typing it would, and waits for it to be checked and found valid unless the caller expects it not to be. */ + async setJson(model: Json, options: { readonly expectValid?: boolean } = {}): Promise { + const editor = await this.advanced(); + await editor.fill(typeof model === 'string' ? model : JSON.stringify(model)); + if (options.expectValid !== false) await this.ready(); + } + + /** Replaces the JSON text with text that need not be JSON. */ + async setText(text: string): Promise { + const editor = await this.advanced(); + await editor.fill(text); + } + + /** The model the JSON view holds, parsed. */ + async json(): Promise { + const editor = await this.advanced(); + const parsed = parseJson(await editor.inputValue()); + if (!isJsonObject(parsed)) throw new Error('the JSON view does not hold an object'); + + return parsed; + } + + /** The value at a path of the model the JSON view holds. */ + async at(path: readonly JsonStep[]): Promise { + return getIn(await this.json(), path); + } + + /** Loads an example from the menu, and waits for it to be valid. */ + async loadExample(name: string): Promise { + await this.id('example').selectOption({ label: name }); + await this.ready(); + } + + /** The text of an operation's result. */ + result(id: 'count' | 'list' | 'diagnose' | 'covering' | 'combination'): Locator { + return this.id(`${id}-result`); + } + + /** Runs an operation by its button. */ + async run(button: 'count' | 'list' | 'diagnose' | 'covering' | 'at' | 'explain'): Promise { + await this.id(button).click(); + } + + /** Sets a number field. */ + async setNumber(id: 'list-limit' | 'strength' | 'index', value: number): Promise { + await this.id(id).fill(String(value)); + } + + /** The cards of the form's dimensions, or of its rules. */ + cards(part: 'dimensions' | 'rules'): Locator { + return this.page.locator(`#${part}-heading`).locator('..').locator('li.card'); + } + + /** The accessible names of the controls of the form, which must be given to each. */ + async unnamedControls(): Promise { + return this.page.evaluate(() => + [...document.querySelectorAll('#form button, #form input, #form select, #form textarea')] + .filter((element) => { + const labelled = element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement ? (element.labels?.length ?? 0) > 0 : false; + + return !labelled && element.getAttribute('aria-label') === null && element.textContent.trim() === ''; + }) + .map((element) => element.dataset['field'] ?? element.outerHTML.slice(0, REPORTED_HTML_LENGTH)), + ); + } + + /** The field id of the control that has focus, or the empty text when none has one. */ + async focused(): Promise { + return this.page.evaluate(() => document.activeElement?.getAttribute('data-field') ?? ''); + } + + /** Presses Tab until the control with a field id has focus, as a keyboard-only user would. It gives up after a full turn of the page's focusable controls, which is as many presses as there are controls. */ + async tabTo(id: string): Promise { + const controls = await this.page.locator('button, input, select, textarea, summary, a[href]').count(); + await this.tabToWithin(id, controls); + } + + private async tabToWithin(id: string, presses: number): Promise { + if ((await this.focused()) === id) return; + if (presses === 0) throw new Error(`Tab never reached ${id}; focus is on ${await this.focused()}`); + + await this.page.keyboard.press('Tab'); + await this.tabToWithin(id, presses - 1); + } +} diff --git a/e2e/form.e2e.ts b/e2e/form.e2e.ts new file mode 100644 index 0000000..3a9da70 --- /dev/null +++ b/e2e/form.e2e.ts @@ -0,0 +1,608 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { EXAMPLES, exampleText } from '../site/src/examples'; +import type { Json, JsonObject } from '../site/src/json'; +import { getIn, isJsonArray, isJsonObject } from '../site/src/json'; +import { EXPRESSION_KINDS, PREDICATE_KINDS } from '../site/src/nodes'; +import { Explorer, inOrder, reference, RESULT_TIMEOUT_MS, textEquals, textLiteral, trackExternalRequests } from './explorer'; + +/** The model the first test builds by hand, written as the form writes it. The rule excludes a combination on Windows or macOS that is not arm64. */ +const BUILT_BY_HAND: JsonObject = { + dimensions: [ + { kind: 'choice', name: 'os', values: ['linux', 'macos', 'windows'] }, + { kind: 'choice', name: 'arch', values: ['x64', 'arm64'] }, + { kind: 'choice', name: 'runtime', values: ['node', 'bun', 'deno'] }, + ], + rules: [ + { + excludes: { + kind: 'and', + left: { kind: 'or', left: textEquals('os', 'windows'), right: textEquals('os', 'macos') }, + right: { kind: 'not', operand: textEquals('arch', 'arm64') }, + }, + }, + ], +}; + +/** The size of that model, worked out by hand: 3 systems by 2 architectures by 3 runtimes is 18, and the rule removes 2 systems by 1 architecture by 3 runtimes, which is 6. */ +const BUILT_BY_HAND_SIZE = 12; + +/** How many of those combinations are on Linux (all 2 by 3 of them) and how many on the other two systems (the arm64 ones, 2 systems by 3 runtimes). */ +const LINUX_ROWS = 6; +const ARM_ROWS = 6; + +/** A model with two number dimensions and a text one, for the tests that build conditions of each kind. */ +/** How many values the number dimension of the mixed model has, and how many dimensions and rules the page's starting example has. */ +const LEVEL_COUNT = 3; +const STARTING_DIMENSIONS = 3; + +/** A level written as a long number, and what it is. */ +const SCIENTIFIC_LEVEL = '1e2'; +const SCIENTIFIC_LEVEL_VALUE = 100; + +const levels = (): number[] => Array.from({ length: LEVEL_COUNT }, (_, index) => index + 1); + +const MIXED_MODEL: JsonObject = { + dimensions: [ + { kind: 'choice', name: 'n', values: levels() }, + { kind: 'choice', name: 't', values: ['a', 'b'] }, + ], + rules: [{ excludes: { kind: 'exists', operand: reference('t') } }], +}; + +/** The size of that model once its rule is `n > 2`: 3 numbers by 2 texts is 6 and `n = 3` removes 2. */ +const MIXED_MODEL_SIZE_WITH_RULE = 4; + +/** A first-valid dimension with an activity condition, for the dimension tests, and the size it makes: `mode = fast` keeps 2 levels, `slow` leaves the level unset, so 2 plus 1. */ +const ACTIVITY_MODEL_SIZE = 3; + +/** The two dimensions added to the starting example by the keyboard test, and the value typed. */ +const TYPED_VALUE = 'freebsd'; + +/** A name and a rule label long enough to test wrapping at phone width. */ +const LONG_NAME = 'a-dimension-with-a-rather-long-name-to-test-wrapping'; +const PHONE_WIDTH = 375; +const PHONE_HEIGHT = 800; +const NESTING = 8; + +/** The dark scheme's page background and the light one's, which the tokens in style.css give. */ +const DARK_BACKGROUND = 'rgb(18, 21, 26)'; +const LIGHT_BACKGROUND = 'rgb(255, 255, 255)'; + +/** Adds a dimension with values through the form, at a position, as a person does. */ +async function addDimension(explorer: Explorer, position: number, name: string, values: readonly string[]): Promise { + await explorer.field('dimensions:add').click(); + const nameField = explorer.field(`dimensions.${String(position)}:name`); + await nameField.fill(name); + await nameField.blur(); + await inOrder( + values.map((value, index) => ({ value, index })), + async ({ value, index }) => { + if (index > 0) await explorer.field(`dimensions.${String(position)}.values:add`).click(); + await explorer.field(`dimensions.${String(position)}.values.${String(index)}:value`).fill(value); + }, + ); +} + +/** Opens the page and replaces the model by the given one, through the Advanced view. A model the library refuses (data the form keeps but the engine cannot use) is opened with `valid: false`, and the form is waited for instead. */ +async function openWith(page: Page, model: Json, options: { readonly valid?: boolean } = {}): Promise { + const explorer = await Explorer.open(page); + await explorer.setJson(model, { expectValid: options.valid !== false }); + + return explorer; +} + +test.describe('building a model with the form', () => { + test('shows the starting example in the form, with the JSON view collapsed', async ({ page }) => { + const explorer = await Explorer.open(page); + + await expect(explorer.id('advanced')).not.toHaveAttribute('open'); + await expect(explorer.cards('dimensions')).toHaveCount(STARTING_DIMENSIONS); + await expect(explorer.cards('rules')).toHaveCount(1); + await expect(explorer.field('dimensions.0:name')).toHaveValue('os'); + await expect(explorer.field('dimensions.0.values.2:value')).toHaveValue('windows'); + await expect(explorer.field('rules.0:name')).toHaveValue('bun is not offered on windows'); + }); + + test('builds a model from nothing with only the form, and the JSON view and the engine agree with it', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.id('empty-model').click(); + await explorer.ready(); + + await expect(explorer.id('form')).toContainText('No dimensions yet'); + await addDimension(explorer, 0, 'os', ['linux', 'macos', 'windows']); + await addDimension(explorer, 1, 'arch', ['x64', 'arm64']); + await addDimension(explorer, 2, 'runtime', ['node', 'bun', 'deno']); + await explorer.field('rules:add').click(); + await explorer.field('rules.0.excludes:kind').selectOption({ label: 'both' }); + await explorer.field('rules.0.excludes.left:kind').selectOption({ label: 'either' }); + await inOrder( + [ + ['left', 'windows'], + ['right', 'macos'], + ] as const, + async ([side, value]) => { + await explorer.field(`rules.0.excludes.left.${side}:kind`).selectOption({ label: 'compares text' }); + await explorer.field(`rules.0.excludes.left.${side}.right.value:value`).fill(value); + }, + ); + await explorer.field('rules.0.excludes.right:kind').selectOption({ label: 'not' }); + await explorer.field('rules.0.excludes.right.operand:kind').selectOption({ label: 'compares text' }); + await explorer.field('rules.0.excludes.right.operand.left:key').selectOption({ label: 'arch' }); + await explorer.field('rules.0.excludes.right.operand.right.value:value').fill('arm64'); + await explorer.ready(); + + expect(await explorer.json()).toEqual(BUILT_BY_HAND); + await explorer.run('count'); + await expect(explorer.result('count')).toContainText(`${String(BUILT_BY_HAND_SIZE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + await explorer.id('list-limit').fill('100'); + await explorer.run('list'); + await expect(explorer.result('list')).toContainText(`All ${String(BUILT_BY_HAND_SIZE)} combinations.`, { timeout: RESULT_TIMEOUT_MS }); + const rows = await explorer.result('list').locator('tr').allInnerTexts(); + const body = rows.slice(1).map((row) => row.replaceAll(/\s+/g, ' ')); + + expect(body).toHaveLength(BUILT_BY_HAND_SIZE); + expect(body.filter((row) => /"(windows|macos)" "x64"/.test(row))).toEqual([]); + expect(body.filter((row) => /"(windows|macos)" "arm64"/.test(row))).toHaveLength(ARM_ROWS); + expect(body.filter((row) => row.includes('"linux"'))).toHaveLength(LINUX_ROWS); + }); + + test('keeps the caret and every character while typing in a field, and the JSON follows each keystroke', async ({ page }) => { + const explorer = await Explorer.open(page); + const field = explorer.field('dimensions.2.values.0:value'); + await field.fill(''); + await field.focus(); + await page.keyboard.type('node-twenty'); + + await expect(field).toHaveValue('node-twenty'); + expect(await explorer.focused()).toBe('dimensions.2.values.0:value'); + expect(await explorer.at(['dimensions', 2, 'values', 0])).toBe('node-twenty'); + }); + + test('renames a dimension together with every reference to it, and the model stays valid', async ({ page }) => { + const explorer = await Explorer.open(page); + const name = explorer.field('dimensions.0:name'); + await name.fill('system'); + await name.blur(); + await explorer.ready(); + + const model = await explorer.json(); + expect(JSON.stringify(model['rules'])).toContain('"key":"system"'); + expect(JSON.stringify(model['rules'])).not.toContain('"key":"os"'); + await expect(explorer.field('rules.0.excludes.left:key')).toHaveValue('system'); + }); +}); + +test.describe('the form and the JSON view are one model', () => { + test('shows an edit of the JSON in the form: a value, a new dimension and a changed operator', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson({ + dimensions: [ + { kind: 'choice', name: 'os', values: ['linux', 'windows'] }, + { kind: 'choice', name: 'arch', values: ['x64', 'arm64', 'riscv'] }, + { kind: 'first-valid', name: 'codec', candidates: ['zstd', 'gzip'] }, + ], + rules: [{ excludes: { kind: 'textCompare', op: 'notEquals', left: reference('os'), right: textLiteral('windows') } }], + }); + + await expect(explorer.field('dimensions.1.values.2:value')).toHaveValue('riscv'); + await expect(explorer.field('dimensions.2:name')).toHaveValue('codec'); + await expect(explorer.field('dimensions.2:kind')).toHaveValue('first-valid'); + await expect(explorer.field('rules.0.excludes:op')).toHaveValue('notEquals'); + }); + + test('keeps the last valid model in the form, read-only, with the error and the typed text kept, when the JSON is broken', async ({ page }) => { + const explorer = await Explorer.open(page); + const broken = '{ "dimensions": [ { "kind": "choice", "name": "oops" '; + await explorer.setText(broken); + + await expect(explorer.id('validation')).toContainText('not valid JSON', { timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.id('form-notice')).toBeVisible(); + await expect(explorer.id('model')).toHaveValue(broken); + await expect(explorer.field('dimensions.0:name')).toHaveValue('os'); + await expect(explorer.id('form-fieldset')).toHaveJSProperty('disabled', true); + await expect(explorer.field('dimensions.0:name')).toBeDisabled(); + await expect(explorer.id('count')).toBeDisabled(); + }); + + test('explains JSON that is valid but is not a model, and keeps the last valid model in the form', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setText('[1, 2, 3]'); + + await expect(explorer.id('form-problem')).toContainText('cannot show it'); + await expect(explorer.field('dimensions.0:name')).toHaveValue('os'); + await expect(explorer.id('model')).toHaveValue('[1, 2, 3]'); + }); + + test('brings the form back when the JSON is fixed, and replaces broken text by the last valid model on request', async ({ page }) => { + const explorer = await Explorer.open(page); + const only: JsonObject = { dimensions: [{ kind: 'choice', name: 'only', values: ['a', 'b'] }] }; + await explorer.setText('{ broken'); + await expect(explorer.id('form-notice')).toBeVisible(); + await explorer.setJson(only); + + await expect(explorer.id('form-notice')).toBeHidden(); + await expect(explorer.field('dimensions.0:name')).toHaveValue('only'); + await expect(explorer.id('form-fieldset')).toHaveJSProperty('disabled', false); + await explorer.setText('{ broken again'); + await explorer.id('use-last-valid').click(); + await explorer.ready(); + + expect(await explorer.json()).toEqual(only); + await expect(explorer.id('form-notice')).toBeHidden(); + }); + + for (const example of EXAMPLES) { + test(`shows the example "${example.name}" in the form, every dimension and rule drawn with controls`, async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(example.name); + const model = await explorer.json(); + const dimensions = getIn(model, ['dimensions']); + const rules = getIn(model, ['rules']) ?? []; + if (dimensions === undefined || !isJsonArray(dimensions) || !isJsonArray(rules)) throw new Error('the example is a model with dimensions'); + + expect(model).toEqual(JSON.parse(exampleText(example))); + await expect(explorer.cards('dimensions')).toHaveCount(dimensions.length); + await expect(explorer.cards('rules')).toHaveCount(rules.length); + await expect(explorer.page.locator('textarea.raw-json')).toHaveCount(0); + await inOrder( + dimensions.map((dimension, index) => ({ dimension, index })), + async ({ dimension, index }) => { + const expected = isJsonObject(dimension) ? dimension['name'] : undefined; + if (typeof expected !== 'string') throw new Error(`dimension ${String(index)} of the example has no name`); + await expect(explorer.field(`dimensions.${String(index)}:name`)).toHaveValue(expected); + }, + ); + }); + } +}); + +test.describe('data the schema does not accept', () => { + test('is shown as JSON and kept exactly through every edit around it', async ({ page }) => { + const mystery: JsonObject = { kind: 'mystery', collection: 'os', item: { kind: 'exists', operand: reference('os') } }; + const computed: JsonObject = { kind: 'computed', name: 'target', reads: ['os'] }; + const withUnknownKey: JsonObject = { requires: { kind: 'and', left: mystery, right: { kind: 'exists', operand: reference('os') } }, note: 'a key the form does not know' }; + const extra: JsonObject = { anything: [1, 2, { deep: true }] }; + const strange: JsonObject = { + dimensions: [computed, { kind: 'choice', name: 'os', values: ['linux', 'windows'] }], + rules: [withUnknownKey, { excludes: { kind: 'and', left: mystery, right: textEquals('os', 'windows') } }], + extra, + }; + const explorer = await openWith(page, strange, { valid: false }); + + await expect(explorer.field('dimensions.0:raw')).toBeVisible(); + await expect(explorer.field('rules.0:raw')).toBeVisible(); + await expect(explorer.field('rules.1.excludes.left:raw')).toBeVisible(); + expect(JSON.parse(await explorer.field('dimensions.0:raw').inputValue())).toEqual(computed); + const name = explorer.field('dimensions.1:name'); + await name.fill('system'); + await name.blur(); + await explorer.field('dimensions.1.values:add').click(); + await explorer.field('dimensions.1:up').click(); + await explorer.field('rules.1:form').selectOption({ label: 'requires: every combination must satisfy' }); + await explorer.field('dimensions:add').click(); + await explorer.field('rules:add').click(); + const model = await explorer.json(); + + expect(await explorer.at(['dimensions', 1])).toEqual(computed); + expect(await explorer.at(['rules', 0])).toEqual(withUnknownKey); + expect(await explorer.at(['rules', 1, 'requires', 'left'])).toEqual(mystery); + expect(model['extra']).toEqual(extra); + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['linux', 'windows', 'value']); + await expect(explorer.id('form')).toContainText('which the form does not show and keeps'); + }); + + test('can be edited in its own box, and an invalid edit leaves the model on the last valid JSON', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'computed', name: 'target', reads: [] }], rules: [] }, { valid: false }); + const raw = explorer.field('dimensions.0:raw'); + await raw.fill(JSON.stringify({ kind: 'computed', name: 'renamed', reads: [] })); + + expect(await explorer.at(['dimensions', 0, 'name'])).toBe('renamed'); + await raw.fill('{ not json'); + + await expect(explorer.page.locator('.field-problem', { hasText: 'Not JSON' }).first()).toBeVisible(); + expect(await explorer.at(['dimensions', 0, 'name'])).toBe('renamed'); + }); +}); + +test.describe('every kind of the schema has controls', () => { + test('offers every predicate kind and every expression kind of the schema in the kind menus, and each can be chosen without raw JSON', async ({ page }) => { + const explorer = await openWith(page, MIXED_MODEL); + const predicateMenu = explorer.field('rules.0.excludes:kind'); + const offered = await predicateMenu.evaluate((menu) => (menu instanceof HTMLSelectElement ? [...menu.options].map((option) => option.value) : [])); + + expect(offered.toSorted((left, right) => left.localeCompare(right))).toEqual([...PREDICATE_KINDS].toSorted((left, right) => left.localeCompare(right))); + await inOrder(offered, async (kind) => { + await predicateMenu.selectOption({ value: kind }); + await expect(predicateMenu).toHaveValue(kind); + await expect(explorer.page.locator('textarea.raw-json')).toHaveCount(0); + }); + await predicateMenu.selectOption({ value: 'compare' }); + const expressionMenu = explorer.field('rules.0.excludes.right:kind'); + const expressions = await expressionMenu.evaluate((menu) => (menu instanceof HTMLSelectElement ? [...menu.options].map((option) => option.value) : [])); + + expect(expressions.toSorted((left, right) => left.localeCompare(right))).toEqual([...EXPRESSION_KINDS].toSorted((left, right) => left.localeCompare(right))); + await inOrder(expressions, async (kind) => { + await expressionMenu.selectOption({ value: kind }); + await expect(expressionMenu).toHaveValue(kind); + await expect(explorer.page.locator('textarea.raw-json')).toHaveCount(0); + }); + }); + + test('builds a comparison through the controls, and the engine counts it', async ({ page }) => { + const explorer = await openWith(page, MIXED_MODEL); + await explorer.field('rules.0.excludes:kind').selectOption({ value: 'compare' }); + await explorer.field('rules.0.excludes.right:kind').selectOption({ value: 'numberLiteral' }); + await explorer.field('rules.0.excludes.right.value:value').fill('2'); + await explorer.field('rules.0.excludes:op').selectOption({ label: 'is greater than' }); + await explorer.field('rules.0.excludes.left:key').selectOption({ label: 'n' }); + await explorer.ready(); + + expect(await explorer.at(['rules', 0, 'excludes'])).toEqual({ kind: 'compare', op: 'gt', left: reference('n'), right: { kind: 'numberLiteral', value: 2 } }); + await explorer.run('count'); + await expect(explorer.result('count')).toContainText(`${String(MIXED_MODEL_SIZE_WITH_RULE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + }); + + test('builds the kinds the library refuses (some, every, tree references) with controls, the check says why, and the data stays editable', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'n', values: [1, 2] }], rules: [{ requires: { kind: 'exists', operand: reference('n') } }] }); + const rule = 'rules.0.requires'; + await explorer.field(`${rule}:kind`).selectOption({ value: 'some' }); + await explorer.field(`${rule}.collection:type`).selectOption({ value: 'list' }); + await explorer.field(`${rule}.collection:add`).click(); + await explorer.field(`${rule}.collection.0:value`).fill('first'); + await explorer.field(`${rule}.collection:add`).click(); + await explorer.field(`${rule}.collection.1:type`).selectOption({ value: 'object' }); + await explorer.field(`${rule}.collection.1:add`).click(); + const key = explorer.field(`${rule}.collection.1.key:key`); + await key.fill('price'); + await key.blur(); + await explorer.field(`${rule}.collection.1.price:type`).selectOption({ value: 'number' }); + await explorer.field(`${rule}.collection.1.price:value`).fill('4.5'); + await explorer.field(`${rule}.filter:add`).click(); + await explorer.field(`${rule}.filter:kind`).selectOption({ value: 'not' }); + + expect(await explorer.at(['rules', 0, 'requires', 'collection'])).toEqual(['first', { price: 4.5 }]); + expect(await explorer.at(['rules', 0, 'requires', 'filter', 'kind'])).toBe('not'); + await expect(explorer.id('validation')).toContainText('"some" is not supported', { timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.id('form')).toContainText('The library refuses this kind in a model'); + await explorer.field(`${rule}.filter:remove-condition`).click(); + await explorer.field(`${rule}:kind`).selectOption({ value: 'treeReference' }); + await explorer.field(`${rule}.key:type`).selectOption({ value: 'object' }); + + expect(await explorer.at(['rules', 0, 'requires'])).toEqual({ kind: 'treeReference', key: {} }); + }); + + test('builds a fold with each combiner, a call with arguments, a lookup, an accumulator and a delegate', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'n', values: [1] }], rules: [{ requires: { kind: 'compare', op: 'gt', left: reference('n'), right: { kind: 'numberLiteral', value: 1 } } }] }); + const right = 'rules.0.requires.right'; + await explorer.field(`${right}:kind`).selectOption({ value: 'fold' }); + await explorer.field(`${right}.collection:type`).selectOption({ value: 'text' }); + await explorer.field(`${right}.collection:value`).fill('xs'); + await inOrder(['max', 'min', 'reduce'], async (mode) => { + await explorer.field(`${right}.combiner:mode`).selectOption({ value: mode }); + const combiner = await explorer.at(['rules', 0, 'requires', 'right', 'combiner']); + + expect(combiner).toMatchObject({ mode }); + expect(typeof combiner === 'object' && combiner !== null && !Array.isArray(combiner) && 'initial' in combiner).toBe(mode === 'reduce'); + }); + await explorer.field(`${right}.combiner.initial:kind`).selectOption({ value: 'accumulator' }); + await explorer.field(`${right}.combiner.combine:kind`).selectOption({ value: 'delegate' }); + await explorer.field(`${right}.combiner.combine.system:value`).fill('rules-engine'); + await explorer.field(`${right}.combiner.combine.payload:type`).selectOption({ value: 'list' }); + + expect(await explorer.at(['rules', 0, 'requires', 'right', 'combiner', 'combine'])).toEqual({ kind: 'delegate', system: 'rules-engine', payload: [] }); + await explorer.field(`${right}:kind`).selectOption({ value: 'call' }); + await explorer.field(`${right}.fn:value`).fill('max'); + await explorer.field(`${right}.args:add`).click(); + await explorer.field(`${right}:kind`).selectOption({ value: 'lookup' }); + await explorer.field(`${right}.table:value`).fill('prices'); + + expect(await explorer.at(['rules', 0, 'requires', 'right', 'table'])).toBe('prices'); + await explorer.field(`${right}:kind`).selectOption({ value: 'treeReference' }); + + expect(await explorer.at(['rules', 0, 'requires', 'right'])).toEqual({ kind: 'treeReference', key: '' }); + }); + + test('gives a number, a complex number and a reference a unit of symbols with exponents, and a reference with a key that is not text a value editor', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'n', values: [1] }], rules: [{ requires: { kind: 'compare', op: 'gt', left: reference('n'), right: { kind: 'numberLiteral', value: 1 } } }] }); + const left = 'rules.0.requires.left'; + const right = 'rules.0.requires.right'; + await explorer.field(`${left}.unit:add`).click(); + await explorer.field(`${left}.unit:add`).click(); + const symbol = explorer.field(`${left}.unit.symbol:key`); + await symbol.fill('m'); + await symbol.blur(); + await explorer.field(`${left}.unit.m:exponent`).fill('2'); + + expect(await explorer.at(['rules', 0, 'requires', 'left'])).toEqual({ kind: 'reference', key: 'n', unit: { m: 2 } }); + await explorer.field(`${right}:kind`).selectOption({ value: 'complexLiteral' }); + await explorer.field(`${right}.unit:add`).click(); + await explorer.field(`${right}.unit:add`).click(); + await explorer.field(`${right}:form`).selectOption({ label: 'magnitude and phase' }); + + expect(await explorer.at(['rules', 0, 'requires', 'right'])).toEqual({ kind: 'complexLiteral', magnitude: 0, phase: 0, unit: { symbol: 1 } }); + await explorer.field(`${left}.unit:remove-unit`).click(); + expect(await explorer.at(['rules', 0, 'requires', 'left'])).toEqual(reference('n')); + await explorer.field(`${left}:not-text`).click(); + await explorer.field(`${left}.key:type`).selectOption({ value: 'list' }); + await explorer.field(`${left}.key:add`).click(); + + expect(await explorer.at(['rules', 0, 'requires', 'left', 'key'])).toEqual(['']); + }); + + test('builds a conditional, arithmetic, a duration and a complex literal through their own fields', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'n', values: levels() }], rules: [{ excludes: { kind: 'compare', op: 'gt', left: reference('n'), right: { kind: 'numberLiteral', value: 2 } } }] }); + const right = 'rules.0.excludes.right'; + await explorer.field(`${right}:kind`).selectOption({ value: 'conditional' }); + await explorer.field(`${right}:hitPolicy`).selectOption({ label: 'the one case that holds' }); + await explorer.field(`${right}.cases:add`).click(); + await explorer.field(`${right}.cases.1:up`).click(); + await explorer.field(`${right}.cases.0:remove`).click(); + const conditional = await explorer.at(['rules', 0, 'excludes', 'right']); + + expect(conditional).toMatchObject({ kind: 'conditional', hitPolicy: 'unique' }); + expect(await explorer.at(['rules', 0, 'excludes', 'right', 'cases'])).toHaveLength(1); + await explorer.field(`${right}:kind`).selectOption({ value: 'arithmetic' }); + await explorer.field(`${right}:op`).selectOption({ label: 'times' }); + await explorer.field(`${right}:kind`).selectOption({ value: 'durationLiteral' }); + await explorer.field(`${right}:unit`).selectOption({ label: 'min' }); + + expect(await explorer.at(['rules', 0, 'excludes', 'right'])).toEqual({ kind: 'durationLiteral', value: 1, unit: 'min' }); + }); +}); + +test.describe('rules and dimensions', () => { + test('edits a rule: its form, label, unless condition, and the two conditions of implies', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'a', values: ['x', 'y'] }, { kind: 'choice', name: 'b', values: ['p', 'q'] }], rules: [{ excludes: textEquals('a', 'x') }] }); + await explorer.field('rules.0:form').selectOption({ label: 'implies: if the first holds, the second must' }); + await explorer.field('rules.0.implies.0:kind').selectOption({ label: 'compares text' }); + await explorer.field('rules.0.implies.0.right.value:value').fill('x'); + await explorer.field('rules.0.implies.1:kind').selectOption({ label: 'compares text' }); + await explorer.field('rules.0.implies.1.left:key').selectOption({ label: 'b' }); + await explorer.field('rules.0.implies.1.right.value:value').fill('p'); + await explorer.field('rules.0:name').fill('x needs p'); + await explorer.field('rules.0.unless:add').click(); + await explorer.field('rules.0.unless:kind').selectOption({ label: 'compares text' }); + await explorer.field('rules.0.unless.right.value:value').fill('y'); + await explorer.ready(); + + expect(await explorer.at(['rules', 0])).toEqual({ implies: [textEquals('a', 'x'), textEquals('b', 'p')], name: 'x needs p', unless: textEquals('a', 'y') }); + await explorer.run('count'); + // a = x requires b = p (1 of 2 values of b), and a = y is exempt by `unless` (2 values of b): 1 and 2. + await expect(explorer.result('count')).toContainText('3 combinations satisfy the model.', { timeout: RESULT_TIMEOUT_MS }); + await explorer.field('rules.0.unless:remove-condition').click(); + + expect(await explorer.at(['rules', 0, 'unless'])).toBeUndefined(); + }); + + test('edits a dimension: its kind, the type of each value, numbers that are not yet numbers, and an activity condition', async ({ page }) => { + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: 'mode', values: ['fast', 'slow'] }, { kind: 'choice', name: 'level', values: [1, 2] }], rules: [] }); + await explorer.field('dimensions.0:kind').selectOption({ label: 'first valid candidate' }); + + expect(await explorer.at(['dimensions', 0])).toEqual({ kind: 'first-valid', name: 'mode', candidates: ['fast', 'slow'] }); + await explorer.field('dimensions.0:kind').selectOption({ label: 'free choice' }); + await explorer.field('dimensions.0.values.1:type').selectOption({ label: 'true or false' }); + await explorer.field('dimensions.0.values.1:value').selectOption({ label: 'false' }); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['fast', false]); + await explorer.field('dimensions.0.values.1:type').selectOption({ label: 'null' }); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['fast', null]); + const level = explorer.field('dimensions.1.values.0:value'); + await level.fill('abc'); + + await expect(explorer.page.locator('.field-problem', { hasText: 'Not a finite number' }).first()).toBeVisible(); + expect(await explorer.at(['dimensions', 1, 'values', 0])).toBe(1); + await level.blur(); + await expect(level).toHaveValue('1'); + await level.fill(SCIENTIFIC_LEVEL); + + expect(await explorer.at(['dimensions', 1, 'values', 0])).toBe(SCIENTIFIC_LEVEL_VALUE); + await explorer.field('dimensions.1.activeWhen:add').click(); + await explorer.field('dimensions.1.activeWhen:kind').selectOption({ label: 'compares text' }); + await explorer.field('dimensions.1.activeWhen.right.value:value').fill('fast'); + await explorer.field('dimensions.0.values.1:type').selectOption({ label: 'text' }); + await explorer.field('dimensions.0.values.1:value').fill('slow'); + await explorer.ready(); + + expect(await explorer.at(['dimensions', 1, 'activeWhen'])).toEqual(textEquals('mode', 'fast')); + await explorer.run('count'); + await expect(explorer.result('count')).toContainText(`${String(ACTIVITY_MODEL_SIZE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + await explorer.field('dimensions.1.activeWhen:remove-condition').click(); + + expect(await explorer.at(['dimensions', 1, 'activeWhen'])).toBeUndefined(); + }); +}); + +test.describe('the form by keyboard alone', () => { + test('reorders, removes, adds and types, with focus kept on the control a person is using', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample('Operating systems and architectures'); + await explorer.advanced(); + await explorer.id('example').focus(); + // Moving "macos" down lands it on the last position, where Down is disabled, so focus goes to its Up button. + await explorer.tabTo('dimensions.0.values.1:down'); + await page.keyboard.press('Enter'); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['linux', 'windows', 'macos']); + expect(await explorer.focused()).toBe('dimensions.0.values.2:up'); + await page.keyboard.press('Enter'); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['linux', 'macos', 'windows']); + expect(await explorer.focused()).toBe('dimensions.0.values.1:up'); + await explorer.id('example').focus(); + await explorer.tabTo('dimensions.0.values.0:remove'); + await page.keyboard.press('Enter'); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['macos', 'windows']); + expect(await explorer.focused()).toBe('dimensions.0.values:add'); + await page.keyboard.press('Space'); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['macos', 'windows', 'value']); + expect(await explorer.focused()).toBe('dimensions.0.values:add'); + await explorer.tabTo('dimensions.0.values.2:type'); + await page.keyboard.press('Tab'); + expect(await explorer.focused()).toBe('dimensions.0.values.2:value'); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.type(TYPED_VALUE); + + expect(await explorer.at(['dimensions', 0, 'values'])).toEqual(['macos', 'windows', TYPED_VALUE]); + await explorer.id('example').focus(); + await explorer.tabTo('dimensions.1:up'); + await page.keyboard.press('Enter'); + + expect((await explorer.json())['dimensions']).toMatchObject([{ name: 'arch' }, { name: 'os' }]); + expect(await explorer.focused()).toBe('dimensions.0:down'); + await explorer.id('example').focus(); + await explorer.tabTo('rules:add'); + await page.keyboard.press('Enter'); + await explorer.tabTo('rules.0.excludes:kind'); + // Typing the first letter of an option's name chooses it in a closed menu ("both" is the only kind that starts with b). + await page.keyboard.type('b'); + + expect(await explorer.at(['rules', 0, 'excludes', 'kind'])).toBe('and'); + await expect(explorer.id('form-status')).not.toHaveText(''); + }); +}); + +test.describe('labels and layout', () => { + test('gives every control of the form an accessible name, and every name and field id is unique', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample('A conditional, derived dimension'); + + expect(await explorer.unnamedControls()).toEqual([]); + const names = await page.evaluate(() => [...document.querySelectorAll('#form [data-field]')].map((element) => element.getAttribute('aria-label')).filter((label) => label !== null)); + const fields = await page.evaluate(() => [...document.querySelectorAll('#form [data-field]')].map((element) => element.dataset['field'] ?? '')); + + expect(new Set(names).size).toBe(names.length); + expect(new Set(fields).size).toBe(fields.length); + }); + + test('does not scroll sideways at phone width with a deeply nested rule and long names, and follows the colour scheme', async ({ page }) => { + const nested = Array.from({ length: NESTING }).reduce( + (tree, _unused, depth) => (depth % 2 === 0 ? { kind: 'not', operand: tree } : { kind: 'and', left: tree, right: textEquals(LONG_NAME, 'y') }), + textEquals(LONG_NAME, 'x'), + ); + const explorer = await openWith(page, { dimensions: [{ kind: 'choice', name: LONG_NAME, values: ['x', 'y'] }], rules: [{ name: 'a rule with a label that is rather long indeed for a phone', excludes: nested }] }); + await page.setViewportSize({ width: PHONE_WIDTH, height: PHONE_HEIGHT }); + await page.emulateMedia({ colorScheme: 'dark' }); + + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0); + await expect(page.locator('body')).toHaveCSS('background-color', DARK_BACKGROUND); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('body')).toHaveCSS('background-color', LIGHT_BACKGROUND); + await expect(explorer.cards('rules')).toHaveCount(1); + }); + + test('makes no request outside its own origin while the form edits a model', async ({ page, baseURL }) => { + const outside = trackExternalRequests(page, baseURL); + const explorer = await Explorer.open(page); + await explorer.field('dimensions:add').click(); + await explorer.field('rules:add').click(); + await explorer.run('count'); + + await expect(explorer.result('count')).toContainText(/combinations? satisf/, { timeout: RESULT_TIMEOUT_MS }); + expect(outside).toEqual([]); + }); +}); diff --git a/e2e/help.e2e.ts b/e2e/help.e2e.ts new file mode 100644 index 0000000..162f4a7 --- /dev/null +++ b/e2e/help.e2e.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test'; + +import { keysOf } from '../site/src/exhaustive'; +import { HELP } from '../site/src/help'; +import { Explorer, RESULT_TIMEOUT_MS } from './explorer'; + +/** The description each control of an operation is wired to, by the control's id. */ +const DESCRIBED_BY: Readonly> = { + count: 'count-help', + 'list-limit': 'list-limit-help', + list: 'list-help', + diagnose: 'diagnose-help', + strength: 'strength-help', + covering: 'covering-help', + index: 'one-help', + at: 'one-help', + pickers: 'explain-help', + explain: 'explain-help', +}; + +/** The id of the paragraph that carries each description in `site/src/help.ts`. */ +const PARAGRAPH_OF: Readonly> = { + count: 'count-help', + list: 'list-help', + 'list-limit': 'list-limit-help', + diagnose: 'diagnose-help', + covering: 'covering-help', + strength: 'strength-help', + one: 'one-help', + explain: 'explain-help', +}; + +/** The least contrast ratio between text and the panel behind it that WCAG asks for ordinary text. */ +const MINIMUM_CONTRAST = 4.5; + +/** The constants of WCAG's relative luminance and contrast ratio: the channel weights, the sRGB transfer function's break and its parts, and the flare added to both luminances. */ +const WCAG = { channels: 3, red: 0.2126, green: 0.7152, blue: 0.0722, byteMax: 255, linearBreak: 0.03928, linearScale: 12.92, offset: 0.055, divisor: 1.055, exponent: 2.4, flare: 0.05 } as const; + +const PHONE_WIDTH = 375; +const PHONE_HEIGHT = 800; + +test.describe('operation descriptions', () => { + test('shows each description as visible text, word for word what help.ts says', async ({ page }) => { + const explorer = await Explorer.open(page); + + await Promise.all(keysOf(PARAGRAPH_OF).map(async (key) => expect(explorer.id(PARAGRAPH_OF[key])).toHaveText(HELP[key]))); + }); + + test('explains the covering array and its strength in the words asked for', async ({ page }) => { + const explorer = await Explorer.open(page); + + await expect(explorer.id('covering-help')).toContainText('short list of valid combinations that tests every interaction you care about without listing them all'); + await expect(explorer.id('strength-help')).toContainText('How many options at a time must be covered together'); + await expect(explorer.id('strength-help')).toContainText('2, pairwise and the default, means every pair of values from any two dimensions'); + await expect(explorer.id('strength-help')).toContainText('3 means every triple'); + await expect(explorer.id('strength-help')).toContainText('rules make impossible are not required'); + }); + + test('names each control\'s description, and the browser reports it as the control\'s accessible description', async ({ page }) => { + const explorer = await Explorer.open(page); + + await Promise.all( + Object.entries(DESCRIBED_BY).map(async ([control, paragraph]) => { + await expect(explorer.id(control)).toHaveAttribute('aria-describedby', paragraph); + await expect(explorer.id(control)).toHaveAccessibleDescription((await explorer.id(paragraph).innerText()).trim()); + }), + ); + }); + + test('shows the description of the Strength field when it has keyboard focus', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.id('strength').focus(); + + await expect(explorer.id('strength')).toBeFocused(); + await expect(explorer.id('strength-help')).toBeVisible(); + }); + + test('still builds a covering array', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.run('covering'); + + await expect(explorer.result('covering')).toContainText('it covers', { timeout: RESULT_TIMEOUT_MS }); + }); + + for (const scheme of ['light', 'dark'] as const) { + test(`keeps the description inside its panel at phone width, with enough contrast in the ${scheme} scheme`, async ({ page }) => { + await page.setViewportSize({ width: PHONE_WIDTH, height: PHONE_HEIGHT }); + await page.emulateMedia({ colorScheme: scheme }); + await Explorer.open(page); + + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0); + const inside = await page.evaluate(() => + [...document.querySelectorAll('.operation .hint')].every((hint) => { + const panel = hint.closest('.operation')?.getBoundingClientRect(); + const box = hint.getBoundingClientRect(); + + return panel !== undefined && box.left >= panel.left - 1 && box.right <= panel.right + 1; + }), + ); + + expect(inside).toBe(true); + const contrast = await page.evaluate((wcag) => { + const channels = (colour: string): number[] => (colour.match(/[\d.]+/g) ?? []).slice(0, wcag.channels).map(Number); + const luminance = ([red = 0, green = 0, blue = 0]: number[]): number => { + const linear = (value: number): number => { + const unit = value / wcag.byteMax; + + return unit <= wcag.linearBreak ? unit / wcag.linearScale : ((unit + wcag.offset) / wcag.divisor) ** wcag.exponent; + }; + + return wcag.red * linear(red) + wcag.green * linear(green) + wcag.blue * linear(blue); + }; + const hint = document.querySelector('#strength-help'); + const panel = hint?.closest('.operation'); + if (hint === null || panel === null || panel === undefined) throw new Error('the strength description is not in an operation'); + const text = luminance(channels(getComputedStyle(hint).color)); + const background = luminance(channels(getComputedStyle(panel).backgroundColor)); + + return (Math.max(text, background) + wcag.flare) / (Math.min(text, background) + wcag.flare); + }, WCAG); + + expect(contrast).toBeGreaterThanOrEqual(MINIMUM_CONTRAST); + }); + } +}); diff --git a/e2e/operations.e2e.ts b/e2e/operations.e2e.ts new file mode 100644 index 0000000..c951208 --- /dev/null +++ b/e2e/operations.e2e.ts @@ -0,0 +1,191 @@ +import { expect, test } from '@playwright/test'; + +import type { JsonObject } from '../site/src/json'; + +import { Explorer, reference, RESULT_TIMEOUT_MS, textEquals, trackExternalRequests } from './explorer'; + +/** Sizes the README states for the explorer's examples: the product of an operating system and an architecture, and the same with a runtime and the rule that bun is not offered on Windows. */ +const PRODUCT_EXAMPLE = 'Operating systems and architectures'; +const PRODUCT_SIZE = 6; +const RULE_EXAMPLE_SIZE = 16; +const PAIRWISE_ROWS = 8; + +/** A list limit that is below the size of the product example, and the rows the page then shows besides the heading row. */ +const SMALL_LIMIT = 4; + +/** The most rows the list may be asked for, which the page states in its own text. */ +const OVER_LIMIT = 10_001; + +/** A model whose one rule reads every dimension, so that counting it visits every node and the solution set is beyond anything that could finish: used to cancel a count and to list only the start of a huge set. */ +const HEAVY_DIMENSIONS = 40; + +/** How long the test lets a count run before it cancels, in milliseconds: long enough that the count is certainly still running, which a model this size guarantees, not long enough to slow the run. */ +const LET_COUNT_RUN_MS = 500; + +function heavyModel(): JsonObject { + const names = Array.from({ length: HEAVY_DIMENSIONS }, (_, index) => `d${String(index)}`); + const [first, ...rest] = names; + if (first === undefined) throw new Error('the heavy model has dimensions'); + const allA = rest.reduce((tree, key) => ({ kind: 'and', left: tree, right: textEquals(key, 'a') }), textEquals(first, 'a')); + + return { dimensions: names.map((name) => ({ kind: 'choice', name, values: ['a', 'b'] })), rules: [{ excludes: allA }] }; +} + +test.describe('operations', () => { + test('starts on an example, checks it, and runs the engine in a worker', async ({ page }) => { + const explorer = await Explorer.open(page); + + await expect(explorer.id('validation')).toContainText('Valid model with 3 dimensions'); + expect(page.workers().some((worker) => worker.url().includes('worker'))).toBe(true); + expect(await explorer.at(['rules', 0, 'name'])).toBe('bun is not offered on windows'); + }); + + test('counts the combinations of an example', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(PRODUCT_EXAMPLE); + await explorer.run('count'); + + await expect(explorer.result('count')).toContainText(`${String(PRODUCT_SIZE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + }); + + test('lists as many combinations as the limit, says more exist, and lists them all at the size of the set', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(PRODUCT_EXAMPLE); + await explorer.setNumber('list-limit', SMALL_LIMIT); + await explorer.run('list'); + + await expect(explorer.result('list').locator('tr')).toHaveCount(SMALL_LIMIT + 1); + await expect(explorer.result('list')).toContainText(`The first ${String(SMALL_LIMIT)} combinations. There are more`); + await explorer.setNumber('list-limit', PRODUCT_SIZE); + await explorer.run('list'); + + await expect(explorer.result('list')).toContainText(`All ${String(PRODUCT_SIZE)} combinations.`); + await expect(explorer.result('list').locator('tr')).toHaveCount(PRODUCT_SIZE + 1); + }); + + test('refuses a list limit past the maximum, without asking the engine', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setNumber('list-limit', OVER_LIMIT); + await explorer.run('list'); + + await expect(explorer.result('list')).toContainText('whole number from 0 to 10000'); + }); + + test('finds the value no combination uses', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson({ + dimensions: [ + { kind: 'choice', name: 'a', values: ['x', 'y'] }, + { kind: 'choice', name: 'b', values: ['p'] }, + ], + rules: [{ excludes: textEquals('a', 'y') }], + }); + await explorer.run('diagnose'); + + await expect(explorer.result('diagnose')).toContainText('a = "y"', { timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.result('diagnose')).toContainText('1 combination satisfy'); + }); + + test('builds a pairwise covering array and reports it checked against the whole set', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.run('covering'); + + await expect(explorer.result('covering')).toContainText(`Checked with isCoveringArray against all ${String(RULE_EXAMPLE_SIZE)} combinations: it covers`, { timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.result('covering')).toContainText(`${String(PAIRWISE_ROWS)} rows cover every combination of 2 dimension values`); + }); + + test('names the rule that excludes a combination, gives the index of one that is valid, and shows the combination at an index', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.id('pick-os').selectOption({ label: '"windows"' }); + await explorer.id('pick-arch').selectOption({ label: '"x64"' }); + await explorer.id('pick-runtime').selectOption({ label: '"bun"' }); + await explorer.run('explain'); + + await expect(explorer.result('combination')).toContainText('rule 1, "bun is not offered on windows", rejects it', { timeout: RESULT_TIMEOUT_MS }); + await explorer.id('pick-runtime').selectOption({ label: '"node"' }); + await explorer.run('explain'); + + await expect(explorer.result('combination')).toContainText(/This is a combination of the model, at index \d+/); + const index = await explorer.id('index').inputValue(); + await explorer.run('at'); + + await expect(explorer.result('combination')).toContainText(`The combination at index ${index}`); + }); + + test('shows the library text for an invalid model and for text that is not JSON, and turns the operations off', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson( + { dimensions: [{ kind: 'choice', name: 'os', values: ['linux'] }], rules: [{ requires: { kind: 'exists', operand: reference('arch') } }] }, + { expectValid: false }, + ); + + await expect(explorer.id('validation')).toContainText('unknown dimension "arch"', { timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.id('count')).toBeDisabled(); + await explorer.setText('{ not json'); + + await expect(explorer.id('validation')).toContainText('not valid JSON', { timeout: RESULT_TIMEOUT_MS }); + }); + + test('cancels a count that would never finish, keeps the page responsive meanwhile, and runs the next operation on a fresh engine', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson(heavyModel()); + await explorer.run('count'); + + await expect(explorer.id('busy')).toHaveText('Counting…'); + await expect(explorer.id('cancel')).toBeEnabled(); + await expect(explorer.id('count')).toBeDisabled(); + await expect(explorer.id('form-fieldset')).toHaveJSProperty('disabled', true); + // The page itself answers while the engine is busy. + await explorer.id('save-name').fill('typed while counting'); + await expect(explorer.id('save-name')).toHaveValue('typed while counting'); + await page.waitForTimeout(LET_COUNT_RUN_MS); + await expect(explorer.id('busy')).toHaveText('Counting…'); + await explorer.id('cancel').click(); + + await expect(explorer.result('count')).toHaveText('Cancelled.'); + await expect(explorer.id('busy')).toHaveText(''); + await expect(explorer.id('cancel')).toBeDisabled(); + await expect(explorer.id('form-fieldset')).toHaveJSProperty('disabled', false); + await explorer.loadExample(PRODUCT_EXAMPLE); + await explorer.run('count'); + + await expect(explorer.result('count')).toContainText(`${String(PRODUCT_SIZE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + }); + + test('lists only the start of a set far too large to list', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson(heavyModel()); + await explorer.setNumber('list-limit', SMALL_LIMIT + 1); + await explorer.run('list'); + + await expect(explorer.result('list')).toContainText(`The first ${String(SMALL_LIMIT + 1)} combinations`, { timeout: RESULT_TIMEOUT_MS }); + }); + + test('makes no request outside its own origin while it runs an operation', async ({ page, baseURL }) => { + const outside = trackExternalRequests(page, baseURL); + const explorer = await Explorer.open(page); + await explorer.run('count'); + + await expect(explorer.result('count')).toContainText('combinations satisfy', { timeout: RESULT_TIMEOUT_MS }); + expect(outside).toEqual([]); + }); + + test('works with localStorage blocked: it loads, counts, and says it cannot save', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { + get() { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }, + }); + }); + const explorer = await Explorer.open(page); + await explorer.run('count'); + + await expect(explorer.result('count')).toContainText(`${String(RULE_EXAMPLE_SIZE)} combinations satisfy the model.`, { timeout: RESULT_TIMEOUT_MS }); + await explorer.id('save-name').fill('anything'); + await explorer.id('save').click(); + + await expect(explorer.id('saved-status')).toContainText('not letting this page save'); + await expect(explorer.page.locator('#saved-list li')).toHaveCount(0); + }); +}); diff --git a/e2e/sharing.e2e.ts b/e2e/sharing.e2e.ts new file mode 100644 index 0000000..ecfde95 --- /dev/null +++ b/e2e/sharing.e2e.ts @@ -0,0 +1,169 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +import type { Page } from '@playwright/test'; +import type { JsonObject } from '../site/src/json'; +import { Explorer, RESULT_TIMEOUT_MS, STARTING_EXAMPLE } from './explorer'; + +/** The index a link carries, so the page that opens it shows that combination. */ +const SHARED_INDEX = 5; + +/** A public gist's id, which is hexadecimal, and the model it holds. */ +const GIST_ID = '0123456789abcdef0123'; +const GIST_MODEL: JsonObject = { dimensions: [{ kind: 'choice', name: 'from-gist', values: ['one', 'two', 'three'] }] }; + +/** The HTTP status of an answer that is fine, and of one that says the gist is not there. */ +const HTTP_OK = 200; +const HTTP_NOT_FOUND = 404; + +/** Answers GitHub's gist API from the test, so no request leaves the machine. */ +async function serveGist(page: Page, status: number, body: unknown): Promise { + const requested: string[] = []; + await page.route('https://api.github.com/gists/**', async (route) => { + requested.push(route.request().url()); + await route.fulfill({ status, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify(body) }); + }); + + return requested; +} + +/** The link the page's Copy link button makes, with a combination selected. */ +async function copiedLink(explorer: Explorer): Promise { + await explorer.id('index').fill(String(SHARED_INDEX)); + await explorer.id('copy-link').click(); + await expect(explorer.id('link-status')).toContainText('Link copied', { timeout: RESULT_TIMEOUT_MS }); + + return explorer.id('link').inputValue(); +} + +test.describe('share links', () => { + test('carries the model and the selected combination to a fresh page, and puts the link on the clipboard', async ({ page, context, baseURL }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(STARTING_EXAMPLE); + await explorer.field('dimensions.2.values:add').click(); + await explorer.field('dimensions.2.values.3:value').fill('wasmtime'); + await explorer.ready(); + const model = await explorer.json(); + const link = await copiedLink(explorer); + + expect(link.startsWith(`${String(baseURL)}#s=`)).toBe(true); + expect(await page.evaluate(async () => navigator.clipboard.readText())).toBe(link); + const fresh = await context.newPage(); + await fresh.goto(link); + const opened = new Explorer(fresh); + await opened.ready(); + + await expect(opened.result('combination')).toContainText(`The combination at index ${String(SHARED_INDEX)}`, { timeout: RESULT_TIMEOUT_MS }); + await expect(opened.field('dimensions.2.values.3:value')).toHaveValue('wasmtime'); + await expect(opened.id('index')).toHaveValue(String(SHARED_INDEX)); + expect(await opened.json()).toEqual(model); + }); + + test('never sends the fragment to a server', async ({ page, context, baseURL }) => { + const link = await copiedLink(await Explorer.open(page)); + const requested: string[] = []; + const fresh = await context.newPage(); + fresh.on('request', (request) => requested.push(request.url())); + await fresh.goto(link); + await new Explorer(fresh).ready(); + + expect(requested.length).toBeGreaterThan(0); + expect(requested.filter((url) => url.includes('#') || url.includes('s='))).toEqual([]); + expect(requested.filter((url) => !url.startsWith(new URL(String(baseURL)).origin))).toEqual([]); + }); + + test('restores the state when only the fragment of the open page changes', async ({ page, context }) => { + const link = await copiedLink(await Explorer.open(page)); + const second = await context.newPage(); + const explorer = await Explorer.open(second); + await second.evaluate((target) => { + location.hash = new URL(target).hash; + }, link); + + await expect(explorer.result('combination')).toContainText(`The combination at index ${String(SHARED_INDEX)}`, { timeout: RESULT_TIMEOUT_MS }); + }); + + test('shows a message, not a crash, for a link that holds no state', async ({ page }) => { + await page.goto('#s=AAAA'); + + await expect(page.locator('#notice')).toContainText('could not be loaded', { timeout: RESULT_TIMEOUT_MS }); + await new Explorer(page).ready(); + }); +}); + +test.describe('gists', () => { + test('loads the first .json file of a public gist by id, asking only GitHub for it', async ({ page }) => { + const requested = await serveGist(page, HTTP_OK, { files: { 'notes.md': { content: 'ignore me' }, 'model.json': { content: JSON.stringify(GIST_MODEL) } } }); + await page.goto(`?gist=${GIST_ID}`); + const explorer = new Explorer(page); + await explorer.ready(); + + expect(await explorer.json()).toEqual(GIST_MODEL); + expect(requested).toEqual([`https://api.github.com/gists/${GIST_ID}`]); + }); + + test('says so when GitHub does not have the gist', async ({ page }) => { + await serveGist(page, HTTP_NOT_FOUND, {}); + await page.goto('?gist=deadbeef'); + + await expect(page.locator('#notice')).toContainText('GitHub answered 404', { timeout: RESULT_TIMEOUT_MS }); + }); + + test('keeps the text of a gist that is not JSON in the Advanced view, opened, and the form on its last model', async ({ page }) => { + await serveGist(page, HTTP_OK, { files: { 'model.json': { content: '{ not json' } } }); + await page.goto('?gist=cafe0123'); + const explorer = new Explorer(page); + + await expect(explorer.id('form-notice')).toBeVisible({ timeout: RESULT_TIMEOUT_MS }); + await expect(explorer.id('model')).toHaveValue('{ not json'); + await expect(explorer.id('advanced')).toHaveAttribute('open', ''); + }); +}); + +test.describe('saved models, import and export', () => { + test('saves a model by name, opens it again after changing the model and after a reload, and deletes it', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(STARTING_EXAMPLE); + const saved = await explorer.json(); + await explorer.id('save-name').fill('my rule model'); + await explorer.id('save').click(); + await expect(explorer.id('saved-status')).toContainText('Saved my rule model.'); + await explorer.loadExample('Operating systems and architectures'); + + expect(await explorer.json()).not.toEqual(saved); + await page.getByRole('button', { name: 'Open my rule model' }).click(); + await explorer.ready(); + + expect(await explorer.json()).toEqual(saved); + await page.reload(); + await explorer.ready(); + await expect(page.getByRole('button', { name: 'Open my rule model' })).toBeVisible(); + await page.getByRole('button', { name: 'Delete my rule model' }).click(); + + await expect(page.getByRole('button', { name: 'Open my rule model' })).toHaveCount(0); + }); + + test('exports the editor text as a file, and imports a file into the form', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample('A conditional, derived dimension'); + const text = await (await explorer.advanced()).inputValue(); + const download = page.waitForEvent('download'); + await explorer.id('export').click(); + const file = await download; + const folder = await mkdtemp(join(tmpdir(), 'combinatrix-e2e-')); + const exported = join(folder, 'exported.json'); + await file.saveAs(exported); + + expect(file.suggestedFilename()).toBe('combinatrix-model.json'); + expect(await readFile(exported, 'utf8')).toBe(text); + const imported = join(folder, 'imported.json'); + await writeFile(imported, JSON.stringify({ dimensions: [{ kind: 'choice', name: 'imported', values: ['yes'] }] })); + await explorer.id('import').setInputFiles(imported); + + await expect(explorer.field('dimensions.0:name')).toHaveValue('imported', { timeout: RESULT_TIMEOUT_MS }); + await explorer.ready(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..2b6efb6 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "paths": { "combinatrix": ["../src/index.ts"] }, + "types": ["node"] + }, + "extends": "../tsconfig.json", + "include": ["."] +} diff --git a/eslint.config.ts b/eslint.config.ts index 37b5382..2e32335 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -3,10 +3,10 @@ import js from '@eslint/js'; import exadev from '@exadev/eslint-config'; export default defineConfig( - { ignores: ['dist', 'coverage', 'node_modules', 'reports'] }, + { ignores: ['dist', 'coverage', 'node_modules', 'reports', 'test-results', 'playwright-report'] }, { languageOptions: { - parserOptions: { project: ['./tsconfig.json', './site/tsconfig.json', './site/tsconfig.worker.json'], tsconfigRootDir: import.meta.dirname }, + parserOptions: { project: ['./tsconfig.json', './site/tsconfig.json', './site/tsconfig.worker.json', './e2e/tsconfig.json'], tsconfigRootDir: import.meta.dirname }, }, }, { ...js.configs.recommended, files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] }, diff --git a/package.json b/package.json index 5eb4e0c..c60a344 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "site:dev": "vite --config site/vite.config.ts", "site:preview": "vite preview --config site/vite.config.ts", "test": "vitest run", - "typecheck": "tsc -p tsconfig.json && tsc -p site/tsconfig.json && tsc -p site/tsconfig.worker.json" + "test:e2e": "playwright test", + "typecheck": "tsc -p tsconfig.json && tsc -p site/tsconfig.json && tsc -p site/tsconfig.worker.json && tsc -p e2e/tsconfig.json" }, "sideEffects": false, "type": "module", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2289f33 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** The port `vite preview` serves the built site on. A fixed one, with `--strictPort`, so a server another process already holds fails the run instead of being tested by mistake. */ +const PORT = 4173; + +/** The page's address: vite's `base` in site/vite.config.ts is the repository's project-page path. */ +export const BASE_URL = `http://127.0.0.1:${String(PORT)}/combinatrix/`; + +/** The most a build and its server may take to come up, in milliseconds. A bound on a hang, not a measure of speed: the work is one bundle. */ +/** Whether this run is on a CI machine, which GitHub Actions marks with `CI`. */ +const CI = process.env['CI'] !== undefined; + +const SERVER_START_TIMEOUT_MS = 120_000; + +/** The most one test may take, in milliseconds. The slowest cancels a count that never finishes, which waits on the page's own controls, so this bounds a hang. */ +const TEST_TIMEOUT_MS = 60_000; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.e2e.ts', + fullyParallel: true, + forbidOnly: CI, + // A test that passes only on a retry is a flake, and a flake is a defect to fix, so nothing is retried. + retries: 0, + timeout: TEST_TIMEOUT_MS, + reporter: CI ? [['list'], ['github']] : 'list', + outputDir: 'test-results', + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + // The share-link tests read and write the clipboard. + permissions: ['clipboard-read', 'clipboard-write'], + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + // The site as it is deployed: the production build, served by vite's own preview, not the dev server. + command: `pnpm run site:build && pnpm run site:preview --port ${String(PORT)} --strictPort`, + url: BASE_URL, + reuseExistingServer: !CI, + timeout: SERVER_START_TIMEOUT_MS, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8022a87..bd25e03 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { @@ -8,6 +8,8 @@ export default defineConfig({ alias: [{ find: /^combinatrix$/, replacement: join(import.meta.dirname, 'src/index.ts') }], }, test: { + // The browser tests under e2e/ are Playwright's, run by `pnpm test:e2e`, and a unit run neither needs a browser nor may start one. + exclude: [...configDefaults.exclude, 'e2e/**'], coverage: { enabled: true, provider: 'v8', From a39988ca6b286bf8333d9868d64bd9f7fde66010 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 19:35:14 +0000 Subject: [PATCH 3/6] ci(site): run the browser tests as a job that always reports The job decides for itself whether the site, the library source, the tests or the dependencies changed, by diffing against the pull request's base or the commit a push replaces, and runs the rest of its steps only when they did. A workflow-level path filter would leave a required check missing on a pull request that touches none of those paths; this job reports passing at once instead, so Required Checks can depend on it. A base that cannot be fetched runs the tests. The browser binary is cached on Playwright's version, platform and architecture, only the headless shell is installed, and the operating system libraries are installed when the runner allows it. Traces of a failed run are kept for a week. --- .github/workflows/ci.yml | 82 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0c989e..2c36875 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,9 +272,89 @@ jobs: exit 1 fi + # The explorer in site/ driven in a real browser: the form, the JSON view, the examples, share links, saved models, cancelling a long count, phone width and the colour schemes, which the unit tests cannot see. It runs only when what the site is built from changed, and decides that itself rather than through a path filter on the workflow or a separate job: a filtered workflow leaves a required check missing, where this job always reports, passing at once when nothing relevant changed, so Required Checks can depend on it. + browser: + name: Browser + needs: determine-runner + runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The files the site, its tests and the library it is built from live in. A dispatch always runs. The base is the pull request's base or the commit a push replaces; one that cannot be fetched (a force-pushed branch, a first push) runs the tests, since not knowing is no reason to skip them. The diff is of two trees, so one commit of history each is enough. + - name: Decide whether the browser tests are needed + id: changes + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + REPOSITORY: ${{ github.repository }} + run: | + if [ "$EVENT" = workflow_dispatch ] || [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = 0000000000000000000000000000000000000000 ]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + echo "The browser tests run: there is no base to compare with." + exit 0 + fi + if ! git fetch --no-tags --depth=1 "https://github.com/${REPOSITORY}.git" "$BASE_SHA"; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + echo "The browser tests run: the base commit could not be fetched." + exit 0 + fi + if git diff --name-only "$BASE_SHA" HEAD | grep -Eq '^(site/|src/|e2e/|playwright\.config\.ts$|package\.json$|pnpm-lock\.yaml$)'; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + echo "The browser tests run: the site, the library, the tests or the dependencies changed." + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + echo "The browser tests are skipped: nothing they depend on changed." + fi + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + if: steps.changes.outputs.relevant == 'true' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + if: steps.changes.outputs.relevant == 'true' + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + if: steps.changes.outputs.relevant == 'true' + - name: Resolve Playwright version + id: playwright + if: steps.changes.outputs.relevant == 'true' + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + # Browser binaries are versioned by Playwright, so its version, with the platform and architecture the binary is for, is the whole key. + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: browsers + if: steps.changes.outputs.relevant == 'true' + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright.outputs.version }} + # Only the headless shell, which is what a headless run uses: the full Chromium that a plain `install chromium` adds is another download the tests never open. + - name: Install Chromium's headless shell + if: steps.changes.outputs.relevant == 'true' && steps.browsers.outputs.cache-hit != 'true' + run: pnpm exec playwright install --only-shell chromium + # The cache holds the browser download only; the operating system libraries it needs are installed by apt and are not in it. They need root, which a runner pod need not have: without it the image's own libraries are used, and Chromium names any that is missing when it starts. + - name: Install the operating system libraries Chromium needs + if: steps.changes.outputs.relevant == 'true' + run: | + if [ "$(id -u)" -eq 0 ] || sudo -n true 2>/dev/null; then + pnpm exec playwright install-deps chromium + else + echo "Neither root nor passwordless sudo: the runner image's libraries are used." + fi + - name: Run the browser tests + if: steps.changes.outputs.relevant == 'true' + run: pnpm test:e2e + # Traces of the tests that failed, which are kept only for a failure. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() && steps.changes.outputs.relevant == 'true' + with: + name: browser-test-results + path: test-results + retention-days: 7 + if-no-files-found: ignore + required-checks: name: Required Checks - needs: [determine-runner, commitlint, lint, typecheck, package, actionlint, zizmor] + needs: [determine-runner, commitlint, lint, typecheck, package, actionlint, zizmor, browser] # Always runs, even if a dependency failed, so it reports that failure itself rather than being skipped: a required check that is silently absent blocks a PR indefinitely instead of failing it visibly. if: always() runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }} From f8c809286cb3f45ecb5f82bacca475f1db3bfb84 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 19:35:15 +0000 Subject: [PATCH 4/6] docs(site): describe the browser tests and when CI runs them --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7bc6e4..d9bc5e5 100644 --- a/README.md +++ b/README.md @@ -680,8 +680,9 @@ Node 22 or later, pnpm. ESM only. | `pnpm test` | Vitest, tests colocated as `*.unit.test.ts` | | `pnpm build` | tsdown into `dist`, then `schemas/combinatrix.schema.json` from `scripts/generate-json-schema.ts` | | `pnpm site:build` / `site:dev` / `site:preview` | Build, serve or preview the explorer in `site/`, which imports the library from `src/` | +| `pnpm test:e2e` | Playwright tests in `e2e/` that drive the built explorer in headless Chromium, starting and stopping its own preview server; run `pnpm exec playwright install --only-shell chromium` once first | -CI also runs `pnpm exec publint` and `pnpm exec attw --pack --profile esm-only` on the build, and installs the packed tarball into a scratch project on each supported Node line to exercise the public API and to compile a TypeScript consumer against it with no DOM library and no type packages, so the published types, and trilean's, depend on neither. trilean's tree types are inferred from zod schemas, and zod's own declarations name the `URL` global, so a consumer that checks library declarations (`skipLibCheck` off) needs the DOM library or a runtime's types for zod's sake; that diagnostic is the one the check allows. +CI runs the browser tests in `e2e/` as their own job whenever the site, the library source, the tests or the dependencies change, and the job passes at once when none did, so the required check is never missing. It also runs `pnpm exec publint` and `pnpm exec attw --pack --profile esm-only` on the build, and installs the packed tarball into a scratch project on each supported Node line to exercise the public API and to compile a TypeScript consumer against it with no DOM library and no type packages, so the published types, and trilean's, depend on neither. trilean's tree types are inferred from zod schemas, and zod's own declarations name the `URL` global, so a consumer that checks library declarations (`skipLibCheck` off) needs the DOM library or a runtime's types for zod's sake; that diagnostic is the one the check allows. ## Releases From 2d9d310fe7e69800f61ebd5d2b5ca4960f1e8aba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 20:04:15 +0000 Subject: [PATCH 5/6] test(site): serve the preview on an explicit address, and let the port be set On the self-hosted runner the preview server ran and the tests could not reach it: the server listened on `localhost`, which resolved to the IPv6 loopback alone, while the tests ask for the IPv4 one. The server now listens on 127.0.0.1, the address the tests use. The port can be set with E2E_PORT for a machine where another process already holds the default, and a port something holds still fails the run, since the server is started with --strictPort. --- playwright.config.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 2289f33..6b7bcf1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,15 +1,19 @@ import { defineConfig, devices } from '@playwright/test'; -/** The port `vite preview` serves the built site on. A fixed one, with `--strictPort`, so a server another process already holds fails the run instead of being tested by mistake. */ -const PORT = 4173; +/** The port `vite preview` serves the built site on: 4173 unless `E2E_PORT` names another, for a machine where something else already holds it. With `--strictPort`, a port something holds fails the run instead of testing whatever answers there. */ +const DEFAULT_PORT = 4173; +const PORT = Number(process.env['E2E_PORT'] ?? DEFAULT_PORT); + +/** The address the server listens on, named rather than left to `localhost`, which a machine may resolve to the IPv6 loopback alone while the tests ask for the IPv4 one: the server then runs and nothing reaches it. */ +const HOST = '127.0.0.1'; /** The page's address: vite's `base` in site/vite.config.ts is the repository's project-page path. */ -export const BASE_URL = `http://127.0.0.1:${String(PORT)}/combinatrix/`; +export const BASE_URL = `http://${HOST}:${String(PORT)}/combinatrix/`; -/** The most a build and its server may take to come up, in milliseconds. A bound on a hang, not a measure of speed: the work is one bundle. */ /** Whether this run is on a CI machine, which GitHub Actions marks with `CI`. */ const CI = process.env['CI'] !== undefined; +/** The most a build and its server may take to come up, in milliseconds. A bound on a hang, not a measure of speed: the work is one bundle. */ const SERVER_START_TIMEOUT_MS = 120_000; /** The most one test may take, in milliseconds. The slowest cancels a count that never finishes, which waits on the page's own controls, so this bounds a hang. */ @@ -34,7 +38,7 @@ export default defineConfig({ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { // The site as it is deployed: the production build, served by vite's own preview, not the dev server. - command: `pnpm run site:build && pnpm run site:preview --port ${String(PORT)} --strictPort`, + command: `pnpm run site:build && pnpm run site:preview --host ${HOST} --port ${String(PORT)} --strictPort`, url: BASE_URL, reuseExistingServer: !CI, timeout: SERVER_START_TIMEOUT_MS, From 798ae8765c471595207b76da85d76a99c74dccbc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 20:25:17 +0000 Subject: [PATCH 6/6] ci(site): run the browser tests when the workflow that defines them changes --- .github/workflows/ci.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c36875..71dec70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,7 +282,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # The files the site, its tests and the library it is built from live in. A dispatch always runs. The base is the pull request's base or the commit a push replaces; one that cannot be fetched (a force-pushed branch, a first push) runs the tests, since not knowing is no reason to skip them. The diff is of two trees, so one commit of history each is enough. + # The files the site, its tests, the library it is built from and this workflow live in. A dispatch always runs. The base is the pull request's base or the commit a push replaces; one that cannot be fetched (a force-pushed branch, a first push) runs the tests, since not knowing is no reason to skip them. The diff is of two trees, so one commit of history each is enough. - name: Decide whether the browser tests are needed id: changes env: @@ -300,7 +300,7 @@ jobs: echo "The browser tests run: the base commit could not be fetched." exit 0 fi - if git diff --name-only "$BASE_SHA" HEAD | grep -Eq '^(site/|src/|e2e/|playwright\.config\.ts$|package\.json$|pnpm-lock\.yaml$)'; then + if git diff --name-only "$BASE_SHA" HEAD | grep -Eq '^(site/|src/|e2e/|playwright\.config\.ts$|package\.json$|pnpm-lock\.yaml$|\.github/workflows/ci\.yml$)'; then echo "relevant=true" >> "$GITHUB_OUTPUT" echo "The browser tests run: the site, the library, the tests or the dependencies changed." else diff --git a/README.md b/README.md index d9bc5e5..7cb87f5 100644 --- a/README.md +++ b/README.md @@ -682,7 +682,7 @@ Node 22 or later, pnpm. ESM only. | `pnpm site:build` / `site:dev` / `site:preview` | Build, serve or preview the explorer in `site/`, which imports the library from `src/` | | `pnpm test:e2e` | Playwright tests in `e2e/` that drive the built explorer in headless Chromium, starting and stopping its own preview server; run `pnpm exec playwright install --only-shell chromium` once first | -CI runs the browser tests in `e2e/` as their own job whenever the site, the library source, the tests or the dependencies change, and the job passes at once when none did, so the required check is never missing. It also runs `pnpm exec publint` and `pnpm exec attw --pack --profile esm-only` on the build, and installs the packed tarball into a scratch project on each supported Node line to exercise the public API and to compile a TypeScript consumer against it with no DOM library and no type packages, so the published types, and trilean's, depend on neither. trilean's tree types are inferred from zod schemas, and zod's own declarations name the `URL` global, so a consumer that checks library declarations (`skipLibCheck` off) needs the DOM library or a runtime's types for zod's sake; that diagnostic is the one the check allows. +CI runs the browser tests in `e2e/` as their own job whenever the site, the library source, the tests, the dependencies or the workflow itself change, and the job passes at once when none did, so the required check is never missing. It also runs `pnpm exec publint` and `pnpm exec attw --pack --profile esm-only` on the build, and installs the packed tarball into a scratch project on each supported Node line to exercise the public API and to compile a TypeScript consumer against it with no DOM library and no type packages, so the published types, and trilean's, depend on neither. trilean's tree types are inferred from zod schemas, and zod's own declarations name the `URL` global, so a consumer that checks library declarations (`skipLibCheck` off) needs the DOM library or a runtime's types for zod's sake; that diagnostic is the one the check allows. ## Releases