@tristan2828/ui-foundation 4.42.0 → 4.44.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/README.md +1 -1
- package/conventions/docs/add-an-entity.md +13 -9
- package/conventions/docs/timed-screens.md +9 -6
- package/dist/lib/keep-audio-alive.d.ts +7 -1
- package/dist/lib/keep-audio-alive.js +28 -2
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +68 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ than installing this by hand:
|
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
25
|
-
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady`, `expectDataViewsLoaded` |
|
|
25
|
+
| `@tristan2828/ui-foundation/testing` | Playwright: `test` and `expect` (a page whose `goto()`/`reload()` wait for the app's boot; `withBootWait` for a page of your own), `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady`, `expectDataViewsLoaded` |
|
|
26
26
|
| `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
|
|
27
27
|
| `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
|
|
28
28
|
| `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*`, `/environment` (optional) and the error bodies |
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
6
|
- [The reference files](#the-reference-files) · lines 37–59
|
|
7
|
-
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–
|
|
7
|
+
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–706
|
|
8
8
|
- Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
|
|
9
9
|
- Step 2: `npm run gen:api` · lines 107–108
|
|
10
10
|
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
- Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
|
|
13
13
|
- Step 6: Copy the reference files, one for one, not just the two screens · lines 136–598
|
|
14
14
|
- Step 7: Register routes · lines 599–609
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines 610–
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
19
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
20
|
-
- [Deleting](#deleting) · lines
|
|
21
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
15
|
+
- Step 8: Add Playwright specs for all three screens · lines 610–688
|
|
16
|
+
- Step 9: Register the nav entry and route names · lines 689–703
|
|
17
|
+
- Step 10: `npm run verify`. · lines 704–706
|
|
18
|
+
- [What not to copy](#what-not-to-copy) · lines 708–734
|
|
19
|
+
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 736–753
|
|
20
|
+
- [Deleting](#deleting) · lines 755–784
|
|
21
|
+
- [Fields only a script writes](#fields-only-a-script-writes) · lines 786–807
|
|
22
22
|
|
|
23
23
|
<!-- /index -->
|
|
24
24
|
|
|
@@ -612,7 +612,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
612
612
|
MSW overrides — `forceMswOverride`, `waitForMswReady` and the
|
|
613
613
|
`window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
|
|
614
614
|
`e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts`/
|
|
615
|
-
`e2e/widget-view.spec.ts` for the pattern.
|
|
615
|
+
`e2e/widget-view.spec.ts` for the pattern. Import `test` and
|
|
616
|
+
`expect` from there too, not from `@playwright/test`: its page's
|
|
617
|
+
`goto()` and `reload()` return once the app has rendered (after MSW's
|
|
618
|
+
start), and load once more if the server refused one of its files,
|
|
619
|
+
so a spec's first expect never meets an empty page. The view's states are
|
|
616
620
|
loading, not found (a 404: "Not found", the way back, no retry), error
|
|
617
621
|
(a failed load, then Try again recovering) and success (every section,
|
|
618
622
|
each value as its cell shows it), plus the sparse record showing its
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
- [The view](#the-view) · lines 36–109
|
|
7
7
|
- [The dark stage](#the-dark-stage) · lines 73–109
|
|
8
8
|
- [The clock](#the-clock) · lines 111–141
|
|
9
|
-
- [Sound](#sound) · lines 143–
|
|
10
|
-
- [Background tabs](#background-tabs) · lines
|
|
11
|
-
- [Interruptions](#interruptions) · lines
|
|
12
|
-
- [Motion](#motion) · lines
|
|
13
|
-
- [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines
|
|
9
|
+
- [Sound](#sound) · lines 143–193
|
|
10
|
+
- [Background tabs](#background-tabs) · lines 195–249
|
|
11
|
+
- [Interruptions](#interruptions) · lines 229–249
|
|
12
|
+
- [Motion](#motion) · lines 251–255
|
|
13
|
+
- [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 257–411
|
|
14
14
|
|
|
15
15
|
<!-- /index -->
|
|
16
16
|
|
|
@@ -176,7 +176,10 @@ Browsers block sound until the person has clicked or tapped. So:
|
|
|
176
176
|
another app's music. It returns `{ pause(), stop() }`: `pause()` shows
|
|
177
177
|
Play (without calling `onToggle`), `stop()` ends it (on done, on leaving);
|
|
178
178
|
to start again after `pause()`, call `keepAudioAlive` again from a click.
|
|
179
|
-
Only one runs at a time.
|
|
179
|
+
Only one runs at a time. Where Safari has an audio session
|
|
180
|
+
(`navigator.audioSession`) it's set to `"playback"` while the track
|
|
181
|
+
runs, so the ringer switch doesn't mute the cues either, and put back on
|
|
182
|
+
`stop()`.
|
|
180
183
|
- Every one of them is a no-op where the browser can't make the sound, so
|
|
181
184
|
none needs a check around it.
|
|
182
185
|
- **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
|
|
@@ -22,6 +22,10 @@ export type KeepAudioAliveEnvironment = {
|
|
|
22
22
|
MediaMetadata?: new (init?: MediaMetadataInit) => MediaMetadata;
|
|
23
23
|
createObjectURL?: (blob: Blob) => string;
|
|
24
24
|
revokeObjectURL?: (url: string) => void;
|
|
25
|
+
/** Safari's `navigator.audioSession`, where it has one. */
|
|
26
|
+
audioSession?: {
|
|
27
|
+
type: string;
|
|
28
|
+
};
|
|
25
29
|
};
|
|
26
30
|
/** One second of silent 8-bit mono audio as a WAV file. */
|
|
27
31
|
export declare function silentWav(): Uint8Array;
|
|
@@ -30,6 +34,8 @@ export declare function createKeepAudioAlive(env: KeepAudioAliveEnvironment): ({
|
|
|
30
34
|
* Plays a silent looped track so iOS keeps the page's sound going with the
|
|
31
35
|
* phone locked, with `title` and Pause and Play on the lock screen. Call it
|
|
32
36
|
* from the click that starts the run, behind a setting that's off by default:
|
|
33
|
-
* it pauses other apps' music.
|
|
37
|
+
* it pauses other apps' music. Where Safari has an audio session it's set to
|
|
38
|
+
* "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
|
|
39
|
+
* where the browser can't play audio.
|
|
34
40
|
*/
|
|
35
41
|
export declare const keepAudioAlive: (options: KeepAudioAliveOptions) => KeepAudioAlive;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Keeps a page's audio going with the phone locked (#217). iOS pauses a
|
|
1
|
+
// Keeps a page's audio going with the phone locked (#217, #261). iOS pauses a
|
|
2
2
|
// page's sound on the lock screen unless the page is playing media, so
|
|
3
3
|
// keepAudioAlive() plays a silent looped track, which also puts the page's
|
|
4
4
|
// title and a Pause and Play on the lock screen (the Media Session). It's
|
|
5
5
|
// opt-in and off by default: it takes the device's audio session, which
|
|
6
6
|
// pauses another app's music (docs/foundation/timed-screens.md "Sound").
|
|
7
|
+
// Where Safari has an audio session (navigator.audioSession, iOS 17+) it's
|
|
8
|
+
// set to "playback" while the track runs, so the ringer switch doesn't mute
|
|
9
|
+
// the page's cues either, and put back on stop().
|
|
7
10
|
//
|
|
8
11
|
// The track is a few bytes of silence made here, so there is no file to
|
|
9
12
|
// ship. createKeepAudioAlive takes the browser's APIs, so the rules are
|
|
@@ -55,6 +58,18 @@ export function createKeepAudioAlive(env) {
|
|
|
55
58
|
}
|
|
56
59
|
audio.loop = true;
|
|
57
60
|
const session = env.mediaSession;
|
|
61
|
+
const audioSession = env.audioSession;
|
|
62
|
+
let typeBefore = null;
|
|
63
|
+
try {
|
|
64
|
+
if (audioSession) {
|
|
65
|
+
typeBefore = audioSession.type;
|
|
66
|
+
audioSession.type = 'playback';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// A type this Safari doesn't take: the silent track alone.
|
|
71
|
+
typeBefore = null;
|
|
72
|
+
}
|
|
58
73
|
const play = () => {
|
|
59
74
|
// Refused without a click: the lock screen then shows nothing to keep.
|
|
60
75
|
Promise.resolve(audio.play()).catch(() => { });
|
|
@@ -102,6 +117,14 @@ export function createKeepAudioAlive(env) {
|
|
|
102
117
|
audio.removeAttribute('src');
|
|
103
118
|
audio.load();
|
|
104
119
|
env.revokeObjectURL?.(url);
|
|
120
|
+
if (audioSession && typeBefore !== null) {
|
|
121
|
+
try {
|
|
122
|
+
audioSession.type = typeBefore;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Left as playback: harmless once the track has stopped.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
105
128
|
if (session) {
|
|
106
129
|
handle('play', null);
|
|
107
130
|
handle('pause', null);
|
|
@@ -119,7 +142,9 @@ let shared = null;
|
|
|
119
142
|
* Plays a silent looped track so iOS keeps the page's sound going with the
|
|
120
143
|
* phone locked, with `title` and Pause and Play on the lock screen. Call it
|
|
121
144
|
* from the click that starts the run, behind a setting that's off by default:
|
|
122
|
-
* it pauses other apps' music.
|
|
145
|
+
* it pauses other apps' music. Where Safari has an audio session it's set to
|
|
146
|
+
* "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
|
|
147
|
+
* where the browser can't play audio.
|
|
123
148
|
*/
|
|
124
149
|
export const keepAudioAlive = (options) => {
|
|
125
150
|
if (!shared) {
|
|
@@ -130,6 +155,7 @@ export const keepAudioAlive = (options) => {
|
|
|
130
155
|
MediaMetadata: hasWindow ? window.MediaMetadata : undefined,
|
|
131
156
|
createObjectURL: typeof URL !== 'undefined' ? (blob) => URL.createObjectURL(blob) : undefined,
|
|
132
157
|
revokeObjectURL: typeof URL !== 'undefined' ? (url) => URL.revokeObjectURL(url) : undefined,
|
|
158
|
+
audioSession: typeof navigator !== 'undefined' ? navigator.audioSession : undefined,
|
|
133
159
|
});
|
|
134
160
|
}
|
|
135
161
|
return shared(options);
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import { type Page } from '@playwright/test';
|
|
1
|
+
import { expect, type Page } from '@playwright/test';
|
|
2
2
|
import { type NavEntry } from '../components/app/app-shell-nav.js';
|
|
3
3
|
import type { MswOverride } from '../mocks/override.js';
|
|
4
4
|
export type { MswHandle, MswOverride } from '../mocks/override.js';
|
|
5
|
+
export { expect };
|
|
6
|
+
/**
|
|
7
|
+
* Makes the page's `goto()` and `reload()` return once the app has
|
|
8
|
+
* booted (its first render into `#root`), not at the load event, and load
|
|
9
|
+
* the page once more when one of the app's own files is refused before
|
|
10
|
+
* then; a second refusal fails naming the file. The wait is bounded by
|
|
11
|
+
* the test's timeout like any navigation, and the page's own expects keep
|
|
12
|
+
* their time from there. The `test` exported here applies it to every
|
|
13
|
+
* test's `page`; this is for a page an app's own fixture opens.
|
|
14
|
+
*/
|
|
15
|
+
export declare function withBootWait(page: Page): Page;
|
|
16
|
+
/**
|
|
17
|
+
* Playwright's `test`, with a `page` whose `goto()` and `reload()` wait
|
|
18
|
+
* for the app's boot (`withBootWait`). Import `test` and `expect` from
|
|
19
|
+
* here instead of '@playwright/test'; the suites below use it too.
|
|
20
|
+
*/
|
|
21
|
+
export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
|
|
5
22
|
/**
|
|
6
23
|
* Forces a response for the page's *first* load: set before navigation, so
|
|
7
24
|
* it wins the race against the app's own first fetch. For states forced
|
package/dist/testing/index.js
CHANGED
|
@@ -12,8 +12,75 @@
|
|
|
12
12
|
// Runs in Node, inside Playwright: @playwright/test and @axe-core/playwright
|
|
13
13
|
// are optional peer dependencies, needed only by apps that import this.
|
|
14
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
15
|
-
import { expect, test } from '@playwright/test';
|
|
15
|
+
import { expect, test as base } from '@playwright/test';
|
|
16
16
|
import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
|
|
17
|
+
export { expect };
|
|
18
|
+
// A page load returns at the load event, but the app isn't up yet: React
|
|
19
|
+
// renders only after MSW's worker.start(), which took up to 2.4 s past
|
|
20
|
+
// load in a busy run, and a test's first 5 s expect then failed on an
|
|
21
|
+
// empty page. Under the same load `vite preview` sometimes refuses a
|
|
22
|
+
// connection (about 1 page in 1500): one of the entry's chunks fails, the
|
|
23
|
+
// browser never retries a module script, and the page stays empty until
|
|
24
|
+
// the test's timeout. Both were seen as "never rendered" in every suite.
|
|
25
|
+
const REFUSED = /ERR_CONNECTION_(REFUSED|RESET)/;
|
|
26
|
+
// The app's first render into the template's `<div id="root">`, which is
|
|
27
|
+
// the boot in a mock build (after MSW's start) and a real one alike. A
|
|
28
|
+
// page without one (another site, about:blank) has nothing to wait for.
|
|
29
|
+
const rendered = (page) => page.waitForFunction(() => {
|
|
30
|
+
const root = document.getElementById('root');
|
|
31
|
+
return !root || root.hasChildNodes();
|
|
32
|
+
});
|
|
33
|
+
async function boot(page, load, again) {
|
|
34
|
+
for (let attempt = 1;; attempt++) {
|
|
35
|
+
let onFailed = () => { };
|
|
36
|
+
const refused = new Promise((resolve) => {
|
|
37
|
+
onFailed = (request) => {
|
|
38
|
+
const url = new URL(request.url());
|
|
39
|
+
const own = url.origin === new URL(page.url()).origin;
|
|
40
|
+
if (own && REFUSED.test(request.failure()?.errorText ?? ''))
|
|
41
|
+
resolve(url.pathname);
|
|
42
|
+
};
|
|
43
|
+
page.on('requestfailed', onFailed);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const response = await (attempt === 1 ? load() : again());
|
|
47
|
+
const file = await Promise.race([rendered(page).then(() => null), refused]);
|
|
48
|
+
if (file === null)
|
|
49
|
+
return response;
|
|
50
|
+
if (attempt === 2)
|
|
51
|
+
throw new Error(`The server refused ${file} twice: the app never booted`);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
page.off('requestfailed', onFailed);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Makes the page's `goto()` and `reload()` return once the app has
|
|
60
|
+
* booted (its first render into `#root`), not at the load event, and load
|
|
61
|
+
* the page once more when one of the app's own files is refused before
|
|
62
|
+
* then; a second refusal fails naming the file. The wait is bounded by
|
|
63
|
+
* the test's timeout like any navigation, and the page's own expects keep
|
|
64
|
+
* their time from there. The `test` exported here applies it to every
|
|
65
|
+
* test's `page`; this is for a page an app's own fixture opens.
|
|
66
|
+
*/
|
|
67
|
+
export function withBootWait(page) {
|
|
68
|
+
const goto = page.goto.bind(page);
|
|
69
|
+
const reload = page.reload.bind(page);
|
|
70
|
+
page.goto = (url, options) => boot(page, () => goto(url, options), () => reload(options));
|
|
71
|
+
page.reload = (options) => boot(page, () => reload(options), () => reload(options));
|
|
72
|
+
return page;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Playwright's `test`, with a `page` whose `goto()` and `reload()` wait
|
|
76
|
+
* for the app's boot (`withBootWait`). Import `test` and `expect` from
|
|
77
|
+
* here instead of '@playwright/test'; the suites below use it too.
|
|
78
|
+
*/
|
|
79
|
+
export const test = base.extend({
|
|
80
|
+
page: async ({ page }, provide) => {
|
|
81
|
+
await provide(withBootWait(page));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
17
84
|
/**
|
|
18
85
|
* Forces a response for the page's *first* load: set before navigation, so
|
|
19
86
|
* it wins the race against the app's own first fetch. For states forced
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.44.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|