@etamong-playground/ui 0.34.2
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/LICENSE +21 -0
- package/README.md +1302 -0
- package/dist/helpers.cjs +137 -0
- package/dist/helpers.d.cts +96 -0
- package/dist/helpers.d.ts +96 -0
- package/dist/helpers.js +118 -0
- package/dist/index.cjs +3684 -0
- package/dist/index.d.cts +1800 -0
- package/dist/index.d.ts +1800 -0
- package/dist/index.js +3585 -0
- package/dist/styles.css +2312 -0
- package/dist/testing.cjs +185 -0
- package/dist/testing.d.cts +166 -0
- package/dist/testing.d.ts +166 -0
- package/dist/testing.js +173 -0
- package/package.json +75 -0
package/dist/testing.cjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var msw = require('msw');
|
|
4
|
+
var test = require('@playwright/test');
|
|
5
|
+
|
|
6
|
+
// src/testing-viewport-fit.ts
|
|
7
|
+
var VIEWPORT_FIT_WARN_PREFIX = "[@etamong-playground/ui]";
|
|
8
|
+
var FLEET_VIEWPORT_PROFILES = [
|
|
9
|
+
{
|
|
10
|
+
name: "iphone-17-pro-pwa",
|
|
11
|
+
viewport: { width: 402, height: 874 },
|
|
12
|
+
standalone: true,
|
|
13
|
+
safeAreaTop: 59,
|
|
14
|
+
safeAreaBottom: 34
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "ipad-mini-portrait",
|
|
18
|
+
viewport: { width: 768, height: 1024 }
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "desktop-1280",
|
|
22
|
+
viewport: { width: 1280, height: 800 }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "android-narrow",
|
|
26
|
+
viewport: { width: 360, height: 780 }
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
async function assertViewportFit(page, urls, options = {}) {
|
|
30
|
+
const profiles = options.profiles && options.profiles.length ? options.profiles : FLEET_VIEWPORT_PROFILES;
|
|
31
|
+
const settleMs = options.settleMs ?? 400;
|
|
32
|
+
const predicate = options.predicate ?? ((msg) => msg.type() === "warning" && msg.text().startsWith(VIEWPORT_FIT_WARN_PREFIX));
|
|
33
|
+
const collected = [];
|
|
34
|
+
const onConsole = (msg) => {
|
|
35
|
+
if (predicate(msg)) collected.push(msg.text());
|
|
36
|
+
};
|
|
37
|
+
page.on("console", onConsole);
|
|
38
|
+
try {
|
|
39
|
+
for (const profile of profiles) {
|
|
40
|
+
await page.setViewportSize(profile.viewport);
|
|
41
|
+
if (profile.standalone) {
|
|
42
|
+
await page.emulateMedia({ media: "screen", colorScheme: "no-preference" });
|
|
43
|
+
await page.addInitScript(() => {
|
|
44
|
+
const origMatchMedia = window.matchMedia;
|
|
45
|
+
window.matchMedia = (q) => {
|
|
46
|
+
if (q.includes("display-mode: standalone")) {
|
|
47
|
+
return {
|
|
48
|
+
matches: true,
|
|
49
|
+
media: q,
|
|
50
|
+
onchange: null,
|
|
51
|
+
addListener: () => {
|
|
52
|
+
},
|
|
53
|
+
removeListener: () => {
|
|
54
|
+
},
|
|
55
|
+
addEventListener: () => {
|
|
56
|
+
},
|
|
57
|
+
removeEventListener: () => {
|
|
58
|
+
},
|
|
59
|
+
dispatchEvent: () => true
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return origMatchMedia.call(window, q);
|
|
63
|
+
};
|
|
64
|
+
Object.defineProperty(navigator, "standalone", {
|
|
65
|
+
configurable: true,
|
|
66
|
+
value: true
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (profile.safeAreaTop != null || profile.safeAreaBottom != null) {
|
|
71
|
+
const top = profile.safeAreaTop ?? 0;
|
|
72
|
+
const bot = profile.safeAreaBottom ?? 0;
|
|
73
|
+
await page.addInitScript(
|
|
74
|
+
(insets) => {
|
|
75
|
+
const style = document.createElement("style");
|
|
76
|
+
style.textContent = `:root {
|
|
77
|
+
--etu-test-safe-top: ${insets.top}px;
|
|
78
|
+
--etu-test-safe-bot: ${insets.bot}px;
|
|
79
|
+
}
|
|
80
|
+
/* Stub env() so the browser sees a real inset value during
|
|
81
|
+
headless testing. CSS @supports cannot polyfill env(), so
|
|
82
|
+
apps that want this assertion to run under emulated
|
|
83
|
+
notched insets must use the .etu-test-safe-top/bot custom
|
|
84
|
+
properties instead of env() in tests \u2014 or apps can keep
|
|
85
|
+
env() and accept that desktop Chromium reports 0. */
|
|
86
|
+
`;
|
|
87
|
+
document.head.appendChild(style);
|
|
88
|
+
},
|
|
89
|
+
{ top, bot }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
for (const url of urls) {
|
|
93
|
+
collected.length = 0;
|
|
94
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
95
|
+
await page.waitForTimeout(settleMs);
|
|
96
|
+
if (collected.length > 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`viewport-fit assertion failed at ${profile.name} on ${url}:
|
|
99
|
+
` + collected.map((l) => " - " + l).join("\n")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
page.off("console", onConsole);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function defineViewportFitTests(test, options) {
|
|
109
|
+
const profiles = options.profiles && options.profiles.length ? options.profiles : FLEET_VIEWPORT_PROFILES;
|
|
110
|
+
const prefix = options.titlePrefix ?? "viewport-fit";
|
|
111
|
+
for (const profile of profiles) {
|
|
112
|
+
test(`${prefix} \u2014 ${profile.name}`, async (args) => {
|
|
113
|
+
if (options.beforeEach) await options.beforeEach(args);
|
|
114
|
+
await assertViewportFit(args.page, options.urls, {
|
|
115
|
+
profiles: [profile]
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function mockHttperrRef() {
|
|
121
|
+
const b = new Uint8Array(4);
|
|
122
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
123
|
+
crypto.getRandomValues(b);
|
|
124
|
+
} else {
|
|
125
|
+
for (let i = 0; i < 4; i++) b[i] = Math.floor(Math.random() * 256);
|
|
126
|
+
}
|
|
127
|
+
return Array.from(b).map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
128
|
+
}
|
|
129
|
+
function httperrBody(message, ref) {
|
|
130
|
+
return { error: message, ref: ref ?? mockHttperrRef() };
|
|
131
|
+
}
|
|
132
|
+
function createMeHandler(me = {}, path = "/api/v1/me") {
|
|
133
|
+
const body = {
|
|
134
|
+
email: me.email ?? "you@etamong.com",
|
|
135
|
+
is_admin: me.is_admin ?? false,
|
|
136
|
+
via_token: me.via_token ?? false,
|
|
137
|
+
can_create_apps: me.can_create_apps ?? false
|
|
138
|
+
};
|
|
139
|
+
return msw.http.get(path, () => msw.HttpResponse.json(body));
|
|
140
|
+
}
|
|
141
|
+
function createMeSignedOutHandler(path = "/api/v1/me") {
|
|
142
|
+
return msw.http.get(
|
|
143
|
+
path,
|
|
144
|
+
() => msw.HttpResponse.json(httperrBody("not signed in"), { status: 401 })
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
function createHealthzHandler(path = "/healthz") {
|
|
148
|
+
return msw.http.get(path, () => new msw.HttpResponse("ok", { status: 200 }));
|
|
149
|
+
}
|
|
150
|
+
function defaultMockHandlers(me = {}, paths = {}) {
|
|
151
|
+
return [createMeHandler(me, paths.me), createHealthzHandler(paths.healthz)];
|
|
152
|
+
}
|
|
153
|
+
var fleetTest = test.test.extend({
|
|
154
|
+
// eslint-disable-next-line no-empty-pattern
|
|
155
|
+
fleetPage: async ({ page }, use) => {
|
|
156
|
+
await use(page);
|
|
157
|
+
},
|
|
158
|
+
page: async ({ browser }, use) => {
|
|
159
|
+
const context = await browser.newContext({
|
|
160
|
+
locale: "ko-KR",
|
|
161
|
+
timezoneId: "Asia/Seoul",
|
|
162
|
+
viewport: { width: 1280, height: 800 },
|
|
163
|
+
extraHTTPHeaders: { "Accept-Language": "ko-KR" }
|
|
164
|
+
});
|
|
165
|
+
const page = await context.newPage();
|
|
166
|
+
try {
|
|
167
|
+
await use(page);
|
|
168
|
+
} finally {
|
|
169
|
+
await page.close();
|
|
170
|
+
await context.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
exports.FLEET_VIEWPORT_PROFILES = FLEET_VIEWPORT_PROFILES;
|
|
176
|
+
exports.VIEWPORT_FIT_WARN_PREFIX = VIEWPORT_FIT_WARN_PREFIX;
|
|
177
|
+
exports.assertViewportFit = assertViewportFit;
|
|
178
|
+
exports.createHealthzHandler = createHealthzHandler;
|
|
179
|
+
exports.createMeHandler = createMeHandler;
|
|
180
|
+
exports.createMeSignedOutHandler = createMeSignedOutHandler;
|
|
181
|
+
exports.defaultMockHandlers = defaultMockHandlers;
|
|
182
|
+
exports.defineViewportFitTests = defineViewportFitTests;
|
|
183
|
+
exports.fleetTest = fleetTest;
|
|
184
|
+
exports.httperrBody = httperrBody;
|
|
185
|
+
exports.mockHttperrRef = mockHttperrRef;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as _playwright_test from '@playwright/test';
|
|
2
|
+
import { ConsoleMessage, PlaywrightTestArgs, Page } from '@playwright/test';
|
|
3
|
+
export { Page, PlaywrightTestArgs, PlaywrightTestConfig } from '@playwright/test';
|
|
4
|
+
import { HttpHandler } from 'msw';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Playwright helpers for the fleet viewport-fit assertions.
|
|
8
|
+
*
|
|
9
|
+
* The chrome primitives (`<NavigationBar>` v0.28.1+, MobileTabBar/Sidebar
|
|
10
|
+
* to follow) emit dev-mode `console.warn` lines prefixed with
|
|
11
|
+
* `[@etamong-playground/ui]` when layout breaks (off-screen bar, shrunk title,
|
|
12
|
+
* stacked safe-area-top). This module lifts the dev-mode signal into a
|
|
13
|
+
* CI gate.
|
|
14
|
+
*
|
|
15
|
+
* 1. Apps add a Playwright spec that calls `assertViewportFit(page,
|
|
16
|
+
* urls)`.
|
|
17
|
+
* 2. CI runs it at each fleet device profile (mobile/tablet/desktop +
|
|
18
|
+
* iPhone-Pro-PWA).
|
|
19
|
+
* 3. Any `[@etamong-playground/ui]` warning fails the test with the original
|
|
20
|
+
* message — so the diagnosis is the same one the developer would
|
|
21
|
+
* have seen at localhost.
|
|
22
|
+
*
|
|
23
|
+
* Peer dependency: `@playwright/test` (peer, not bundled). Apps that
|
|
24
|
+
* don't run e2e simply don't import this entry; nothing is pulled.
|
|
25
|
+
*
|
|
26
|
+
* See planning wiki: concepts/viewport-fit-assertions.md.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Marker the chrome primitives prefix every layout warning with. */
|
|
30
|
+
declare const VIEWPORT_FIT_WARN_PREFIX = "[@etamong-playground/ui]";
|
|
31
|
+
/**
|
|
32
|
+
* Canonical device profiles to drive viewport-fit checks at. These
|
|
33
|
+
* cover the four corners the regression has historically slipped past:
|
|
34
|
+
* iPhone Pro PWA (notch, narrow), iPad Mini (tablet rail boundary),
|
|
35
|
+
* desktop (sidebar), small Android (mobile tab bar).
|
|
36
|
+
*
|
|
37
|
+
* Names match Playwright's `devices` map where possible so apps can
|
|
38
|
+
* also reuse the profile directly.
|
|
39
|
+
*/
|
|
40
|
+
interface DeviceProfile {
|
|
41
|
+
name: string;
|
|
42
|
+
viewport: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
/** When true, set the page to standalone-display-mode for iOS PWA. */
|
|
47
|
+
standalone?: boolean;
|
|
48
|
+
/** Emulate notched device safe-area-insets via a meta tag patch. */
|
|
49
|
+
safeAreaTop?: number;
|
|
50
|
+
safeAreaBottom?: number;
|
|
51
|
+
}
|
|
52
|
+
declare const FLEET_VIEWPORT_PROFILES: DeviceProfile[];
|
|
53
|
+
interface AssertViewportFitOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Profiles to iterate. Default: `FLEET_VIEWPORT_PROFILES`. Pass an
|
|
56
|
+
* empty array to run only the currently-set page viewport.
|
|
57
|
+
*/
|
|
58
|
+
profiles?: DeviceProfile[];
|
|
59
|
+
/**
|
|
60
|
+
* Extra time (ms) to settle after navigation before reading the
|
|
61
|
+
* captured warnings. Default 400 — gives the primitive's two
|
|
62
|
+
* `requestAnimationFrame` ticks plus a buffer for `env()` to apply.
|
|
63
|
+
*/
|
|
64
|
+
settleMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Additional console-message predicate. Default matches anything
|
|
67
|
+
* starting with `[@etamong-playground/ui]`.
|
|
68
|
+
*/
|
|
69
|
+
predicate?: (msg: ConsoleMessage) => boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Open each URL at each profile and assert no chrome primitive emitted
|
|
73
|
+
* a `[@etamong-playground/ui]` warning. The first violation throws with the
|
|
74
|
+
* original message — the failing test's stack carries the URL + profile.
|
|
75
|
+
*/
|
|
76
|
+
declare function assertViewportFit(page: Page, urls: string[], options?: AssertViewportFitOptions): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Convenience: a Playwright test that wraps `assertViewportFit` and
|
|
79
|
+
* fans out across profiles as separate test cases (so the failure
|
|
80
|
+
* report names the profile). Apps wire this in their `tests/` dir as:
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* import { defineViewportFitTests } from "@etamong-playground/ui/testing";
|
|
84
|
+
* defineViewportFitTests({
|
|
85
|
+
* urls: ["/", "/settings", "/me"],
|
|
86
|
+
* beforeEach: async ({ page }) => { /* auth, mocks */ interface DefineViewportFitTestsOptions {
|
|
87
|
+
urls: string[];
|
|
88
|
+
profiles?: DeviceProfile[];
|
|
89
|
+
beforeEach?: (args: PlaywrightTestArgs) => Promise<void> | void;
|
|
90
|
+
/** Optional title prefix. Default: "viewport-fit". */
|
|
91
|
+
titlePrefix?: string;
|
|
92
|
+
}
|
|
93
|
+
declare function defineViewportFitTests(test: any, options: DefineViewportFitTestsOptions): void;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cross-app MSW helpers. Optional — apps that use a different mock layer
|
|
97
|
+
* don't pull `msw` in (it's a peer dep).
|
|
98
|
+
*
|
|
99
|
+
* Two contracts every etamong-lab webui shares:
|
|
100
|
+
*
|
|
101
|
+
* 1. `/me` from oauth2-proxy/Authentik returns `{email, is_admin, ...}`.
|
|
102
|
+
* 2. Errors carry `{error, ref}` per `@etamong-lab/httperr`. Tests want a
|
|
103
|
+
* one-liner to produce a realistic failure.
|
|
104
|
+
*
|
|
105
|
+
* Both live here so a per-app `handlers.ts` only encodes the app's own
|
|
106
|
+
* surface, not these standard shapes.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/** Random 8-hex ref, matching the httperr server-side format. */
|
|
110
|
+
declare function mockHttperrRef(): string;
|
|
111
|
+
/**
|
|
112
|
+
* httperr-shaped error body. Pass to `HttpResponse.json` with the desired
|
|
113
|
+
* status so MSW returns the same shape as the real apiserver.
|
|
114
|
+
*/
|
|
115
|
+
declare function httperrBody(message: string, ref?: string): {
|
|
116
|
+
error: string;
|
|
117
|
+
ref: string;
|
|
118
|
+
};
|
|
119
|
+
interface MockMe {
|
|
120
|
+
email?: string;
|
|
121
|
+
is_admin?: boolean;
|
|
122
|
+
via_token?: boolean;
|
|
123
|
+
can_create_apps?: boolean;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `/me` handler at the standard fleet path. Pass any subset of fields; the
|
|
127
|
+
* rest fall back to sensible test defaults.
|
|
128
|
+
*/
|
|
129
|
+
declare function createMeHandler(me?: MockMe, path?: string): HttpHandler;
|
|
130
|
+
/**
|
|
131
|
+
* `/me` that returns the oauth2-proxy "not signed in" 401, so the webui's
|
|
132
|
+
* sign-in flow renders.
|
|
133
|
+
*/
|
|
134
|
+
declare function createMeSignedOutHandler(path?: string): HttpHandler;
|
|
135
|
+
/**
|
|
136
|
+
* Default healthz — every fleet apiserver exposes one and tests often hit it
|
|
137
|
+
* in setup teardown.
|
|
138
|
+
*/
|
|
139
|
+
declare function createHealthzHandler(path?: string): HttpHandler;
|
|
140
|
+
/**
|
|
141
|
+
* Convenience bundle: `/me` (signed-in by default) + `/healthz`. The first
|
|
142
|
+
* call site usually wants both; pass overrides as needed.
|
|
143
|
+
*/
|
|
144
|
+
declare function defaultMockHandlers(me?: MockMe, paths?: {
|
|
145
|
+
me?: string;
|
|
146
|
+
healthz?: string;
|
|
147
|
+
}): HttpHandler[];
|
|
148
|
+
|
|
149
|
+
interface FleetFixtures {
|
|
150
|
+
/** Hook called once per test; passed the page after locale/viewport setup. */
|
|
151
|
+
fleetPage: Page;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Extended Playwright `test` that:
|
|
155
|
+
* - sets `Accept-Language: ko-KR` and locale ko-KR (matches the production
|
|
156
|
+
* i18n default per planning concept fleet-language-policy)
|
|
157
|
+
* - sets timezone to Asia/Seoul so date assertions are stable
|
|
158
|
+
* - sets a 1280×800 desktop viewport unless the test overrides it
|
|
159
|
+
*
|
|
160
|
+
* Use:
|
|
161
|
+
* import { fleetTest as test } from "@etamong-playground/ui/testing";
|
|
162
|
+
* test("works", async ({ page }) => { ... });
|
|
163
|
+
*/
|
|
164
|
+
declare const fleetTest: _playwright_test.TestType<PlaywrightTestArgs & _playwright_test.PlaywrightTestOptions & FleetFixtures, _playwright_test.PlaywrightWorkerArgs & _playwright_test.PlaywrightWorkerOptions>;
|
|
165
|
+
|
|
166
|
+
export { type AssertViewportFitOptions, type DefineViewportFitTestsOptions, type DeviceProfile, FLEET_VIEWPORT_PROFILES, type FleetFixtures, type MockMe, VIEWPORT_FIT_WARN_PREFIX, assertViewportFit, createHealthzHandler, createMeHandler, createMeSignedOutHandler, defaultMockHandlers, defineViewportFitTests, fleetTest, httperrBody, mockHttperrRef };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as _playwright_test from '@playwright/test';
|
|
2
|
+
import { ConsoleMessage, PlaywrightTestArgs, Page } from '@playwright/test';
|
|
3
|
+
export { Page, PlaywrightTestArgs, PlaywrightTestConfig } from '@playwright/test';
|
|
4
|
+
import { HttpHandler } from 'msw';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Playwright helpers for the fleet viewport-fit assertions.
|
|
8
|
+
*
|
|
9
|
+
* The chrome primitives (`<NavigationBar>` v0.28.1+, MobileTabBar/Sidebar
|
|
10
|
+
* to follow) emit dev-mode `console.warn` lines prefixed with
|
|
11
|
+
* `[@etamong-playground/ui]` when layout breaks (off-screen bar, shrunk title,
|
|
12
|
+
* stacked safe-area-top). This module lifts the dev-mode signal into a
|
|
13
|
+
* CI gate.
|
|
14
|
+
*
|
|
15
|
+
* 1. Apps add a Playwright spec that calls `assertViewportFit(page,
|
|
16
|
+
* urls)`.
|
|
17
|
+
* 2. CI runs it at each fleet device profile (mobile/tablet/desktop +
|
|
18
|
+
* iPhone-Pro-PWA).
|
|
19
|
+
* 3. Any `[@etamong-playground/ui]` warning fails the test with the original
|
|
20
|
+
* message — so the diagnosis is the same one the developer would
|
|
21
|
+
* have seen at localhost.
|
|
22
|
+
*
|
|
23
|
+
* Peer dependency: `@playwright/test` (peer, not bundled). Apps that
|
|
24
|
+
* don't run e2e simply don't import this entry; nothing is pulled.
|
|
25
|
+
*
|
|
26
|
+
* See planning wiki: concepts/viewport-fit-assertions.md.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Marker the chrome primitives prefix every layout warning with. */
|
|
30
|
+
declare const VIEWPORT_FIT_WARN_PREFIX = "[@etamong-playground/ui]";
|
|
31
|
+
/**
|
|
32
|
+
* Canonical device profiles to drive viewport-fit checks at. These
|
|
33
|
+
* cover the four corners the regression has historically slipped past:
|
|
34
|
+
* iPhone Pro PWA (notch, narrow), iPad Mini (tablet rail boundary),
|
|
35
|
+
* desktop (sidebar), small Android (mobile tab bar).
|
|
36
|
+
*
|
|
37
|
+
* Names match Playwright's `devices` map where possible so apps can
|
|
38
|
+
* also reuse the profile directly.
|
|
39
|
+
*/
|
|
40
|
+
interface DeviceProfile {
|
|
41
|
+
name: string;
|
|
42
|
+
viewport: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
/** When true, set the page to standalone-display-mode for iOS PWA. */
|
|
47
|
+
standalone?: boolean;
|
|
48
|
+
/** Emulate notched device safe-area-insets via a meta tag patch. */
|
|
49
|
+
safeAreaTop?: number;
|
|
50
|
+
safeAreaBottom?: number;
|
|
51
|
+
}
|
|
52
|
+
declare const FLEET_VIEWPORT_PROFILES: DeviceProfile[];
|
|
53
|
+
interface AssertViewportFitOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Profiles to iterate. Default: `FLEET_VIEWPORT_PROFILES`. Pass an
|
|
56
|
+
* empty array to run only the currently-set page viewport.
|
|
57
|
+
*/
|
|
58
|
+
profiles?: DeviceProfile[];
|
|
59
|
+
/**
|
|
60
|
+
* Extra time (ms) to settle after navigation before reading the
|
|
61
|
+
* captured warnings. Default 400 — gives the primitive's two
|
|
62
|
+
* `requestAnimationFrame` ticks plus a buffer for `env()` to apply.
|
|
63
|
+
*/
|
|
64
|
+
settleMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Additional console-message predicate. Default matches anything
|
|
67
|
+
* starting with `[@etamong-playground/ui]`.
|
|
68
|
+
*/
|
|
69
|
+
predicate?: (msg: ConsoleMessage) => boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Open each URL at each profile and assert no chrome primitive emitted
|
|
73
|
+
* a `[@etamong-playground/ui]` warning. The first violation throws with the
|
|
74
|
+
* original message — the failing test's stack carries the URL + profile.
|
|
75
|
+
*/
|
|
76
|
+
declare function assertViewportFit(page: Page, urls: string[], options?: AssertViewportFitOptions): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Convenience: a Playwright test that wraps `assertViewportFit` and
|
|
79
|
+
* fans out across profiles as separate test cases (so the failure
|
|
80
|
+
* report names the profile). Apps wire this in their `tests/` dir as:
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* import { defineViewportFitTests } from "@etamong-playground/ui/testing";
|
|
84
|
+
* defineViewportFitTests({
|
|
85
|
+
* urls: ["/", "/settings", "/me"],
|
|
86
|
+
* beforeEach: async ({ page }) => { /* auth, mocks */ interface DefineViewportFitTestsOptions {
|
|
87
|
+
urls: string[];
|
|
88
|
+
profiles?: DeviceProfile[];
|
|
89
|
+
beforeEach?: (args: PlaywrightTestArgs) => Promise<void> | void;
|
|
90
|
+
/** Optional title prefix. Default: "viewport-fit". */
|
|
91
|
+
titlePrefix?: string;
|
|
92
|
+
}
|
|
93
|
+
declare function defineViewportFitTests(test: any, options: DefineViewportFitTestsOptions): void;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cross-app MSW helpers. Optional — apps that use a different mock layer
|
|
97
|
+
* don't pull `msw` in (it's a peer dep).
|
|
98
|
+
*
|
|
99
|
+
* Two contracts every etamong-lab webui shares:
|
|
100
|
+
*
|
|
101
|
+
* 1. `/me` from oauth2-proxy/Authentik returns `{email, is_admin, ...}`.
|
|
102
|
+
* 2. Errors carry `{error, ref}` per `@etamong-lab/httperr`. Tests want a
|
|
103
|
+
* one-liner to produce a realistic failure.
|
|
104
|
+
*
|
|
105
|
+
* Both live here so a per-app `handlers.ts` only encodes the app's own
|
|
106
|
+
* surface, not these standard shapes.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/** Random 8-hex ref, matching the httperr server-side format. */
|
|
110
|
+
declare function mockHttperrRef(): string;
|
|
111
|
+
/**
|
|
112
|
+
* httperr-shaped error body. Pass to `HttpResponse.json` with the desired
|
|
113
|
+
* status so MSW returns the same shape as the real apiserver.
|
|
114
|
+
*/
|
|
115
|
+
declare function httperrBody(message: string, ref?: string): {
|
|
116
|
+
error: string;
|
|
117
|
+
ref: string;
|
|
118
|
+
};
|
|
119
|
+
interface MockMe {
|
|
120
|
+
email?: string;
|
|
121
|
+
is_admin?: boolean;
|
|
122
|
+
via_token?: boolean;
|
|
123
|
+
can_create_apps?: boolean;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `/me` handler at the standard fleet path. Pass any subset of fields; the
|
|
127
|
+
* rest fall back to sensible test defaults.
|
|
128
|
+
*/
|
|
129
|
+
declare function createMeHandler(me?: MockMe, path?: string): HttpHandler;
|
|
130
|
+
/**
|
|
131
|
+
* `/me` that returns the oauth2-proxy "not signed in" 401, so the webui's
|
|
132
|
+
* sign-in flow renders.
|
|
133
|
+
*/
|
|
134
|
+
declare function createMeSignedOutHandler(path?: string): HttpHandler;
|
|
135
|
+
/**
|
|
136
|
+
* Default healthz — every fleet apiserver exposes one and tests often hit it
|
|
137
|
+
* in setup teardown.
|
|
138
|
+
*/
|
|
139
|
+
declare function createHealthzHandler(path?: string): HttpHandler;
|
|
140
|
+
/**
|
|
141
|
+
* Convenience bundle: `/me` (signed-in by default) + `/healthz`. The first
|
|
142
|
+
* call site usually wants both; pass overrides as needed.
|
|
143
|
+
*/
|
|
144
|
+
declare function defaultMockHandlers(me?: MockMe, paths?: {
|
|
145
|
+
me?: string;
|
|
146
|
+
healthz?: string;
|
|
147
|
+
}): HttpHandler[];
|
|
148
|
+
|
|
149
|
+
interface FleetFixtures {
|
|
150
|
+
/** Hook called once per test; passed the page after locale/viewport setup. */
|
|
151
|
+
fleetPage: Page;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Extended Playwright `test` that:
|
|
155
|
+
* - sets `Accept-Language: ko-KR` and locale ko-KR (matches the production
|
|
156
|
+
* i18n default per planning concept fleet-language-policy)
|
|
157
|
+
* - sets timezone to Asia/Seoul so date assertions are stable
|
|
158
|
+
* - sets a 1280×800 desktop viewport unless the test overrides it
|
|
159
|
+
*
|
|
160
|
+
* Use:
|
|
161
|
+
* import { fleetTest as test } from "@etamong-playground/ui/testing";
|
|
162
|
+
* test("works", async ({ page }) => { ... });
|
|
163
|
+
*/
|
|
164
|
+
declare const fleetTest: _playwright_test.TestType<PlaywrightTestArgs & _playwright_test.PlaywrightTestOptions & FleetFixtures, _playwright_test.PlaywrightWorkerArgs & _playwright_test.PlaywrightWorkerOptions>;
|
|
165
|
+
|
|
166
|
+
export { type AssertViewportFitOptions, type DefineViewportFitTestsOptions, type DeviceProfile, FLEET_VIEWPORT_PROFILES, type FleetFixtures, type MockMe, VIEWPORT_FIT_WARN_PREFIX, assertViewportFit, createHealthzHandler, createMeHandler, createMeSignedOutHandler, defaultMockHandlers, defineViewportFitTests, fleetTest, httperrBody, mockHttperrRef };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { test } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
// src/testing-viewport-fit.ts
|
|
5
|
+
var VIEWPORT_FIT_WARN_PREFIX = "[@etamong-playground/ui]";
|
|
6
|
+
var FLEET_VIEWPORT_PROFILES = [
|
|
7
|
+
{
|
|
8
|
+
name: "iphone-17-pro-pwa",
|
|
9
|
+
viewport: { width: 402, height: 874 },
|
|
10
|
+
standalone: true,
|
|
11
|
+
safeAreaTop: 59,
|
|
12
|
+
safeAreaBottom: 34
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "ipad-mini-portrait",
|
|
16
|
+
viewport: { width: 768, height: 1024 }
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "desktop-1280",
|
|
20
|
+
viewport: { width: 1280, height: 800 }
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "android-narrow",
|
|
24
|
+
viewport: { width: 360, height: 780 }
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
async function assertViewportFit(page, urls, options = {}) {
|
|
28
|
+
const profiles = options.profiles && options.profiles.length ? options.profiles : FLEET_VIEWPORT_PROFILES;
|
|
29
|
+
const settleMs = options.settleMs ?? 400;
|
|
30
|
+
const predicate = options.predicate ?? ((msg) => msg.type() === "warning" && msg.text().startsWith(VIEWPORT_FIT_WARN_PREFIX));
|
|
31
|
+
const collected = [];
|
|
32
|
+
const onConsole = (msg) => {
|
|
33
|
+
if (predicate(msg)) collected.push(msg.text());
|
|
34
|
+
};
|
|
35
|
+
page.on("console", onConsole);
|
|
36
|
+
try {
|
|
37
|
+
for (const profile of profiles) {
|
|
38
|
+
await page.setViewportSize(profile.viewport);
|
|
39
|
+
if (profile.standalone) {
|
|
40
|
+
await page.emulateMedia({ media: "screen", colorScheme: "no-preference" });
|
|
41
|
+
await page.addInitScript(() => {
|
|
42
|
+
const origMatchMedia = window.matchMedia;
|
|
43
|
+
window.matchMedia = (q) => {
|
|
44
|
+
if (q.includes("display-mode: standalone")) {
|
|
45
|
+
return {
|
|
46
|
+
matches: true,
|
|
47
|
+
media: q,
|
|
48
|
+
onchange: null,
|
|
49
|
+
addListener: () => {
|
|
50
|
+
},
|
|
51
|
+
removeListener: () => {
|
|
52
|
+
},
|
|
53
|
+
addEventListener: () => {
|
|
54
|
+
},
|
|
55
|
+
removeEventListener: () => {
|
|
56
|
+
},
|
|
57
|
+
dispatchEvent: () => true
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return origMatchMedia.call(window, q);
|
|
61
|
+
};
|
|
62
|
+
Object.defineProperty(navigator, "standalone", {
|
|
63
|
+
configurable: true,
|
|
64
|
+
value: true
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (profile.safeAreaTop != null || profile.safeAreaBottom != null) {
|
|
69
|
+
const top = profile.safeAreaTop ?? 0;
|
|
70
|
+
const bot = profile.safeAreaBottom ?? 0;
|
|
71
|
+
await page.addInitScript(
|
|
72
|
+
(insets) => {
|
|
73
|
+
const style = document.createElement("style");
|
|
74
|
+
style.textContent = `:root {
|
|
75
|
+
--etu-test-safe-top: ${insets.top}px;
|
|
76
|
+
--etu-test-safe-bot: ${insets.bot}px;
|
|
77
|
+
}
|
|
78
|
+
/* Stub env() so the browser sees a real inset value during
|
|
79
|
+
headless testing. CSS @supports cannot polyfill env(), so
|
|
80
|
+
apps that want this assertion to run under emulated
|
|
81
|
+
notched insets must use the .etu-test-safe-top/bot custom
|
|
82
|
+
properties instead of env() in tests \u2014 or apps can keep
|
|
83
|
+
env() and accept that desktop Chromium reports 0. */
|
|
84
|
+
`;
|
|
85
|
+
document.head.appendChild(style);
|
|
86
|
+
},
|
|
87
|
+
{ top, bot }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
for (const url of urls) {
|
|
91
|
+
collected.length = 0;
|
|
92
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
93
|
+
await page.waitForTimeout(settleMs);
|
|
94
|
+
if (collected.length > 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`viewport-fit assertion failed at ${profile.name} on ${url}:
|
|
97
|
+
` + collected.map((l) => " - " + l).join("\n")
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
page.off("console", onConsole);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function defineViewportFitTests(test, options) {
|
|
107
|
+
const profiles = options.profiles && options.profiles.length ? options.profiles : FLEET_VIEWPORT_PROFILES;
|
|
108
|
+
const prefix = options.titlePrefix ?? "viewport-fit";
|
|
109
|
+
for (const profile of profiles) {
|
|
110
|
+
test(`${prefix} \u2014 ${profile.name}`, async (args) => {
|
|
111
|
+
if (options.beforeEach) await options.beforeEach(args);
|
|
112
|
+
await assertViewportFit(args.page, options.urls, {
|
|
113
|
+
profiles: [profile]
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function mockHttperrRef() {
|
|
119
|
+
const b = new Uint8Array(4);
|
|
120
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
121
|
+
crypto.getRandomValues(b);
|
|
122
|
+
} else {
|
|
123
|
+
for (let i = 0; i < 4; i++) b[i] = Math.floor(Math.random() * 256);
|
|
124
|
+
}
|
|
125
|
+
return Array.from(b).map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
126
|
+
}
|
|
127
|
+
function httperrBody(message, ref) {
|
|
128
|
+
return { error: message, ref: ref ?? mockHttperrRef() };
|
|
129
|
+
}
|
|
130
|
+
function createMeHandler(me = {}, path = "/api/v1/me") {
|
|
131
|
+
const body = {
|
|
132
|
+
email: me.email ?? "you@etamong.com",
|
|
133
|
+
is_admin: me.is_admin ?? false,
|
|
134
|
+
via_token: me.via_token ?? false,
|
|
135
|
+
can_create_apps: me.can_create_apps ?? false
|
|
136
|
+
};
|
|
137
|
+
return http.get(path, () => HttpResponse.json(body));
|
|
138
|
+
}
|
|
139
|
+
function createMeSignedOutHandler(path = "/api/v1/me") {
|
|
140
|
+
return http.get(
|
|
141
|
+
path,
|
|
142
|
+
() => HttpResponse.json(httperrBody("not signed in"), { status: 401 })
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function createHealthzHandler(path = "/healthz") {
|
|
146
|
+
return http.get(path, () => new HttpResponse("ok", { status: 200 }));
|
|
147
|
+
}
|
|
148
|
+
function defaultMockHandlers(me = {}, paths = {}) {
|
|
149
|
+
return [createMeHandler(me, paths.me), createHealthzHandler(paths.healthz)];
|
|
150
|
+
}
|
|
151
|
+
var fleetTest = test.extend({
|
|
152
|
+
// eslint-disable-next-line no-empty-pattern
|
|
153
|
+
fleetPage: async ({ page }, use) => {
|
|
154
|
+
await use(page);
|
|
155
|
+
},
|
|
156
|
+
page: async ({ browser }, use) => {
|
|
157
|
+
const context = await browser.newContext({
|
|
158
|
+
locale: "ko-KR",
|
|
159
|
+
timezoneId: "Asia/Seoul",
|
|
160
|
+
viewport: { width: 1280, height: 800 },
|
|
161
|
+
extraHTTPHeaders: { "Accept-Language": "ko-KR" }
|
|
162
|
+
});
|
|
163
|
+
const page = await context.newPage();
|
|
164
|
+
try {
|
|
165
|
+
await use(page);
|
|
166
|
+
} finally {
|
|
167
|
+
await page.close();
|
|
168
|
+
await context.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export { FLEET_VIEWPORT_PROFILES, VIEWPORT_FIT_WARN_PREFIX, assertViewportFit, createHealthzHandler, createMeHandler, createMeSignedOutHandler, defaultMockHandlers, defineViewportFitTests, fleetTest, httperrBody, mockHttperrRef };
|