zz-meridian 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrations.js +8 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +22 -0
- package/payload/docs/distribution.md +2 -2
- package/payload/docs/testing.md +1 -0
- package/payload/package.json +1 -1
- package/payload/scripts/check.ts +1 -1
- package/payload/scripts/lib/slots.ts +34 -0
- package/payload/scripts/verify.config.ts +8 -4
- package/payload/scripts/verify.ts +31 -12
- package/payload/skills/zz-meridian/references/update.md +3 -2
- package/payload/skills/zz-meridian/references/validation.md +4 -2
- package/payload/src/components/patterns/data-table/README.md +2 -2
- package/payload/src/components/patterns/data-table/index.tsx +26 -16
- package/payload/src/components/ui/table/README.md +1 -1
- package/payload/src/components/ui/table/index.tsx +59 -20
- package/payload/src/views/members.tsx +3 -2
- package/payload/tests/table-cut-align.test.tsx +2 -1
- package/payload/tests/table-cut-cell.test.tsx +3 -2
- package/payload/tests/table-grid.test.tsx +49 -0
- package/payload/tests/verify-browsers.test.ts +34 -0
package/dist/migrations.js
CHANGED
|
@@ -470,6 +470,14 @@ export const RELEASE_MIGRATIONS = [
|
|
|
470
470
|
instructions: 'Bring `tests/column-chart-no-data.test.tsx` over from the release as it has it. Since 0.19.0 `src/components/charts/column-chart/index.tsx` draws no column for a `null` period: its box has no height, a dashed hairline (`data-no-data-mark`) keeps the slot, and the word stands over it, because the track was the tallest mark on the chart. A test of your own that finds `.bg-fill-track` or `[data-track]` in a no-data column finds `[data-no-data-mark]` instead.',
|
|
471
471
|
checks: ['gate'],
|
|
472
472
|
},
|
|
473
|
+
{
|
|
474
|
+
id: 'table-grid',
|
|
475
|
+
since: '0.26.0',
|
|
476
|
+
applies: (root) => matching(root, (rel, src) => importsOf(rel, src).some(({ from }) => from === 'src/components/ui/table' || from === 'src/components/patterns/data-table') && (/@min-\[672px\]:min-w-64/.test(src) || /<TableCell\b[^>]*\bcolSpan\s*=/.test(src))),
|
|
477
|
+
summary: 'A table is a grid since 0.26.0: its spare width sits in the gaps, a `grow` column\'s floor is 160px at every width, and a cell spans no columns.',
|
|
478
|
+
instructions: 'Bring `tests/table-cut-cell.test.tsx` over from the release as it has it. Since 0.26.0 `src/components/ui/table/index.tsx` lays a table out as a grid whose rows are subgrids: every column is as wide as its content, the spare is shared equally between the gaps, and a `grow` column\'s floor is `min-w-40` at every width, without the `@min-[672px]:min-w-64` it had from a 672px table. A test of your own that expects that class expects `min-w-40` alone. A `TableCell` of your own with a `colSpan` takes one track of the grid, so it draws in the first column only: a row that should run the width of the table, such as an empty state, is drawn outside the table, or as a `DataTable` `empty`.',
|
|
479
|
+
checks: ['gate'],
|
|
480
|
+
},
|
|
473
481
|
];
|
|
474
482
|
/**
|
|
475
483
|
* A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.26.0] · 2026-10-22
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **A table puts its spare width in the gaps between its columns, an equal share in each.** `Table` is laid out as a CSS grid now, not by the table algorithm. Every column is as wide as its content. `justify-content: space-between` spreads the spare across the gaps, so every gap is the same whatever each column's alignment. In 0.25 the table algorithm put a column's spare inside the column, after a left-aligned value and before a right-aligned one. So a gap between a text column and a number held both columns' shares, and the gap after the number held none. Measured from the ink of one column to the ink of the next at 1920px, the gaps on /customers, /members and /tests now vary by 0 to 3% (a coefficient of variation of 0.00 to 0.03), against 42 to 53% in 0.25.1.
|
|
10
|
+
- **Rows are subgrids** of the table's columns, so a row's border, its hover and its selection run across the gaps. The header row carries the sunk background and the rules.
|
|
11
|
+
- **The table counts its columns** from the cells of its first row as its own JSX writes them: the head's `TableHeader` cells when it has a head, otherwise the first row's `TableCell` cells. It counts one track per column at each `hideBelow` step. Write that row's cells in the table's own JSX, not inside a component of your own, or the table cannot count them.
|
|
12
|
+
- **Cells are blocks in a grid.** A cell no longer sets the row height or centres itself: the row is at least `--row-height` tall and centres its cells. A cell you styled as a table cell (`align-middle`, a fixed height, a `colSpan`) is now laid out by its row; drop those classes.
|
|
13
|
+
- **The `grow` floor is 160px at every width.** It was 256px from a 672px table. A floor is also a width the column claims when its names are shorter, so the gap after the lead was half as wide again as every other gap.
|
|
14
|
+
- **A text column after a number no longer takes extra left padding** (`pl-8`). With equal gaps it made one gap wider than the rest.
|
|
15
|
+
- **`DataTable`'s selection checkbox sits over the lead column's left padding**, not in a column of its own, so no gap stands between a checkbox and the name it selects. It is still its own cell, first in the row, marked `data-check`. The table does not count it.
|
|
16
|
+
- **`update` lists `migration:table-grid`** for a project whose own files expect the old floor (`@min-[672px]:min-w-64`, as `tests/table-cut-cell.test.tsx` did in a project created before 0.26.0) or give a `TableCell` a `colSpan`, which a grid row draws in one track. Bring the release's `tests/table-cut-cell.test.tsx` over, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`.
|
|
17
|
+
- Rows, cells and row groups carry their ARIA roles (`row`, `cell`, `columnheader`, `rowgroup`, `table`), since a table laid out as a grid can lose its native semantics in some browsers.
|
|
18
|
+
|
|
19
|
+
### New
|
|
20
|
+
|
|
21
|
+
- **`pnpm verify --browsers <n>`: one run holds at most `n` browsers at once** ([#77](https://github.com/zhixuan312/zz-meridian/issues/77)).
|
|
22
|
+
- Without the flag, the ceiling is one browser per two CPUs and per 4 GiB of memory, whichever is fewer. `--full`'s audit, presses, keyboard walk and product checks start under the ceiling, not all at once.
|
|
23
|
+
- A check in `browserChecks` holds one browser, or the number its entry names: `{ script, browsers }`, the whole ceiling at most. It starts once that many are free and is told in `VERIFY_BROWSERS` how many it holds, so a check that pools browsers sizes its pool from that number, not from the machine. The sample's journey drives three people at once and declares `browsers: 3`.
|
|
24
|
+
- The trace header prints the ceiling used, for example `browsers: at most 5 at once (10 CPUs and 32 GiB)`.
|
|
25
|
+
- Without a ceiling, one run on a 32 GB laptop held about 70 Chrome processes and pushed the machine about 17 GB into swap.
|
|
26
|
+
|
|
5
27
|
## [0.25.1] · 2026-10-21
|
|
6
28
|
|
|
7
29
|
### Fixed
|
|
@@ -148,10 +148,10 @@ Two environment variables exist for the updater's own tests and are used only wh
|
|
|
148
148
|
reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
|
|
149
149
|
than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
|
|
150
150
|
null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
|
|
151
|
-
absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers
|
|
151
|
+
absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`, `gate` alone), and its
|
|
152
152
|
instructions name the section of a reference (`cache.md`, `live.md`, `validation.md`, `agents.md`, `customize.md`), of the Start guide or of `docs/assistant.md` to follow.
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Seventeen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), and `table-grid` (0.26.0). The ten of 0.5.0 are:
|
|
155
155
|
|
|
156
156
|
| Id | Reported when a team-owned file still has | Completion step |
|
|
157
157
|
|----|-------------------------------------------|-----------------|
|
package/payload/docs/testing.md
CHANGED
|
@@ -34,6 +34,7 @@ A tier is when a check runs, ordered by speed and scope. The first four are one
|
|
|
34
34
|
- **on request**: a command no tier runs, run at the moment `## When each runs` gives it. The combinations that exist:
|
|
35
35
|
- `pnpm verify --perf` is the 20-sample navigation protocol, and the whole of it runs only in CI, split by route with `--shard <i>/<n>`, each shard on its own runner. It refuses to run elsewhere, and a shard runs alone or beside the other tiers. Locally, perf runs only narrowed, `node scripts/perf.ts --routes <route>`, to diagnose one route or to sample a recalibration.
|
|
36
36
|
- `pnpm verify --as <persona>` signs the browser in as each persona it names (a comma-separated list), alone or beside `--full`. It is refused beside `--timed` or `--perf`, before anything runs.
|
|
37
|
+
- `pnpm verify --browsers <n>` holds at most `n` browsers at once in the complete tier. Without it the ceiling is one browser per two CPUs and per 4 GiB of memory, whichever is fewer, and the trace header prints the one a run used (`browsers: at most 5 at once (10 CPUs and 32 GiB)`). The audit, the presses and the keyboard walk take one each; a product check in `browserChecks` takes one, or the `browsers` its entry names (`{ script, browsers: 3 }`), the whole ceiling at most, and is told in `VERIFY_BROWSERS` how many it holds.
|
|
37
38
|
- `node scripts/audit.ts --atlas` and `node scripts/vitals.ts --atlas` run against a served app, outside `pnpm verify`, over every Design Atlas entry.
|
|
38
39
|
- The browser-fixture tests of the checks, `pnpm exec vitest run tests/deep- --no-file-parallelism`, run beside `pnpm verify --full` after a change to a check script. Meridian's repository only.
|
|
39
40
|
- `node cli/scripts/smoke.ts --tarball <tarball>` is the consumer smoke, a packed tarball adopted, created and updated end to end. Meridian's repository only.
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
package/payload/scripts/check.ts
CHANGED
|
@@ -57,7 +57,7 @@ function walk(dir: string, ext: RegExp, out: string[] = []) {
|
|
|
57
57
|
const css = read('src/styles/tokens.css') + read('src/styles/base.css') + read('src/styles/motion.css');
|
|
58
58
|
const defined = new Set([...css.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1]));
|
|
59
59
|
/** Local custom properties a component sets for itself, and the host's bridged variables. */
|
|
60
|
-
const LOCAL = /^(i|w|m-len|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
60
|
+
const LOCAL = /^(i|w|m-len|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
61
61
|
|
|
62
62
|
// ── Cards ────────────────────────────────────────────────────────────────────────────────────────────
|
|
63
63
|
const CARD_SECTIONS = ['## Surfaces', '## Agents', '## Accessibility'];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ceiling on how many browsers one verify run holds at once (#77). Each suite asks for the browsers it will open and
|
|
3
|
+
* starts when that many are free; one that asks for more than the ceiling is given the whole ceiling. Without it the
|
|
4
|
+
* browser suites all started together, and a product check that pools its own browsers added its pool on top.
|
|
5
|
+
*/
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
/** What a machine can hold: one browser for every two CPUs and every 4 GiB of memory, whichever is fewer, and at least one. */
|
|
9
|
+
export function browserCeiling(cpus = os.availableParallelism(), memory = os.totalmem()) {
|
|
10
|
+
return Math.max(1, Math.min(Math.floor(cpus / 2), Math.floor(memory / 2 ** 32)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function slots(ceiling: number) {
|
|
14
|
+
let free = ceiling;
|
|
15
|
+
const waiting: { want: number; go: () => void }[] = [];
|
|
16
|
+
const next = () => {
|
|
17
|
+
while (waiting.length && waiting[0].want <= free) {
|
|
18
|
+
const w = waiting.shift()!;
|
|
19
|
+
free -= w.want;
|
|
20
|
+
w.go();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Run `job` once `want` browsers are free, and free them when it ends, however it ends. */
|
|
24
|
+
return async function hold<T>(want: number, job: () => Promise<T>): Promise<T> {
|
|
25
|
+
const n = Math.max(1, Math.min(want, ceiling));
|
|
26
|
+
await new Promise<void>((go) => { waiting.push({ want: n, go }); next(); });
|
|
27
|
+
try {
|
|
28
|
+
return await job();
|
|
29
|
+
} finally {
|
|
30
|
+
free += n;
|
|
31
|
+
next();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -73,9 +73,12 @@ type VerifyConfig = {
|
|
|
73
73
|
allowRemoteData?: true;
|
|
74
74
|
/**
|
|
75
75
|
* The product's own browser checks, beside Meridian's audit, presses and keyboard walk: scripts verify runs with
|
|
76
|
-
* `--base <url>` against the built app, failing when one exits non-zero.
|
|
76
|
+
* `--base <url>` against the built app, failing when one exits non-zero. An entry is the script's path, for a check that
|
|
77
|
+
* opens one browser, or `{ script, browsers }` for one that opens more at once. Verify holds that many of the run's
|
|
78
|
+
* browsers (`--browsers`), the whole ceiling at most, while it runs, and tells it in `VERIFY_BROWSERS` how many that
|
|
79
|
+
* is: a check that pools browsers sizes its pool from it, not from the machine.
|
|
77
80
|
*/
|
|
78
|
-
browserChecks?: string[];
|
|
81
|
+
browserChecks?: (string | { script: string; browsers: number })[];
|
|
79
82
|
/**
|
|
80
83
|
* The product's own browser checks that judge a time, run by `pnpm verify --timed` alone and one at a time, after the
|
|
81
84
|
* live data and before Web Vitals, each with `--base <url>` like `browserChecks`. A check that judges a time under the
|
|
@@ -154,8 +157,9 @@ const config: VerifyConfig = {
|
|
|
154
157
|
'/customers/missing', // no customer has this id: the not-found screen in the shell
|
|
155
158
|
],
|
|
156
159
|
smokeRoutes: ['/', '/requests', '/settings'],
|
|
157
|
-
// The sample's own journeys across its three workspaces, run beside the audit, the presses and the keyboard walk
|
|
158
|
-
|
|
160
|
+
// The sample's own journeys across its three workspaces, run beside the audit, the presses and the keyboard walk; three
|
|
161
|
+
// people at once, so three browsers.
|
|
162
|
+
browserChecks: [{ script: 'journeys/workspaces.ts', browsers: 3 }],
|
|
159
163
|
navigationChecks: [
|
|
160
164
|
{ path: '/', title: 'Home', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main [role="radio"][aria-checked="false"]', resultSelector: 'main [role="radio"]' },
|
|
161
165
|
{ path: '/members', title: 'Members', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main button[aria-haspopup="menu"]', resultSelector: '[role="menu"]' },
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* and through the phone drawer, warm, cold and right after a live refresh, the p95 reported
|
|
17
17
|
* The whole protocol runs only where CI is `true`, a share of it with `--shard <i>/<n>` (with --perf alone)
|
|
18
18
|
* pnpm verify --full --perf the deep suites together; the gate and the build are shared, so each runs once
|
|
19
|
+
* --browsers <n> the most browsers the run holds at once (default: one per two CPUs and per 4 GiB of memory,
|
|
20
|
+
* whichever is fewer); --full's browser suites start under it, and a product check is told in
|
|
21
|
+
* VERIFY_BROWSERS how many it holds
|
|
19
22
|
*
|
|
20
23
|
* It prints one line per phase (`ok`, `warn`, `FAIL` or `not run`, and the seconds), then one coverage line saying what
|
|
21
24
|
* ran and what did not, then the outcome. Nothing that did not run is reported as passed. The trace, with the commit, the
|
|
@@ -41,6 +44,7 @@ import net from 'node:net';
|
|
|
41
44
|
import os from 'node:os';
|
|
42
45
|
import path from 'node:path';
|
|
43
46
|
|
|
47
|
+
import { browserCeiling, slots } from './lib/slots.ts';
|
|
44
48
|
import { bin } from './lib/bin.ts';
|
|
45
49
|
import { launch } from './lib/chrome.ts';
|
|
46
50
|
import type { BudgetsConfig, NavigationCheck } from './lib/budgets.ts';
|
|
@@ -59,7 +63,7 @@ type VerifyConfig = BudgetsConfig & {
|
|
|
59
63
|
noLiveApi?: true;
|
|
60
64
|
dataUrls?: string[];
|
|
61
65
|
allowRemoteData?: true;
|
|
62
|
-
browserChecks?: string[];
|
|
66
|
+
browserChecks?: (string | { script: string; browsers: number })[];
|
|
63
67
|
timedChecks?: string[];
|
|
64
68
|
fakeModel?: FakeModelStep[];
|
|
65
69
|
navigationChecks?: NavigationCheck[];
|
|
@@ -70,17 +74,19 @@ type VerifyConfig = BudgetsConfig & {
|
|
|
70
74
|
const config = verifyConfig as VerifyConfig;
|
|
71
75
|
|
|
72
76
|
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
73
|
-
const OPTIONS = ['--full', '--timed', '--perf', '--as'];
|
|
77
|
+
const OPTIONS = ['--full', '--timed', '--perf', '--as', '--browsers'];
|
|
74
78
|
|
|
75
|
-
// `--as`
|
|
76
|
-
//
|
|
79
|
+
// `--as` and `--browsers` consume the argument after them: a comma-separated persona list, a number of browsers. Every
|
|
80
|
+
// other argument must be a known option. An unknown option is refused here, before anything runs.
|
|
77
81
|
const argv = process.argv.slice(2);
|
|
78
82
|
const unknown: string[] = [];
|
|
79
83
|
let asList = '';
|
|
80
84
|
let shardText: string | undefined;
|
|
85
|
+
let browsersText: string | undefined;
|
|
81
86
|
for (let i = 0; i < argv.length; i++) {
|
|
82
87
|
const a = argv[i];
|
|
83
88
|
if (a === '--as') { asList = argv[++i] ?? ''; continue; }
|
|
89
|
+
if (a === '--browsers') { browsersText = argv[++i] ?? ''; continue; }
|
|
84
90
|
if (a === '--shard') { shardText = argv[++i] ?? ''; continue; }
|
|
85
91
|
if (!OPTIONS.includes(a)) unknown.push(a);
|
|
86
92
|
}
|
|
@@ -88,6 +94,13 @@ if (unknown.length) {
|
|
|
88
94
|
console.error(`verify: ${unknown.join(', ')} ${unknown.length === 1 ? 'is not an option' : 'are not options'}. pnpm verify takes ${OPTIONS.join(', ')}.`);
|
|
89
95
|
process.exit(1);
|
|
90
96
|
}
|
|
97
|
+
if (browsersText !== undefined && !/^[1-9]\d*$/.test(browsersText)) {
|
|
98
|
+
console.error(`verify: --browsers takes a whole number of browsers, at least 1, not "${browsersText}".`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
/** The most browsers this run holds at once: `--browsers`, or what the machine can hold. */
|
|
102
|
+
const ceiling = browsersText ? Number(browsersText) : browserCeiling();
|
|
103
|
+
const ceilingFrom = browsersText ? '--browsers' : `${os.availableParallelism()} CPUs and ${Math.round(os.totalmem() / 2 ** 30)} GiB`;
|
|
91
104
|
const full = argv.includes('--full');
|
|
92
105
|
const timed = argv.includes('--timed');
|
|
93
106
|
const perf = argv.includes('--perf');
|
|
@@ -145,7 +158,8 @@ const hasAssistant = fs.existsSync(path.join(ROOT, APP_DIR, 'api/assistant/route
|
|
|
145
158
|
// The stream itself is Meridian's, proven on the sample in Meridian's own runs; a product's own live pages prove
|
|
146
159
|
// themselves through `browserChecks`.
|
|
147
160
|
const hasLiveSample = sampleSurfaces(ROOT, APP_DIR).members;
|
|
148
|
-
|
|
161
|
+
/** Each product check with the browsers it opens at once: one, unless its entry says more. */
|
|
162
|
+
const own = (config.browserChecks ?? []).map((c) => (typeof c === 'string' ? { script: c, browsers: 1 } : c));
|
|
149
163
|
const ownTimed = config.timedChecks ?? [];
|
|
150
164
|
const absent = new Set<string>([...(hasAssistant ? [] : ['assistant']), ...(hasLiveSample ? [] : ['live']), ...(own.length ? [] : ['browserChecks']), ...(ownTimed.length ? [] : ['timedChecks']), ...(perf ? [] : ['perf'])]);
|
|
151
165
|
const SUITES = SUITE_NAMES.filter((name) => !absent.has(name));
|
|
@@ -203,6 +217,7 @@ function traceHeader(code: number): string[] {
|
|
|
203
217
|
`commit: ${sha ? `${sha}${dirty ? ' (working tree has changes)' : ''}` : 'not a git checkout'}`,
|
|
204
218
|
`toolchain: node ${process.version}; next ${nextVersion()}; ${process.env.npm_config_user_agent?.split(' ')[0] ?? 'no package manager'}`,
|
|
205
219
|
`browser: ${chrome}; profile: ${os.platform()} ${os.arch()}, ${cpus.length} CPUs (${cpus[0]?.model ?? 'unknown'}), local, not the reference profile`,
|
|
220
|
+
`browsers: at most ${ceiling} at once (${ceilingFrom})`,
|
|
206
221
|
`selected routes: ${selected.length ? selected.join(', ') : 'none'}`,
|
|
207
222
|
`gate runs: ${gates}`,
|
|
208
223
|
`build runs: ${builds}`,
|
|
@@ -620,20 +635,24 @@ if (full && browser.ran && baseUrl) {
|
|
|
620
635
|
if (suiteOutcome(on.status) === 'FAIL') ok = false;
|
|
621
636
|
}
|
|
622
637
|
|
|
623
|
-
// The audit, the presses and the keyboard walk each run
|
|
638
|
+
// The audit, the presses and the keyboard walk each run one browser of their own, side by side against the one built
|
|
639
|
+
// app, as many at once as the ceiling allows (#77). A product check holds the browsers its entry says it opens, the
|
|
640
|
+
// whole ceiling at most, and is told in VERIFY_BROWSERS how many that is, so a check that pools browsers sizes its pool
|
|
641
|
+
// from that and not from the machine; it starts once that many are free.
|
|
624
642
|
current = 'the browser suites';
|
|
625
|
-
announce('the audit, every control and link, the keyboard path' + (own.length ? ', and the product\'s own checks' : ''));
|
|
643
|
+
announce('the audit, every control and link, the keyboard path' + (own.length ? ', and the product\'s own checks' : '') + `, at most ${ceiling} browsers at once`);
|
|
626
644
|
const here = `http://127.0.0.1:${server!.port}`;
|
|
645
|
+
const hold = slots(ceiling);
|
|
627
646
|
const [audit, presses, keys, ...extras] = await Promise.all([
|
|
628
|
-
run('scripts/audit.ts', ['--base', here]),
|
|
629
|
-
run('scripts/interactions.ts', ['--base', here]),
|
|
630
|
-
run('scripts/keyboard.ts', ['--base', here]),
|
|
631
|
-
...own.map((
|
|
647
|
+
hold(1, () => run('scripts/audit.ts', ['--base', here])),
|
|
648
|
+
hold(1, () => run('scripts/interactions.ts', ['--base', here])),
|
|
649
|
+
hold(1, () => run('scripts/keyboard.ts', ['--base', here])),
|
|
650
|
+
...own.map((c) => hold(c.browsers, () => run(c.script, ['--base', here], { ...process.env, VERIFY_BROWSERS: String(Math.min(c.browsers, ceiling)) }))),
|
|
632
651
|
]);
|
|
633
652
|
reportSuite('audit', 'audit: every page at 5 widths in both themes', audit);
|
|
634
653
|
reportSuite('presses', 'every control pressed and every link followed', presses);
|
|
635
654
|
reportSuite('keyboard', 'the whole keyboard path', keys);
|
|
636
|
-
extras.forEach((r, i) => reportSuite('browserChecks', own[i], r));
|
|
655
|
+
extras.forEach((r, i) => reportSuite('browserChecks', own[i].script, r));
|
|
637
656
|
if ([audit, presses, keys, ...extras].some((r) => suiteOutcome(r.status) === 'FAIL')) ok = false;
|
|
638
657
|
}
|
|
639
658
|
|
|
@@ -169,7 +169,7 @@ update (`2` pending, `0` finalized, `1` failure). `--abort`: `0` aborted, `1` re
|
|
|
169
169
|
|
|
170
170
|
## Resolving 0.9.0's migrations
|
|
171
171
|
|
|
172
|
-
Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, to 0.17.0 one more, for a file calling `finalOutcome` with a boolean, and to 0.19.0 one more, for a test that expects a Column chart's period with no data to draw a track. Each is resolved the same way as those below:
|
|
172
|
+
Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, to 0.17.0 one more, for a file calling `finalOutcome` with a boolean, and to 0.19.0 one more, for a test that expects a Column chart's period with no data to draw a track, and to 0.26.0 one more, for a test that expects a table's 256px floor or a table cell that spans columns. Each is resolved the same way as those below:
|
|
173
173
|
|
|
174
174
|
| Id | The old shape it found | The change |
|
|
175
175
|
|----|------------------------|------------|
|
|
@@ -189,6 +189,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
|
|
|
189
189
|
| `today-in-timezone` (0.16.3) | `optional:app/api/assistant/route.ts` or `optional:src/data/metrics.ts` cutting today with `clock().toISOString().slice(0, 10)` | `dayOf(clock())` from `src/lib/format-date.ts`, so today is the day in `app.timezone`; a file of your own comparing an instant's day with a day row asks `dayOf` too; checked by the gate |
|
|
190
190
|
| `verify-tiers` (0.17.0) | a file of the team's importing `finalOutcome` from `scripts/lib/coverage.ts` and calling it with a boolean, such as a copy of `optional:tests/verify-suite-outcome.test.ts` from before the tiers | `finalOutcome` takes `{ full, timed }` since verify has a timed tier: bring `optional:tests/verify-suite-outcome.test.ts` over from the release as it has it, and call `finalOutcome({ full, timed }, notRun)` in a file of your own, as `validation.md` describes; checked by the gate |
|
|
191
191
|
| `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
|
|
192
|
+
| `table-grid` (0.26.0) | a file of the team's importing the Table or the DataTable that expects a `grow` column's `@min-[672px]:min-w-64`, such as `optional:tests/table-cut-cell.test.tsx` as the template wrote it, or passes a `colSpan` to a `TableCell` | a table is a grid since 0.26.0, its spare shared equally between the gaps, and a `grow` column's floor is `min-w-40` at every width: bring `optional:tests/table-cut-cell.test.tsx` over from the release as it has it, expect `min-w-40` alone where a test of your own expected the wider floor, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`, since a cell takes one track; checked by the gate |
|
|
192
193
|
|
|
193
194
|
A created project that never changed the template's pages brings the release's version of each affected file over, with
|
|
194
195
|
the files that arrived with them: the view contexts `optional:src/views/home-context.ts` and its siblings, the view
|
|
@@ -218,7 +219,7 @@ each one in the project, then record it as above, with the hashes of the files a
|
|
|
218
219
|
| `live-provider` | collections without `optional:src/data/live-actions.ts`, or a console layout without the provider | add the live files: `live.md`, "The live starters" and "Refreshing" |
|
|
219
220
|
| `authorized-endpoints` | the assistant route or an actions file that never calls `resolveAccess` | resolve the caller first, and bring `optional:src/lib/assistant/tools.ts` and `optional:src/lib/assistant/respond.ts` over, which take the guard: "The assistant route" |
|
|
220
221
|
|
|
221
|
-
Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers
|
|
222
|
+
Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`.
|
|
222
223
|
|
|
223
224
|
How to make the change depends on whose file it is:
|
|
224
225
|
|
|
@@ -84,7 +84,9 @@ line naming the command that runs them. Steps in order:
|
|
|
84
84
|
border (or the frame around it, or any ancestor up to its host inside a shadow root) differs when it has focus. A ring
|
|
85
85
|
that is always there does not count. With them run the
|
|
86
86
|
product's own browser checks (`browserChecks` in `scripts/verify.config.ts`), each given `--base` and the served
|
|
87
|
-
app's address; none of them judges a time.
|
|
87
|
+
app's address; none of them judges a time. The run holds at most `--browsers` browsers at once (one per two CPUs and
|
|
88
|
+
per 4 GiB by default): each suite takes one, a check whose entry is `{ script, browsers }` takes that many, and a
|
|
89
|
+
check reads how many it holds from `VERIFY_BROWSERS`.
|
|
88
90
|
7. **Live data** (`--timed` only; `scripts/live.ts`), alone after the presses, which change members: two tabs, a quiet
|
|
89
91
|
or restarted stream, a hidden or offline tab and a burst, against a second server that drops its change hints. A case
|
|
90
92
|
that cannot run is `not run` and the suite exits 2. It drives the sample Members page; a product without it prints
|
|
@@ -138,7 +140,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
|
|
|
138
140
|
| `unmeasured: x-card (no open shadow root)` | A custom element shows a box but exposes no open root and no content in the page, so nothing inside it was measured | Attach its root open (`attachShadow({ mode: 'open' })`), or render its content as light DOM in a slot; the audit fails until it can look |
|
|
139
141
|
| `unmeasured: x-card (not defined)` | A custom element was still not registered after the settle wait | Register it (`customElements.define`) before the page settles, or remove the tag from the page |
|
|
140
142
|
| `"Export" does nothing when pressed` | A control with no action, or one that only works on hover | Wire it, or remove it; an info button uses `Tooltip toggle` so a tap opens it |
|
|
141
|
-
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column
|
|
143
|
+
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column is as wide as its content, the spare sits in the gaps between columns, an equal share in each, and a cut column shrinks only when the table lacks room; a fixed `width` keeps its width, and the `grow` floor is 160px |
|
|
142
144
|
| `exception:` / `console.error:` | A runtime error or a hydration mismatch | Hydration: no `Date.now()`, `Math.random()` or locale-dependent formatting in render; use the demo clock and `src/lib/format*` |
|
|
143
145
|
|
|
144
146
|
## Keyboard walk failures
|
|
@@ -27,7 +27,7 @@ Status: beta
|
|
|
27
27
|
|
|
28
28
|
## Sizes
|
|
29
29
|
|
|
30
|
-
Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width. Every column
|
|
30
|
+
Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width. Every column is as wide as its content, and the spare sits between the columns, an equal share in every gap, so the gaps read even (the Table's Sizes). The `grow` column (the record's name) is at least 160px. A column whose cells `truncate` asks for its whole content width too and shrinks only when the table lacks room for every column; every column that must show its whole value keeps its content width. A column whose content is short and fixed (an ID, a key, a date) can take `width` (`w-28`), which it keeps. The selection checkbox sits over the lead column's left padding rather than in a column of its own, so no gap stands between a checkbox and the name it selects.
|
|
31
31
|
|
|
32
32
|
## States
|
|
33
33
|
|
|
@@ -85,7 +85,7 @@ Rows arrive whole for client-side sorting and paging (a few hundred at most), or
|
|
|
85
85
|
|
|
86
86
|
## Do and do not
|
|
87
87
|
|
|
88
|
-
- Do give the record's name `grow`: it sets the floor and the phone card's title
|
|
88
|
+
- Do give the record's name `grow`: it sets the floor and the phone card's title. A column may be cut (`truncate`) if its values are long, since it shows them whole while the table has room and shrinks only when it has not.
|
|
89
89
|
- Do drop columns with `hideBelow` rather than letting the table scroll sideways.
|
|
90
90
|
- Do not put more than one action per row; a row with several actions opens a record that has them.
|
|
91
91
|
- Do not show pagination for a list that fits on one page.
|
|
@@ -36,9 +36,9 @@ export type Column<R> = {
|
|
|
36
36
|
title?: (row: R) => string;
|
|
37
37
|
/** Drop the column below this width (the least important columns go first). */
|
|
38
38
|
hideBelow?: Breakpoint;
|
|
39
|
-
/** The record's name: the one column with a floor, so the last to be cut.
|
|
39
|
+
/** The record's name: the one column with a floor, so the last to be cut. */
|
|
40
40
|
grow?: boolean;
|
|
41
|
-
/** A width class for a column whose content is short and fixed (an ID, a key, a date),
|
|
41
|
+
/** A width class for a column whose content is short and fixed (an ID, a key, a date), which it keeps: `w-28`. */
|
|
42
42
|
width?: string;
|
|
43
43
|
/**
|
|
44
44
|
* Its place in the card a row becomes on phones: the `title` (one column, the record's name), a `status` at the end
|
|
@@ -75,10 +75,10 @@ type Mobile = 'check' | 'title' | 'status' | 'fact' | 'hidden';
|
|
|
75
75
|
*/
|
|
76
76
|
const CELLS = [
|
|
77
77
|
'text-ink',
|
|
78
|
-
'[&_td]:
|
|
78
|
+
'[&_td]:px-4 [&_td]:py-2 [&_td]:text-left',
|
|
79
79
|
'[&_td[data-num]]:text-right [&_td[data-num=num]]:whitespace-nowrap',
|
|
80
80
|
'[&_[data-sep]]:hidden',
|
|
81
|
-
'[&_tbody_tr]:transition-colors [&_tbody_tr]:duration-(--dur-hover)
|
|
81
|
+
'[&_tbody_tr]:transition-colors [&_tbody_tr]:duration-(--dur-hover)',
|
|
82
82
|
].join(' ');
|
|
83
83
|
|
|
84
84
|
/**
|
|
@@ -95,8 +95,8 @@ const PHONE_ROWS = [
|
|
|
95
95
|
'@max-[640px]:[&_tbody_tr]:px-(--card-pad) @max-[640px]:[&_tbody_tr]:py-3.5 @max-[640px]:[&_tbody_td]:border-0 @max-[640px]:[&_tbody_tr]:border-b @max-[640px]:[&_tbody_tr]:border-line @max-[640px]:[&_tbody_tr:last-child]:border-b-0',
|
|
96
96
|
].join(' ');
|
|
97
97
|
|
|
98
|
-
/** The resets a cell needs once the row is a card: not a
|
|
99
|
-
const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!
|
|
98
|
+
/** The resets a cell needs once the row is a card: not a padding box, free to shrink and to clip. */
|
|
99
|
+
const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!p-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:min-w-0 @max-[640px]:[&_td:not([data-mobile=hidden]):not([data-mobile=fact])]:!block @max-[640px]:[&_[data-sep]]:inline';
|
|
100
100
|
|
|
101
101
|
/** Where a role sits in the card, and how it reads there.
|
|
102
102
|
*
|
|
@@ -119,6 +119,16 @@ const PHONE_ROLES = [
|
|
|
119
119
|
'@max-[640px]:[&_tr[aria-selected]_td[data-mobile=fact]]:text-ink-2',
|
|
120
120
|
].join(' ');
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The selection checkbox sits inside the lead column's area, over its left padding, rather than in a column of its own:
|
|
124
|
+
* the table puts an equal share of its spare width in every gap between columns, and a column of checkboxes would stand
|
|
125
|
+
* that far from the names it selects. It is still its own cell, first in the row, and the table does not count it.
|
|
126
|
+
*/
|
|
127
|
+
const SELECT = [
|
|
128
|
+
'[&_[data-check]]:relative [&_[data-check]]:z-1 [&_[data-check]]:col-start-1 [&_[data-check]]:row-start-1 [&_[data-check]]:justify-self-start',
|
|
129
|
+
'[&_[data-check]+*]:col-start-1 [&_[data-check]+*]:row-start-1 [&_[data-check]+*]:pl-[calc(var(--card-pad)+--spacing(7))]',
|
|
130
|
+
].join(' ');
|
|
131
|
+
|
|
122
132
|
/* Literal strings, as in the Table: Tailwind finds classes by reading the source. */
|
|
123
133
|
const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-[672px]:hidden', lg: '@max-[896px]:hidden', xl: '@max-[1152px]:hidden' };
|
|
124
134
|
|
|
@@ -293,13 +303,13 @@ export function DataTable<R>({
|
|
|
293
303
|
} else {
|
|
294
304
|
const roleOf = (c: Column<R>): Mobile => (c === titleColumn ? 'title' : c === status ? 'status' : facts.includes(c) ? 'fact' : 'hidden');
|
|
295
305
|
body = (
|
|
296
|
-
<Table caption={caption} aria-busy={loading || busy || undefined} className={cn('@max-[640px]:block @max-[640px]:[&>thead]:hidden @max-[640px]:[&>tbody]:block', CELLS, PHONE_ROWS, PHONE_CELLS, PHONE_ROLES, '[&>tbody]:transition-opacity [&>tbody]:duration-(--dur-hover)', busy && '[&>tbody]:opacity-60')}>
|
|
306
|
+
<Table caption={caption} aria-busy={loading || busy || undefined} className={cn('@max-[640px]:block @max-[640px]:[&>thead]:hidden @max-[640px]:[&>tbody]:block', CELLS, selectable && SELECT, PHONE_ROWS, PHONE_CELLS, PHONE_ROLES, '[&>tbody]:transition-opacity [&>tbody]:duration-(--dur-hover)', busy && '[&>tbody]:opacity-60')}>
|
|
297
307
|
<TableHead>
|
|
298
|
-
<tr>
|
|
308
|
+
<tr role="row">
|
|
299
309
|
{selectable ? (
|
|
300
|
-
<
|
|
310
|
+
<th scope="col" role="columnheader" data-check className="w-10 !pr-0">
|
|
301
311
|
<Checkbox aria-label={`Select every ${noun.replace(/s$/, '')} on this page`} checked={allOn ? true : someOn ? 'indeterminate' : false} onCheckedChange={toggleAll} />
|
|
302
|
-
</
|
|
312
|
+
</th>
|
|
303
313
|
) : null}
|
|
304
314
|
{columns.map((c) => (
|
|
305
315
|
<TableHeader key={c.key} align={c.align ?? (c.numeric ? 'right' : 'left')} hideBelow={c.hideBelow} grow={c.grow} className={c.width} sort={c.sortValue ? dirOf(c) : undefined} onSort={c.sortValue ? () => toggleSort(c) : undefined}>
|
|
@@ -311,10 +321,10 @@ export function DataTable<R>({
|
|
|
311
321
|
<TableBody>
|
|
312
322
|
{loading
|
|
313
323
|
? Array.from({ length: Math.min(size, 8) }, (_, i) => (
|
|
314
|
-
<tr key={i} className="group/row">
|
|
315
|
-
{selectable ? <td data-mobile="check"><Skeleton className="size-4" /></td> : null}
|
|
324
|
+
<tr key={i} role="row" className="group/row">
|
|
325
|
+
{selectable ? <td role="cell" data-check data-mobile="check"><Skeleton className="size-4" /></td> : null}
|
|
316
326
|
{columns.map((c, j) => (
|
|
317
|
-
<td key={c.key} data-mobile={roleOf(c)} data-num={c.numeric ? 'num' : undefined} data-hide-below={c.hideBelow} className={cellClass({ numeric: c.numeric, hideBelow: c.hideBelow })}>
|
|
327
|
+
<td key={c.key} role="cell" data-mobile={roleOf(c)} data-num={c.numeric ? 'num' : undefined} data-hide-below={c.hideBelow} className={cellClass({ numeric: c.numeric, hideBelow: c.hideBelow })}>
|
|
318
328
|
<Skeleton className={cn('h-3', c.numeric ? 'ml-auto w-12' : c.grow ? ['w-48', 'w-40', 'w-56'][i % 3] : ['w-20', 'w-16', 'w-24'][(i + j) % 3])} />
|
|
319
329
|
</td>
|
|
320
330
|
))}
|
|
@@ -324,9 +334,9 @@ export function DataTable<R>({
|
|
|
324
334
|
const k = rowKey(r);
|
|
325
335
|
const href = rowHref?.(r);
|
|
326
336
|
return (
|
|
327
|
-
<tr key={k} aria-selected={sel.has(k) || undefined} onClick={(e) => open(e, href)} className={cn('group/row', href && 'cursor-pointer hover:bg-fill-hover', sel.has(k) && 'bg-accent-tint hover:bg-accent-tint')}>
|
|
337
|
+
<tr key={k} role="row" aria-selected={sel.has(k) || undefined} onClick={(e) => open(e, href)} className={cn('group/row', href && 'cursor-pointer hover:bg-fill-hover', sel.has(k) && 'bg-accent-tint hover:bg-accent-tint')}>
|
|
328
338
|
{selectable ? (
|
|
329
|
-
<td data-mobile="check" className="!pr-0">
|
|
339
|
+
<td role="cell" data-check data-mobile="check" className="!pr-0">
|
|
330
340
|
<Checkbox aria-label={`Select ${k}`} checked={sel.has(k)} onCheckedChange={() => toggle(k)} />
|
|
331
341
|
</td>
|
|
332
342
|
) : null}
|
|
@@ -340,7 +350,7 @@ export function DataTable<R>({
|
|
|
340
350
|
const value = c.cell(r);
|
|
341
351
|
const content = c === titleColumn && href ? <Link href={href}>{value}</Link> : value;
|
|
342
352
|
return (
|
|
343
|
-
<td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} data-hide-below={c.hideBelow} title={c.title ? c.title(r) : c.truncate && (typeof value === 'string' || typeof value === 'number') ? String(value) : undefined} className={cellClass(c) || undefined}>
|
|
353
|
+
<td key={c.key} role="cell" data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} data-hide-below={c.hideBelow} title={c.title ? c.title(r) : c.truncate && (typeof value === 'string' || typeof value === 'number') ? String(value) : undefined} className={cellClass(c) || undefined}>
|
|
344
354
|
{/* The dot that separates two facts, on a phone only — the table has a column for each of
|
|
345
355
|
them, where a dot would be noise. It leads the fact it separates, in that fact's own
|
|
346
356
|
cell: each fact is a flex item, so a line that runs out of room wraps whole ones and a
|
|
@@ -24,7 +24,7 @@ The names follow the HTML elements: `TableHead` is the `thead`, `TableHeader` is
|
|
|
24
24
|
|
|
25
25
|
## Sizes
|
|
26
26
|
|
|
27
|
-
Column widths follow content.
|
|
27
|
+
Column widths follow content, and the spare goes between the columns. The table is a grid: every column is as wide as its content, and a table with spare width puts an equal share of it in every gap between two columns, so the gaps read even whatever each column's alignment, a number beside a name as much as two names. Each row is a subgrid of the table's columns, so its border, hover and selection run across the gaps. The columns are counted from the cells of the table's first row as its own JSX writes them, the head's `TableHeader` cells when it has a head: write that row's cells there, not inside a component of your own, or the table cannot count them. `grow` on the head marks the record's name, the last column to be cut. A column whose cells can be cut (`truncate`) asks for its whole content width too, and shrinks only when the table lacks room for every column; then the cut columns give way, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A column given `width` keeps that width. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. The `grow` column is at least 160px. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike, and marks each `data-hide-below`, so the column drawn last at that width, not the dropped one after it, takes the card's padding on its right.
|
|
28
28
|
|
|
29
29
|
## States
|
|
30
30
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react';
|
|
4
|
-
import type
|
|
4
|
+
import { Children, isValidElement, type CSSProperties, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
|
|
5
5
|
import { cn } from '@/lib/cn';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -9,6 +9,13 @@ import { cn } from '@/lib/cn';
|
|
|
9
9
|
* too narrow, the least important columns drop (`hideBelow` on the head and the cells alike), and a DataTable's rows
|
|
10
10
|
* become cards on phones. Numbers align right in tabular figures.
|
|
11
11
|
*
|
|
12
|
+
* It is laid out as a grid, not by the table algorithm: each column is as wide as its content, and the spare width sits
|
|
13
|
+
* between the columns, an equal share in every gap, so the gaps read even whatever each column's alignment. The table
|
|
14
|
+
* algorithm put each column's spare inside it, after a left-aligned value and before a right-aligned one, so a text
|
|
15
|
+
* column beside a number held both shares in one gap and a number beside a text column none. Each row is a subgrid of
|
|
16
|
+
* the table's columns, so a row's border, hover and selection run across the gaps. The columns are counted from the
|
|
17
|
+
* cells of the table's first row as its own JSX writes them, the head's `TableHeader` cells when it has a head.
|
|
18
|
+
*
|
|
12
19
|
* `hideBelow` reads the table's own width, not the window's: the rail, a split row or an embed all narrow the table
|
|
13
20
|
* without narrowing the window. sm under 512px, md under 672px, lg under 896px, xl under 1152px.
|
|
14
21
|
*/
|
|
@@ -18,20 +25,48 @@ const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-
|
|
|
18
25
|
const ALIGN = { left: 'text-left', right: 'text-right', center: 'text-center' } as const;
|
|
19
26
|
const JUSTIFY = { left: undefined, right: 'justify-end', center: 'justify-center' } as const;
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
type Props = { children?: ReactNode; hideBelow?: Breakpoint };
|
|
29
|
+
|
|
30
|
+
/** Which columns the table draws: the `hideBelow` of each cell in its first row, the head's when it has one. */
|
|
31
|
+
function columnsOf(node: ReactNode): (Breakpoint | undefined)[] | undefined {
|
|
32
|
+
const kids = Children.toArray(node).filter((c) => isValidElement<Props>(c));
|
|
33
|
+
const cells = kids.filter((c) => c.type === TableHeader || c.type === TableCell);
|
|
34
|
+
if (cells.length > 0) return cells.map((c) => c.props.hideBelow);
|
|
35
|
+
for (const c of kids) {
|
|
36
|
+
const found = columnsOf(c.props.children);
|
|
37
|
+
if (found) return found;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Below each step a column with that `hideBelow`, or a wider one, is dropped. */
|
|
43
|
+
const DROPPED: Record<Breakpoint, Breakpoint[]> = { xl: ['xl'], lg: ['xl', 'lg'], md: ['xl', 'lg', 'md'], sm: ['xl', 'lg', 'md', 'sm'] };
|
|
44
|
+
|
|
45
|
+
/** One `auto` track per column the table draws at each step: as wide as its content, shrinking when there is no room. */
|
|
46
|
+
function columns(heads: (Breakpoint | undefined)[]) {
|
|
47
|
+
const at = (drop: Breakpoint[]) => `repeat(${Math.max(1, heads.filter((h) => !h || !drop.includes(h)).length)}, auto)`;
|
|
48
|
+
return { '--cols': at([]), '--cols-xl': at(DROPPED.xl), '--cols-lg': at(DROPPED.lg), '--cols-md': at(DROPPED.md), '--cols-sm': at(DROPPED.sm) } as CSSProperties;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Table({ className, caption, children, style, ...rest }: HTMLAttributes<HTMLTableElement> & { caption?: string }) {
|
|
52
|
+
const heads = columnsOf(children) ?? [];
|
|
22
53
|
return (
|
|
23
54
|
<div className="@container w-full min-w-0 overflow-x-clip">
|
|
24
55
|
<table
|
|
56
|
+
role="table"
|
|
57
|
+
style={{ ...columns(heads), ...style }}
|
|
25
58
|
className={cn(
|
|
26
|
-
'w-full
|
|
59
|
+
'grid w-full justify-between text-sm',
|
|
60
|
+
'grid-cols-(--cols) @max-[1152px]:grid-cols-(--cols-xl) @max-[896px]:grid-cols-(--cols-lg) @max-[672px]:grid-cols-(--cols-md) @max-[512px]:grid-cols-(--cols-sm)',
|
|
61
|
+
'[&>thead]:contents [&>tbody]:contents [&_tr]:col-span-full [&_tr]:grid [&_tr]:grid-cols-subgrid [&_tr]:items-center',
|
|
62
|
+
'[&_thead_tr]:min-h-9 [&_thead_tr]:border-y [&_thead_tr]:border-line [&_thead_tr]:bg-surface-sunk',
|
|
63
|
+
'[&_tbody_tr]:min-h-(--row-height) [&_tbody_tr]:border-b [&_tbody_tr]:border-line [&_tbody_tr:last-child]:border-b-0',
|
|
27
64
|
'[&_tr>*:first-child]:pl-(--card-pad) [&_tr>*:last-child]:pr-(--card-pad)',
|
|
28
65
|
/* The column drawn last takes the card's padding, whichever columns the table's width hides after it (#70). */
|
|
29
66
|
'@max-[1152px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]))]:pr-(--card-pad)',
|
|
30
67
|
'@max-[896px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]):not([data-hide-below=lg]))]:pr-(--card-pad)',
|
|
31
68
|
'@max-[672px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]):not([data-hide-below=lg]):not([data-hide-below=md]))]:pr-(--card-pad)',
|
|
32
69
|
'@max-[512px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below]))]:pr-(--card-pad)',
|
|
33
|
-
/* A text column after a right-aligned number gets room to breathe, so "234ms" and "Example Co" read as two facts. */
|
|
34
|
-
'[&_[data-num]+:not([data-num])]:pl-8',
|
|
35
70
|
className,
|
|
36
71
|
)}
|
|
37
72
|
{...rest}
|
|
@@ -44,19 +79,20 @@ export function Table({ className, caption, children, ...rest }: HTMLAttributes<
|
|
|
44
79
|
}
|
|
45
80
|
|
|
46
81
|
export function TableHead({ className, sticky, ...rest }: HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }) {
|
|
47
|
-
return <thead className={cn(sticky && '[&
|
|
82
|
+
return <thead role="rowgroup" className={cn(sticky && '[&_tr]:sticky [&_tr]:top-0 [&_tr]:z-10', className)} {...rest} />;
|
|
48
83
|
}
|
|
49
84
|
|
|
50
85
|
export function TableBody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
51
|
-
return <tbody {...props} />;
|
|
86
|
+
return <tbody role="rowgroup" {...props} />;
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
export function TableRow({ className, selected, interactive, ...rest }: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean; interactive?: boolean }) {
|
|
55
90
|
return (
|
|
56
91
|
<tr
|
|
92
|
+
role="row"
|
|
57
93
|
aria-selected={selected || undefined}
|
|
58
94
|
className={cn(
|
|
59
|
-
'group/row transition-colors duration-(--dur-hover)
|
|
95
|
+
'group/row transition-colors duration-(--dur-hover)',
|
|
60
96
|
interactive && 'cursor-pointer hover:bg-fill-hover',
|
|
61
97
|
selected && 'bg-accent-tint hover:bg-accent-tint',
|
|
62
98
|
className,
|
|
@@ -82,7 +118,7 @@ export function TableHeader({
|
|
|
82
118
|
hideBelow?: Breakpoint;
|
|
83
119
|
sort?: SortDirection;
|
|
84
120
|
onSort?: () => void;
|
|
85
|
-
/** The record's name: the one column with a floor, so it is the last to be cut.
|
|
121
|
+
/** The record's name: the one column with a floor, so it is the last to be cut. */
|
|
86
122
|
grow?: boolean;
|
|
87
123
|
}) {
|
|
88
124
|
const sortable = onSort !== undefined;
|
|
@@ -90,19 +126,22 @@ export function TableHeader({
|
|
|
90
126
|
return (
|
|
91
127
|
<th
|
|
92
128
|
scope="col"
|
|
129
|
+
role="columnheader"
|
|
93
130
|
data-num={align === 'right' || undefined}
|
|
94
131
|
data-hide-below={hideBelow}
|
|
95
132
|
aria-sort={sortable ? (sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none') : undefined}
|
|
96
133
|
className={cn(
|
|
97
|
-
'
|
|
134
|
+
'px-4 text-xs font-medium whitespace-nowrap text-ink-3',
|
|
98
135
|
ALIGN[align],
|
|
99
136
|
hideBelow && HIDE[hideBelow],
|
|
100
|
-
/* No head claims the spare
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// The floor belongs to the lead column alone (c8eccdc): a second column with a minimum cannot shrink, and
|
|
104
|
-
// Releases table held 837px inside a 713px frame at 1024 and 768 when two
|
|
105
|
-
|
|
137
|
+
/* No head claims the spare: every column is as wide as its content, a cut column's asked for through its cells
|
|
138
|
+
(`Cut`), and the spare sits in the gaps between columns, an equal share in each. A cut column shrinks only when
|
|
139
|
+
the table lacks room, and a head given a width keeps it. */
|
|
140
|
+
// The floor, 160px, belongs to the lead column alone (c8eccdc): a second column with a minimum cannot shrink, and
|
|
141
|
+
// the Releases table held 837px inside a 713px frame at 1024 and 768 when two had one. A floor is also a width the
|
|
142
|
+
// column claims when its names are shorter, so it is no wider than a short name: 256px from a 672px table gave the
|
|
143
|
+
// gap after the lead half as much again as every other gap.
|
|
144
|
+
grow && 'min-w-40',
|
|
106
145
|
className,
|
|
107
146
|
)}
|
|
108
147
|
{...rest}
|
|
@@ -131,9 +170,8 @@ export function TableHeader({
|
|
|
131
170
|
|
|
132
171
|
/**
|
|
133
172
|
* A cut value: it asks for its whole content's width and shrinks only when the table has no room. One grid track of
|
|
134
|
-
* `minmax(0, max-content)` gives the cell that ask
|
|
135
|
-
* the
|
|
136
|
-
* cell's alignment itself.
|
|
173
|
+
* `minmax(0, max-content)` gives the cell that ask and lets it shrink to nothing. The track is as wide as the value and
|
|
174
|
+
* the cell's `text-align` does not move it, so it takes the cell's alignment itself.
|
|
137
175
|
*/
|
|
138
176
|
export function Cut({ align = 'left', children }: { align?: keyof typeof ALIGN; children?: ReactNode }) {
|
|
139
177
|
return (
|
|
@@ -155,6 +193,7 @@ export function TableCell({
|
|
|
155
193
|
}: TdHTMLAttributes<HTMLTableCellElement> & { align?: keyof typeof ALIGN; numeric?: boolean; muted?: boolean; truncate?: boolean; hideBelow?: Breakpoint; children?: ReactNode }) {
|
|
156
194
|
return (
|
|
157
195
|
<td
|
|
196
|
+
role="cell"
|
|
158
197
|
data-num={numeric || align === 'right' || undefined}
|
|
159
198
|
data-hide-below={hideBelow}
|
|
160
199
|
// A cut value is cut everywhere on the page, and this README has always said the cell carries the whole one in its
|
|
@@ -162,7 +201,7 @@ export function TableCell({
|
|
|
162
201
|
// node is its author's, and a `title` passed in wins over this one because the spread comes after.
|
|
163
202
|
title={truncate && (typeof children === 'string' || typeof children === 'number') ? String(children) : undefined}
|
|
164
203
|
className={cn(
|
|
165
|
-
'
|
|
204
|
+
'px-4 py-2',
|
|
166
205
|
ALIGN[align ?? (numeric ? 'right' : 'left')],
|
|
167
206
|
numeric && 't-num whitespace-nowrap',
|
|
168
207
|
muted ? 'text-ink-2' : 'text-ink',
|
|
@@ -198,8 +198,9 @@ export function MembersView({ rows, now, revisions, actions, access, roles, work
|
|
|
198
198
|
},
|
|
199
199
|
// The name is the row's identifier and the table's floor; the email is not a second one, and it is a fixed-width
|
|
200
200
|
// thing (a domain and a local part), so it is drawn whole on one line at the width its longest one needs. Every
|
|
201
|
-
// column
|
|
202
|
-
// column claims the room. Held on one line, the email column is as wide as the longest address and
|
|
201
|
+
// column is as wide as its content and the table puts its spare in the gaps between columns, an equal share in each,
|
|
202
|
+
// so neither column claims the room. Held on one line, the email column is as wide as the longest address and
|
|
203
|
+
// nothing is cut.
|
|
203
204
|
{ key: 'email', header: 'Email', muted: true, hideBelow: 'lg', sortValue: (m) => m.email, cell: (m) => <span className="whitespace-nowrap">{m.email}</span> },
|
|
204
205
|
// The role as a person reads it, add-ons and all, as a column and as a fact on the phone card (AC-3.2).
|
|
205
206
|
{ key: 'role', header: 'Role', hideBelow: 'sm', mobile: 'fact', sortValue: (m) => m.role, cell: (m) => roleLabel(m), mobileCell: (m) => roleLabel(m) },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
|
3
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
4
4
|
|
|
5
5
|
const wrapOf = (td: Element) => td.firstElementChild!.getAttribute('class')?.split(/\s+/) ?? [];
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ describe('a cut cell keeps its alignment', () => {
|
|
|
9
9
|
it('justifies a right-aligned, numeric or centred value and leaves a left one alone', () => {
|
|
10
10
|
const { container } = render(
|
|
11
11
|
<Table>
|
|
12
|
+
<TableHead><TableRow><TableHeader grow>Name</TableHeader><TableHeader align="right">Region</TableHeader><TableHeader align="right">Calls</TableHeader><TableHeader align="center">Tier</TableHeader></TableRow></TableHead>
|
|
12
13
|
<TableBody>
|
|
13
14
|
<TableRow>
|
|
14
15
|
<TableCell truncate>left</TableCell>
|
|
@@ -34,7 +34,7 @@ describe('a cut cell keeps its content width (spec AC-1.4)', () => {
|
|
|
34
34
|
expect(td.getAttribute('title')).toBe('/v1/messages/with/a/long/tail');
|
|
35
35
|
const heads = [...container.querySelectorAll('th')];
|
|
36
36
|
expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
|
|
37
|
-
expect(classes(heads[0])).toEqual(expect.arrayContaining(['min-w-40'
|
|
37
|
+
expect(classes(heads[0])).toEqual(expect.arrayContaining(['min-w-40']));
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -58,8 +58,9 @@ describe('DataTable shares the spare (spec AC-1.4, AC-1.6)', () => {
|
|
|
58
58
|
const heads = [...container.querySelectorAll('thead th')];
|
|
59
59
|
expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
|
|
60
60
|
expect(classes(heads[0])).toContain('w-10');
|
|
61
|
+
expect(heads[0].hasAttribute('data-check')).toBe(true);
|
|
61
62
|
const customer = heads.find((th) => th.textContent?.includes('Customer'))!;
|
|
62
|
-
expect(classes(customer)).toEqual(expect.arrayContaining(['min-w-40'
|
|
63
|
+
expect(classes(customer)).toEqual(expect.arrayContaining(['min-w-40']));
|
|
63
64
|
expect(classes(heads.find((th) => th.textContent?.includes('ID'))!)).toContain('w-28');
|
|
64
65
|
});
|
|
65
66
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
|
|
5
|
+
import { DataTable, type Column } from '@/components/patterns/data-table';
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
7
|
+
|
|
8
|
+
const cols = (table: HTMLElement) => ['--cols', '--cols-xl', '--cols-lg', '--cols-md', '--cols-sm'].map((v) => table.style.getPropertyValue(v));
|
|
9
|
+
|
|
10
|
+
/** The table is a grid of one auto track per column it draws at each width; the spare sits between them. */
|
|
11
|
+
describe('the table counts its columns from its head', () => {
|
|
12
|
+
it('drops a column from the count below its hideBelow step and every narrower one', () => {
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<Table>
|
|
15
|
+
<TableHead>
|
|
16
|
+
<TableRow>
|
|
17
|
+
<TableHeader grow>Request</TableHeader>
|
|
18
|
+
<TableHeader hideBelow="xl">Region</TableHeader>
|
|
19
|
+
<TableHeader hideBelow="md">Customer</TableHeader>
|
|
20
|
+
<TableHeader align="right">Latency</TableHeader>
|
|
21
|
+
</TableRow>
|
|
22
|
+
</TableHead>
|
|
23
|
+
<TableBody><TableRow><TableCell>a</TableCell><TableCell>b</TableCell><TableCell>c</TableCell><TableCell>d</TableCell></TableRow></TableBody>
|
|
24
|
+
</Table>,
|
|
25
|
+
);
|
|
26
|
+
const table = container.querySelector('table')!;
|
|
27
|
+
expect(cols(table)).toEqual(['repeat(4, auto)', 'repeat(3, auto)', 'repeat(3, auto)', 'repeat(2, auto)', 'repeat(2, auto)']);
|
|
28
|
+
expect(table.getAttribute('class')).toContain('justify-between');
|
|
29
|
+
expect(container.querySelector('tbody tr')!.getAttribute('role')).toBe('row');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('counts a headless table from its first row', () => {
|
|
33
|
+
const { container } = render(<Table><TableBody><TableRow><TableCell>a</TableCell><TableCell hideBelow="sm">b</TableCell></TableRow></TableBody></Table>);
|
|
34
|
+
expect(cols(container.querySelector('table')!)).toEqual(['repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(1, auto)']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not count DataTable's selection column, which sits over the lead's padding", () => {
|
|
38
|
+
type Row = { id: string; name: string };
|
|
39
|
+
const columns: Column<Row>[] = [
|
|
40
|
+
{ key: 'name', header: 'Name', grow: true, mobile: 'title', cell: (r) => r.name },
|
|
41
|
+
{ key: 'id', header: 'ID', cell: (r) => r.id },
|
|
42
|
+
];
|
|
43
|
+
const { container } = render(<DataTable caption="Things" noun="things" rows={[{ id: 't1', name: 'One' }]} columns={columns} rowKey={(r) => r.id} selectable selected={new Set()} onSelectedChange={() => {}} />);
|
|
44
|
+
const table = container.querySelector('table')!;
|
|
45
|
+
expect(cols(table)[0]).toBe('repeat(2, auto)');
|
|
46
|
+
expect(container.querySelector('thead th')!.hasAttribute('data-check')).toBe(true);
|
|
47
|
+
expect(container.querySelector('tbody td')!.hasAttribute('data-check')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { browserCeiling, slots } from '../scripts/lib/slots.ts';
|
|
3
|
+
|
|
4
|
+
const GiB = 2 ** 30;
|
|
5
|
+
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
6
|
+
|
|
7
|
+
/** One verify run holds no more browsers at once than its ceiling (#77). */
|
|
8
|
+
describe('the browser ceiling', () => {
|
|
9
|
+
it('is what the machine holds: a browser per two CPUs and per 4 GiB, whichever is fewer, at least one', () => {
|
|
10
|
+
expect(browserCeiling(10, 32 * GiB)).toBe(5);
|
|
11
|
+
expect(browserCeiling(4, 15.6 * GiB)).toBe(2);
|
|
12
|
+
expect(browserCeiling(16, 8 * GiB)).toBe(2);
|
|
13
|
+
expect(browserCeiling(1, 1 * GiB)).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('starts a suite only when the browsers it asks for are free, and frees them however it ends', async () => {
|
|
17
|
+
const hold = slots(3);
|
|
18
|
+
let open = 0;
|
|
19
|
+
let most = 0;
|
|
20
|
+
const suite = (want: number, fail = false) =>
|
|
21
|
+
hold(want, async () => {
|
|
22
|
+
open += Math.min(want, 3);
|
|
23
|
+
most = Math.max(most, open);
|
|
24
|
+
await tick();
|
|
25
|
+
open -= Math.min(want, 3);
|
|
26
|
+
if (fail) throw new Error('red');
|
|
27
|
+
return want;
|
|
28
|
+
});
|
|
29
|
+
const runs = await Promise.allSettled([suite(1, true), suite(1), suite(1), suite(2), suite(9)]);
|
|
30
|
+
expect(most).toBeLessThanOrEqual(3);
|
|
31
|
+
expect(runs.map((r) => r.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled', 'fulfilled']);
|
|
32
|
+
expect(await hold(1, async () => 'free again')).toBe('free again');
|
|
33
|
+
});
|
|
34
|
+
});
|