cursedbelt 5.101.0 → 5.102.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/react/engagement/beacon.js +3 -3
- package/dist/react/engagement/beacon.js.map +1 -1
- package/dist/react/engagement/beaconClient.d.ts +25 -2
- package/dist/react/engagement/beaconClient.d.ts.map +1 -1
- package/dist/react/engagement/beaconClient.js +105 -14
- package/dist/react/engagement/beaconClient.js.map +1 -1
- package/dist/react/engagement/index.d.ts +1 -1
- package/dist/react/engagement/index.d.ts.map +1 -1
- package/dist/react/engagement/index.js +1 -1
- package/dist/react/engagement/index.js.map +1 -1
- package/dist/testing/designBaseline.d.ts +5 -1
- package/dist/testing/designBaseline.d.ts.map +1 -1
- package/dist/testing/designBaseline.js +27 -13
- package/dist/testing/designBaseline.js.map +1 -1
- package/package.json +1 -1
- package/src/react/engagement/beacon.spec.tsx +2 -1
- package/src/react/engagement/beacon.tsx +3 -3
- package/src/react/engagement/beaconClient.spec.ts +63 -6
- package/src/react/engagement/beaconClient.ts +119 -13
- package/src/react/engagement/index.ts +1 -0
- package/src/testing/designBaseline.spec.ts +36 -0
- package/src/testing/designBaseline.ts +34 -15
|
@@ -35,10 +35,10 @@ export function useEngagementBeacon(place, options = {}) {
|
|
|
35
35
|
* may and may not carry.
|
|
36
36
|
*/
|
|
37
37
|
export function useEngagementAction(action, options = {}) {
|
|
38
|
-
const { basePath, fetchImpl, sessionPath, assumeSignedIn, place } = options;
|
|
38
|
+
const { basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl } = options;
|
|
39
39
|
return useCallback(() => {
|
|
40
|
-
void reportAction(action, { basePath, fetchImpl, sessionPath, assumeSignedIn, place });
|
|
41
|
-
}, [action, basePath, fetchImpl, sessionPath, assumeSignedIn, place]);
|
|
40
|
+
void reportAction(action, { basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl });
|
|
41
|
+
}, [action, basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl]);
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Report wherever the browser IS, and keep reporting as it moves.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"beacon.js","sourceRoot":"","sources":["../../../src/react/engagement/beacon.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC/C,OAAO,EAGN,YAAY,EACZ,WAAW,EACX,mBAAmB,GACnB,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAoB,EAAE,UAA8B,EAAE;IACzF,iGAAiG;IACjG,+FAA+F;IAC/F,kFAAkF;IAClF,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC;IACrE,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,KAAK;YAAE,OAAO;QACnB,yEAAyE;QACzE,6CAA6C;QAC7C,KAAK,WAAW,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,CAAC,CAAC;IAC/E,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc,EAAE,UAA+B,EAAE;IACpF,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"beacon.js","sourceRoot":"","sources":["../../../src/react/engagement/beacon.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC/C,OAAO,EAGN,YAAY,EACZ,WAAW,EACX,mBAAmB,GACnB,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAoB,EAAE,UAA8B,EAAE;IACzF,iGAAiG;IACjG,+FAA+F;IAC/F,kFAAkF;IAClF,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC;IACrE,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,KAAK;YAAE,OAAO;QACnB,yEAAyE;QACzE,6CAA6C;QAC7C,KAAK,WAAW,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,CAAC,CAAC;IAC/E,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc,EAAE,UAA+B,EAAE;IACpF,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IACxF,OAAO,WAAW,CAAC,GAAG,EAAE;QACvB,KAAK,YAAY,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;IACpG,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAAC,UAA8B,EAAE;IACjE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC;IACrE,0EAA0E;IAC1E,uEAAuE;IACvE,uEAAuE;IACvE,SAAS,CACR,GAAG,EAAE,CAAC,mBAAmB,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,CAAC,EAC/E,CAAC,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,CAAC,CAClD,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,gEAAgE;AAChE,MAAM,UAAU,gBAAgB,CAAC,KAA4B;IAC5D,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACtE,OAAO,IAAI,CAAC;AACb,CAAC"}
|
|
@@ -85,7 +85,7 @@ export interface ReportPlaceOptions {
|
|
|
85
85
|
* not sending it in the first place means user text does not travel at all.
|
|
86
86
|
*/
|
|
87
87
|
export declare function currentLocationPlace(): string;
|
|
88
|
-
/** Reset the de-duplication memory. Tests, and a sign-out. */
|
|
88
|
+
/** Reset the de-duplication memory, and drop any queued action unsent. Tests, and a sign-out. */
|
|
89
89
|
export declare function resetEngagementBeacon(): void;
|
|
90
90
|
/**
|
|
91
91
|
* Report an arrival at `place`. Returns whether anything was sent.
|
|
@@ -100,10 +100,27 @@ export interface ReportActionOptions extends ReportPlaceOptions {
|
|
|
100
100
|
* against the manifest exactly as it resolves a view, so the default is right for every app.
|
|
101
101
|
*/
|
|
102
102
|
place?: string;
|
|
103
|
+
/** Injected in tests; defaults to `navigator.sendBeacon`. The page-leave flush sends through it. */
|
|
104
|
+
beaconImpl?: (url: string, body: Blob) => boolean;
|
|
103
105
|
}
|
|
106
|
+
/** How long the action queue waits for quiet before it sends (forge task 4034). */
|
|
107
|
+
export declare const ACTION_QUIET_MS = 5000;
|
|
108
|
+
/** The longest an action waits however busy the page is, so a page that crashes loses 30 s at most. */
|
|
109
|
+
export declare const ACTION_MAX_WAIT_MS = 30000;
|
|
110
|
+
/** One POST carries at most this many: the recorder's `MAX_ACTION_BATCH` (cursedbelt-server). */
|
|
111
|
+
export declare const ACTION_BATCH_MAX = 100;
|
|
112
|
+
/**
|
|
113
|
+
* Send what is queued, now: ONE POST per mount. A queue of one posts `{ place, action }` as
|
|
114
|
+
* `reportAction` always has, so a recorder older than the batch reads it; more posts
|
|
115
|
+
* `{ actions }`, and a recorder that refuses the batch (an older one answers 400) gets them one by
|
|
116
|
+
* one instead. `leaving` sends through `sendBeacon`, which outlives the page. Never throws.
|
|
117
|
+
*/
|
|
118
|
+
export declare function flushEngagementActions(options?: {
|
|
119
|
+
leaving?: boolean;
|
|
120
|
+
}): Promise<void>;
|
|
104
121
|
/**
|
|
105
122
|
* Report that a FEATURE was used — `reportAction("search")` — beside the place it was used on
|
|
106
|
-
* (forge task 3470). Returns whether
|
|
123
|
+
* (forge task 3470). Returns whether it was queued to send.
|
|
107
124
|
*
|
|
108
125
|
* The owner's question is "what features do they use and ignore"; a page view cannot answer it,
|
|
109
126
|
* because one page holds several features. So the app names the action at the press, from the
|
|
@@ -117,6 +134,12 @@ export interface ReportActionOptions extends ReportPlaceOptions {
|
|
|
117
134
|
* · the same silence: session-probed (the probe's answer is shared with `reportPlace`, one per
|
|
118
135
|
* page load), fire-and-forget, never throws, never logs.
|
|
119
136
|
*
|
|
137
|
+
* 🔴 BATCHED (forge task 4034): an action is queued, and the queue goes as ONE request after
|
|
138
|
+
* {@link ACTION_QUIET_MS} of quiet, {@link ACTION_MAX_WAIT_MS} after its first action at most, at
|
|
139
|
+
* {@link ACTION_BATCH_MAX}, and when the page is left ({@link flushEngagementActions}). So a
|
|
140
|
+
* feature the owner adds to the record costs no request per click — his words: *"batching some
|
|
141
|
+
* interactions with debounces will lower traffic"*.
|
|
142
|
+
*
|
|
120
143
|
* NOT de-duplicated, unlike `reportPlace`: three plays are three uses. Only call it from apps
|
|
121
144
|
* whose recorder was mounted WITH `actions` — elsewhere `/action` is a 404 in the console.
|
|
122
145
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"beaconClient.d.ts","sourceRoot":"","sources":["../../../src/react/engagement/beaconClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,sBAAsB,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CACzB;AAaD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAI7C;AAYD,
|
|
1
|
+
{"version":3,"file":"beaconClient.d.ts","sourceRoot":"","sources":["../../../src/react/engagement/beaconClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,sBAAsB,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CACzB;AAaD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAI7C;AAYD,iGAAiG;AACjG,wBAAgB,qBAAqB,IAAI,IAAI,CAQ5C;AA6CD;;;;;GAKG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,kBAAuB,GAC9B,OAAO,CAAC,OAAO,CAAC,CAgClB;AAED,MAAM,WAAW,mBAAoB,SAAQ,kBAAkB;IAC9D;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oGAAoG;IACpG,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;CAClD;AAED,mFAAmF;AACnF,eAAO,MAAM,eAAe,OAAQ,CAAC;AACrC,uGAAuG;AACvG,eAAO,MAAM,kBAAkB,QAAS,CAAC;AACzC,iGAAiG;AACjG,eAAO,MAAM,gBAAgB,MAAM,CAAC;AA8CpC;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAuBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,OAAO,CAAC,CAwBtG;AAED,8DAA8D;AAC9D,MAAM,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC;AAIpC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAehF"}
|
|
@@ -92,10 +92,17 @@ export function currentLocationPlace() {
|
|
|
92
92
|
* dozens.
|
|
93
93
|
*/
|
|
94
94
|
let sessionAnswer = null;
|
|
95
|
-
/** Reset the de-duplication memory. Tests, and a sign-out. */
|
|
95
|
+
/** Reset the de-duplication memory, and drop any queued action unsent. Tests, and a sign-out. */
|
|
96
96
|
export function resetEngagementBeacon() {
|
|
97
97
|
lastReported = null;
|
|
98
98
|
sessionAnswer = null;
|
|
99
|
+
for (const queue of queues.values()) {
|
|
100
|
+
if (queue.quiet)
|
|
101
|
+
clearTimeout(queue.quiet);
|
|
102
|
+
if (queue.due)
|
|
103
|
+
clearTimeout(queue.due);
|
|
104
|
+
}
|
|
105
|
+
queues.clear();
|
|
99
106
|
}
|
|
100
107
|
/**
|
|
101
108
|
* Read an answer to its end, and drop it.
|
|
@@ -183,9 +190,85 @@ export async function reportPlace(place, options = {}) {
|
|
|
183
190
|
return false;
|
|
184
191
|
}
|
|
185
192
|
}
|
|
193
|
+
/** How long the action queue waits for quiet before it sends (forge task 4034). */
|
|
194
|
+
export const ACTION_QUIET_MS = 5_000;
|
|
195
|
+
/** The longest an action waits however busy the page is, so a page that crashes loses 30 s at most. */
|
|
196
|
+
export const ACTION_MAX_WAIT_MS = 30_000;
|
|
197
|
+
/** One POST carries at most this many: the recorder's `MAX_ACTION_BATCH` (cursedbelt-server). */
|
|
198
|
+
export const ACTION_BATCH_MAX = 100;
|
|
199
|
+
const queues = new Map();
|
|
200
|
+
/** The window the page-leave listeners sit on, so a second one is never added (tests swap it). */
|
|
201
|
+
let leaveTarget = null;
|
|
202
|
+
function defaultBeacon() {
|
|
203
|
+
const nav = globalThis.navigator;
|
|
204
|
+
return typeof nav?.sendBeacon === "function" ? nav.sendBeacon.bind(nav) : undefined;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Flush on the way out: `pagehide` (a close, a navigation, the bfcache) and a tab going hidden,
|
|
208
|
+
* which is the last moment a phone gives a page it is about to kill. A timer cannot fire after
|
|
209
|
+
* either, so this is what delivers the actions of a page closed inside its quiet window.
|
|
210
|
+
*/
|
|
211
|
+
function listenForLeave() {
|
|
212
|
+
const win = globalThis.window;
|
|
213
|
+
if (!win || typeof win.addEventListener !== "function" || leaveTarget === win)
|
|
214
|
+
return;
|
|
215
|
+
leaveTarget = win;
|
|
216
|
+
const leave = () => {
|
|
217
|
+
void flushEngagementActions({ leaving: true });
|
|
218
|
+
};
|
|
219
|
+
win.addEventListener("pagehide", leave);
|
|
220
|
+
const doc = globalThis.document;
|
|
221
|
+
doc?.addEventListener?.("visibilitychange", () => {
|
|
222
|
+
if (doc.visibilityState === "hidden")
|
|
223
|
+
leave();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Send what is queued, now: ONE POST per mount. A queue of one posts `{ place, action }` as
|
|
228
|
+
* `reportAction` always has, so a recorder older than the batch reads it; more posts
|
|
229
|
+
* `{ actions }`, and a recorder that refuses the batch (an older one answers 400) gets them one by
|
|
230
|
+
* one instead. `leaving` sends through `sendBeacon`, which outlives the page. Never throws.
|
|
231
|
+
*/
|
|
232
|
+
export async function flushEngagementActions(options = {}) {
|
|
233
|
+
const sends = [];
|
|
234
|
+
for (const [basePath, queue] of queues) {
|
|
235
|
+
if (queue.quiet)
|
|
236
|
+
clearTimeout(queue.quiet);
|
|
237
|
+
if (queue.due)
|
|
238
|
+
clearTimeout(queue.due);
|
|
239
|
+
queues.delete(basePath);
|
|
240
|
+
for (let i = 0; i < queue.actions.length; i += ACTION_BATCH_MAX) {
|
|
241
|
+
sends.push(sendActions(`${basePath}/action`, queue.actions.slice(i, i + ACTION_BATCH_MAX), queue, options.leaving === true));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await Promise.all(sends);
|
|
245
|
+
}
|
|
246
|
+
async function sendActions(url, actions, queue, leaving) {
|
|
247
|
+
const [only] = actions;
|
|
248
|
+
const body = JSON.stringify(actions.length === 1 && only ? { place: only.place, action: only.action } : { actions });
|
|
249
|
+
try {
|
|
250
|
+
if (leaving && queue.beaconImpl?.(url, new Blob([body], { type: "application/json" })))
|
|
251
|
+
return;
|
|
252
|
+
const res = await queue.fetchImpl(url, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "content-type": "application/json" },
|
|
255
|
+
credentials: "same-origin",
|
|
256
|
+
body,
|
|
257
|
+
keepalive: true,
|
|
258
|
+
});
|
|
259
|
+
await drain(res);
|
|
260
|
+
if (res.status === 400 && actions.length > 1) {
|
|
261
|
+
for (const a of actions)
|
|
262
|
+
await sendActions(url, [a], queue, false);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// See the header: telemetry never becomes the user's problem.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
186
269
|
/**
|
|
187
270
|
* Report that a FEATURE was used — `reportAction("search")` — beside the place it was used on
|
|
188
|
-
* (forge task 3470). Returns whether
|
|
271
|
+
* (forge task 3470). Returns whether it was queued to send.
|
|
189
272
|
*
|
|
190
273
|
* The owner's question is "what features do they use and ignore"; a page view cannot answer it,
|
|
191
274
|
* because one page holds several features. So the app names the action at the press, from the
|
|
@@ -199,6 +282,12 @@ export async function reportPlace(place, options = {}) {
|
|
|
199
282
|
* · the same silence: session-probed (the probe's answer is shared with `reportPlace`, one per
|
|
200
283
|
* page load), fire-and-forget, never throws, never logs.
|
|
201
284
|
*
|
|
285
|
+
* 🔴 BATCHED (forge task 4034): an action is queued, and the queue goes as ONE request after
|
|
286
|
+
* {@link ACTION_QUIET_MS} of quiet, {@link ACTION_MAX_WAIT_MS} after its first action at most, at
|
|
287
|
+
* {@link ACTION_BATCH_MAX}, and when the page is left ({@link flushEngagementActions}). So a
|
|
288
|
+
* feature the owner adds to the record costs no request per click — his words: *"batching some
|
|
289
|
+
* interactions with debounces will lower traffic"*.
|
|
290
|
+
*
|
|
202
291
|
* NOT de-duplicated, unlike `reportPlace`: three plays are three uses. Only call it from apps
|
|
203
292
|
* whose recorder was mounted WITH `actions` — elsewhere `/action` is a 404 in the console.
|
|
204
293
|
*/
|
|
@@ -214,20 +303,22 @@ export async function reportAction(action, options = {}) {
|
|
|
214
303
|
return false;
|
|
215
304
|
}
|
|
216
305
|
const place = options.place ?? (typeof window === "undefined" ? "/" : currentLocationPlace());
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
306
|
+
const basePath = options.basePath ?? ENGAGEMENT_BASE_PATH;
|
|
307
|
+
const queue = queues.get(basePath) ?? { actions: [], fetchImpl: doFetch, beaconImpl: undefined, quiet: null, due: null };
|
|
308
|
+
queue.fetchImpl = doFetch;
|
|
309
|
+
queue.beaconImpl = options.beaconImpl ?? defaultBeacon();
|
|
310
|
+
queue.actions.push({ place, action, at: Date.now() });
|
|
311
|
+
queues.set(basePath, queue);
|
|
312
|
+
listenForLeave();
|
|
313
|
+
if (queue.actions.length >= ACTION_BATCH_MAX) {
|
|
314
|
+
void flushEngagementActions();
|
|
226
315
|
return true;
|
|
227
316
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
317
|
+
if (queue.quiet)
|
|
318
|
+
clearTimeout(queue.quiet);
|
|
319
|
+
queue.quiet = setTimeout(() => void flushEngagementActions(), ACTION_QUIET_MS);
|
|
320
|
+
queue.due ??= setTimeout(() => void flushEngagementActions(), ACTION_MAX_WAIT_MS);
|
|
321
|
+
return true;
|
|
231
322
|
}
|
|
232
323
|
let running = null;
|
|
233
324
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"beaconClient.js","sourceRoot":"","sources":["../../../src/react/engagement/beaconClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,2EAA2E;AAC3E,MAAM,CAAC,MAAM,oBAAoB,GAAG,iBAAiB,CAAC;AAEtD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,mBAAmB,CAAC;AAmBhD;;;;;;;;GAQG;AACH,IAAI,YAAY,GAAkB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB;IACnC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC3C,MAAM,GAAG,GAAG,IAAI,IAAI,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC;IACnD,OAAO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;AACjC,CAAC;AAED;;;;;;;GAOG;AACH,IAAI,aAAa,GAA4B,IAAI,CAAC;AAElD,
|
|
1
|
+
{"version":3,"file":"beaconClient.js","sourceRoot":"","sources":["../../../src/react/engagement/beaconClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { ENGAGEMENT_BASE_PATH, type ReportActionOptions, type ReportPlaceOptions, reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH, type StopBeacon, startLocationBeacon, currentLocationPlace, } from './beaconClient.js';
|
|
1
|
+
export { ENGAGEMENT_BASE_PATH, flushEngagementActions, type ReportActionOptions, type ReportPlaceOptions, reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH, type StopBeacon, startLocationBeacon, currentLocationPlace, } from './beaconClient.js';
|
|
2
2
|
export { EngagementBeacon, useEngagementAction, useEngagementBeacon, useLocationBeacon } from './beacon.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/engagement/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACN,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,YAAY,EACZ,KAAK,UAAU,EACf,mBAAmB,EACnB,oBAAoB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/engagement/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACN,oBAAoB,EACpB,sBAAsB,EACtB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,YAAY,EACZ,KAAK,UAAU,EACf,mBAAmB,EACnB,oBAAoB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `cursedbelt/react/engagement-beacon` — the engagement beacon: `reportPlace`, `reportAction` and
|
|
2
2
|
// `startLocationBeacon` (plain JS, no React) and the React hooks over them.
|
|
3
|
-
export { ENGAGEMENT_BASE_PATH, reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH, startLocationBeacon, currentLocationPlace, } from './beaconClient.js';
|
|
3
|
+
export { ENGAGEMENT_BASE_PATH, flushEngagementActions, reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH, startLocationBeacon, currentLocationPlace, } from './beaconClient.js';
|
|
4
4
|
export { EngagementBeacon, useEngagementAction, useEngagementBeacon, useLocationBeacon } from './beacon.js';
|
|
5
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/engagement/index.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,4EAA4E;AAC5E,OAAO,EACN,oBAAoB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/engagement/index.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,4EAA4E;AAC5E,OAAO,EACN,oBAAoB,EACpB,sBAAsB,EAGtB,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,YAAY,EAEZ,mBAAmB,EACnB,oBAAoB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -51,8 +51,12 @@ export declare function designLedger(baselinePath: string): {
|
|
|
51
51
|
* a WebKit context made from `appleContext(devices, device)`, judged by the design audit and
|
|
52
52
|
* the phone rules — a long page read through a screen at a time, the keyboard raised over
|
|
53
53
|
* `options.keyboard` — and recorded as `<route>@<device slug>`. The ratchet is the same file.
|
|
54
|
+
* A walk that read no text is walked again: an app's `load` can land on a shell of icons, with
|
|
55
|
+
* a phone's pass the first to see it (forge task 4039 — collections, station, family).
|
|
54
56
|
*/
|
|
55
|
-
auditApple(page: ApplePage, route: string, device: AppleDevice, options?: AuditAppleOptions
|
|
57
|
+
auditApple(page: ApplePage, route: string, device: AppleDevice, options?: AuditAppleOptions & {
|
|
58
|
+
blankWaitMs?: number;
|
|
59
|
+
}): Promise<void>;
|
|
56
60
|
record(route: string, violations: DesignViolation[]): void;
|
|
57
61
|
/**
|
|
58
62
|
* Every finding so far, formatted with its route — the string a suite with no baseline asserts
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"designBaseline.d.ts","sourceRoot":"","sources":["../../src/testing/designBaseline.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAA4B,MAAM,gBAAgB,CAAC;AAClG,OAAO,EACL,KAAK,kBAAkB,EAGvB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,cAAc,EAEpB,MAAM,kBAAkB,CAAC;AAE1B,2EAA2E;AAC3E,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,eAAe,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,YAAY,IAAI;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"designBaseline.d.ts","sourceRoot":"","sources":["../../src/testing/designBaseline.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAA4B,MAAM,gBAAgB,CAAC;AAClG,OAAO,EACL,KAAK,kBAAkB,EAGvB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,cAAc,EAEpB,MAAM,kBAAkB,CAAC;AAE1B,2EAA2E;AAC3E,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,eAAe,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,YAAY,IAAI;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AA2D3C,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,sBAAuB,CAAC;AAIrD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAOD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAYvE;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,MAAM,CAG3E;AAED,MAAM,WAAW,qBAAqB;IACpC,uFAAuF;IACvF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,iGAAiG;IACjG,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,iGAAiG;AACjG,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,QAAQ,EAAE,mBAAmB,EAAE,GAAG,qBAAqB,CAc1H;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM;IAiC7C;;;;OAIG;gBAEK,aAAa,SACZ,MAAM,YACJ,kBAAkB,GAAG;QAAE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GACnH,OAAO,CAAC,IAAI,CAAC;IAehB;;;;;;;OAOG;qBACoB,SAAS,SAAS,MAAM,UAAU,WAAW,YAAW,iBAAiB,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAQ,OAAO,CAAC,IAAI,CAAC;kBAOlI,MAAM,cAAc,eAAe,EAAE,GAAG,IAAI;IAG1D;;;OAGG;cACO,MAAM;aAMP,mBAAmB,EAAE;iBAUlB,MAAM,GAAG,SAAS,GAAiC,qBAAqB;EAkCvF"}
|
|
@@ -20,8 +20,24 @@
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { auditApple, settleInPage } from './appleWalk.js';
|
|
22
22
|
import { auditDesign, DESIGN_RULES, formatDesignViolations, } from './designAudit.js';
|
|
23
|
-
/** How long a
|
|
23
|
+
/** How long a read that finds no visible text is given to paint before the audit calls it blank. */
|
|
24
24
|
const BLANK_WAIT_MS = 10_000;
|
|
25
|
+
/**
|
|
26
|
+
* Reads the page until it has text or the deadline passes. A page a test is holding can be blank
|
|
27
|
+
* for a moment that is the machine's, not the app's: a pane the CSS dropped before its replacement
|
|
28
|
+
* mounts, or a shell of icons before the screen's chunk lands, and a loaded machine stretches that
|
|
29
|
+
* past `settleInPage`'s calm. Both of the ledger's audits come through here; what stays blank at
|
|
30
|
+
* the deadline is the caller's to refuse.
|
|
31
|
+
*/
|
|
32
|
+
async function readUntilPainted(read, blank, waitMs = BLANK_WAIT_MS) {
|
|
33
|
+
const deadline = Date.now() + waitMs;
|
|
34
|
+
let result = await read();
|
|
35
|
+
while (blank(result) && Date.now() < deadline) {
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
37
|
+
result = await read();
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
25
41
|
/** The color-scheme store's key (cursedbelt-core `COLOR_SCHEME_STORAGE_KEY`), said here so the
|
|
26
42
|
* ledger stays free of a runtime import from the React half. */
|
|
27
43
|
const SCHEME_KEY = 'cwip.colorScheme';
|
|
@@ -106,20 +122,15 @@ export function designLedger(baselinePath) {
|
|
|
106
122
|
const original = page.viewportSize();
|
|
107
123
|
for (const width of options.widths ?? APP_AUDIT_WIDTHS) {
|
|
108
124
|
await page.setViewportSize({ width, height: original?.height ?? 900 });
|
|
109
|
-
// Wait for the page to settle (`settleInPage` says what that means and why).
|
|
110
|
-
await page.evaluate(settleInPage, undefined);
|
|
111
125
|
const label = `${route}@${width}${scheme ? `:${scheme}` : ''}`;
|
|
112
|
-
let { violations, census } = await auditDesign(page, { ...options, route, width });
|
|
113
126
|
// A width change can hide what was on screen (a pane the CSS drops at this width) before the
|
|
114
|
-
// code that mounts its replacement runs
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
while (census.textNodes === 0 && Date.now() < deadline) {
|
|
119
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
127
|
+
// code that mounts its replacement runs: vault's entry read blank at 390 under the ship's
|
|
128
|
+
// three gates at once (forge task 3890). So a blank census is settled and read again.
|
|
129
|
+
const { violations, census } = await readUntilPainted(async () => {
|
|
130
|
+
// Wait for the page to settle (`settleInPage` says what that means and why).
|
|
120
131
|
await page.evaluate(settleInPage, undefined);
|
|
121
|
-
|
|
122
|
-
}
|
|
132
|
+
return auditDesign(page, { ...options, route, width });
|
|
133
|
+
}, (read) => read.census.textNodes === 0, options.blankWaitMs);
|
|
123
134
|
// A sweep that read no text audited nothing, and an empty finding list from it would
|
|
124
135
|
// read as a clean page — the false green the touch-target sweep was once caught in.
|
|
125
136
|
if (census.textNodes === 0)
|
|
@@ -158,10 +169,13 @@ export function designLedger(baselinePath) {
|
|
|
158
169
|
* a WebKit context made from `appleContext(devices, device)`, judged by the design audit and
|
|
159
170
|
* the phone rules — a long page read through a screen at a time, the keyboard raised over
|
|
160
171
|
* `options.keyboard` — and recorded as `<route>@<device slug>`. The ratchet is the same file.
|
|
172
|
+
* A walk that read no text is walked again: an app's `load` can land on a shell of icons, with
|
|
173
|
+
* a phone's pass the first to see it (forge task 4039 — collections, station, family).
|
|
161
174
|
*/
|
|
162
175
|
async auditApple(page, route, device, options = {}) {
|
|
163
176
|
const label = `${route}@${device.slug}`;
|
|
164
|
-
const {
|
|
177
|
+
const { blankWaitMs, ...walk } = options;
|
|
178
|
+
const { violations, textNodes } = await readUntilPainted(() => auditApple(page, route, device, walk), (read) => read.textNodes === 0, blankWaitMs);
|
|
165
179
|
if (textNodes === 0)
|
|
166
180
|
throw new Error(`design audit: ${label} rendered no visible text — nothing was audited`);
|
|
167
181
|
byRoute.set(label, violations);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"designBaseline.js","sourceRoot":"","sources":["../../src/testing/designBaseline.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,8BAA8B;AAC9B;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAElE,OAAO,EAA0C,UAAU,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAClG,OAAO,EAEL,WAAW,EACX,YAAY,EAIZ,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAU1B,
|
|
1
|
+
{"version":3,"file":"designBaseline.js","sourceRoot":"","sources":["../../src/testing/designBaseline.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.102.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
10
10
|
import { useEngagementAction, useEngagementBeacon, useLocationBeacon } from "./beacon.js";
|
|
11
|
-
import { resetEngagementBeacon } from "./beaconClient.js";
|
|
11
|
+
import { flushEngagementActions, resetEngagementBeacon } from "./beaconClient.js";
|
|
12
12
|
|
|
13
13
|
/** A `fetch` that records every URL and answers every one signed in. */
|
|
14
14
|
function recordingFetch() {
|
|
@@ -57,6 +57,7 @@ describe("useEngagementAction", () => {
|
|
|
57
57
|
expect(result.current).toBe(first);
|
|
58
58
|
expect(calls).toEqual([]);
|
|
59
59
|
result.current();
|
|
60
|
+
await flushEngagementActions();
|
|
60
61
|
await waitFor(() => expect(calls).toEqual(["/api/eng/action"]));
|
|
61
62
|
});
|
|
62
63
|
});
|
|
@@ -42,10 +42,10 @@ export function useEngagementBeacon(place: string | null, options: ReportPlaceOp
|
|
|
42
42
|
* may and may not carry.
|
|
43
43
|
*/
|
|
44
44
|
export function useEngagementAction(action: string, options: ReportActionOptions = {}): () => void {
|
|
45
|
-
const { basePath, fetchImpl, sessionPath, assumeSignedIn, place } = options;
|
|
45
|
+
const { basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl } = options;
|
|
46
46
|
return useCallback(() => {
|
|
47
|
-
void reportAction(action, { basePath, fetchImpl, sessionPath, assumeSignedIn, place });
|
|
48
|
-
}, [action, basePath, fetchImpl, sessionPath, assumeSignedIn, place]);
|
|
47
|
+
void reportAction(action, { basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl });
|
|
48
|
+
}, [action, basePath, fetchImpl, sessionPath, assumeSignedIn, place, beaconImpl]);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* this file runs in the package's plain `bun test`.
|
|
14
14
|
*/
|
|
15
15
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
-
import { reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH } from "./beaconClient.js";
|
|
16
|
+
import { ACTION_BATCH_MAX, flushEngagementActions, reportAction, reportPlace, resetEngagementBeacon, SESSION_PATH } from "./beaconClient.js";
|
|
17
17
|
|
|
18
18
|
/** A `fetch` that records every call, and every answer it handed out, and answers from a script. */
|
|
19
19
|
function recordingFetch(answers: Record<string, { status?: number; body?: unknown }>) {
|
|
@@ -134,6 +134,7 @@ describe("every answer is read to its end (forge task 3884)", () => {
|
|
|
134
134
|
const { impl, responses } = recordingFetch(answers);
|
|
135
135
|
await reportPlace("/#/hub", { fetchImpl: impl });
|
|
136
136
|
await reportAction("search", { fetchImpl: impl, place: "/#/hub" });
|
|
137
|
+
await flushEngagementActions();
|
|
137
138
|
expect(responses.length).toBeGreaterThan(0);
|
|
138
139
|
expect(responses.map((r) => r.bodyUsed)).toEqual(responses.map(() => true));
|
|
139
140
|
});
|
|
@@ -171,22 +172,78 @@ describe("reportAction — a feature NAME on a place, with the same silence (tas
|
|
|
171
172
|
test("🔴 the body is the place and the action NAME — nothing else can travel", async () => {
|
|
172
173
|
const { impl, posts } = bodyFetch(true);
|
|
173
174
|
expect(await reportAction("search", { fetchImpl: impl, place: "library" })).toBe(true);
|
|
175
|
+
await flushEngagementActions();
|
|
174
176
|
expect(posts).toEqual([{ url: "/api/engagement/action", body: { place: "library", action: "search" } }]);
|
|
175
177
|
});
|
|
176
178
|
|
|
177
|
-
test("NOT de-duplicated: three plays are three uses, and one session probe", async () => {
|
|
178
|
-
const { impl, calls } = bodyFetch(true);
|
|
179
|
+
test("NOT de-duplicated: three plays are three uses, ONE request and one session probe (task 4034)", async () => {
|
|
180
|
+
const { impl, calls, posts } = bodyFetch(true);
|
|
179
181
|
for (let i = 0; i < 3; i++) await reportAction("play", { fetchImpl: impl, place: "/" });
|
|
180
|
-
expect(calls).toEqual([SESSION_PATH
|
|
182
|
+
expect(calls).toEqual([SESSION_PATH]);
|
|
183
|
+
await flushEngagementActions();
|
|
184
|
+
expect(calls).toEqual([SESSION_PATH, "/api/engagement/action"]);
|
|
185
|
+
const body = posts[0]?.body as { actions: { place: string; action: string; at: number }[] };
|
|
186
|
+
expect(body.actions.map(({ place, action }) => ({ place, action }))).toEqual([0, 1, 2].map(() => ({ place: "/", action: "play" })));
|
|
187
|
+
expect(body.actions.every((a) => typeof a.at === "number" && Object.keys(a).length === 3)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("the queue goes by itself: after the quiet window, and at the cap without waiting", async () => {
|
|
191
|
+
const { impl, posts } = bodyFetch(true);
|
|
192
|
+
await reportAction("play", { fetchImpl: impl, assumeSignedIn: true, place: "/" });
|
|
193
|
+
expect(posts).toEqual([]);
|
|
194
|
+
await Bun.sleep(5_100);
|
|
195
|
+
expect(posts).toEqual([{ url: "/api/engagement/action", body: { place: "/", action: "play" } }]);
|
|
196
|
+
for (let i = 0; i < ACTION_BATCH_MAX; i++) await reportAction("play", { fetchImpl: impl, assumeSignedIn: true, place: "/" });
|
|
197
|
+
await Bun.sleep(0);
|
|
198
|
+
expect(posts).toHaveLength(2);
|
|
199
|
+
expect((posts[1] as { body: { actions: unknown[] } }).body.actions).toHaveLength(ACTION_BATCH_MAX);
|
|
200
|
+
}, 10_000);
|
|
201
|
+
|
|
202
|
+
test("🔴 a page closed inside the quiet window still delivers its actions, through sendBeacon", async () => {
|
|
203
|
+
const { impl, calls } = bodyFetch(true);
|
|
204
|
+
const beacons: { url: string; body: Blob }[] = [];
|
|
205
|
+
const beaconImpl = (url: string, body: Blob) => beacons.push({ url, body }) > 0;
|
|
206
|
+
const g = globalThis as { window?: EventTarget };
|
|
207
|
+
const before = g.window;
|
|
208
|
+
g.window = new EventTarget();
|
|
209
|
+
try {
|
|
210
|
+
await reportAction("play", { fetchImpl: impl, assumeSignedIn: true, place: "/", beaconImpl });
|
|
211
|
+
await reportAction("search", { fetchImpl: impl, assumeSignedIn: true, place: "library", beaconImpl });
|
|
212
|
+
g.window.dispatchEvent(new Event("pagehide"));
|
|
213
|
+
await Bun.sleep(0);
|
|
214
|
+
expect(calls).toEqual([]);
|
|
215
|
+
expect(beacons.map((b) => [b.url, b.body.type.split(";")[0]])).toEqual([["/api/engagement/action", "application/json"]]);
|
|
216
|
+
const sent = JSON.parse(await (beacons[0] as { body: Blob }).body.text()) as { actions: { action: string }[] };
|
|
217
|
+
expect(sent.actions.map((a) => a.action)).toEqual(["play", "search"]);
|
|
218
|
+
} finally {
|
|
219
|
+
g.window = before;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a recorder older than the batch (a 400) gets the actions one by one", async () => {
|
|
224
|
+
const posts: unknown[] = [];
|
|
225
|
+
const impl = (async (_url: string, init?: { body?: string }) => {
|
|
226
|
+
const body = JSON.parse(init?.body ?? "null") as { actions?: unknown };
|
|
227
|
+
posts.push(body);
|
|
228
|
+
return new Response("{}", { status: body?.actions ? 400 : 200 });
|
|
229
|
+
}) as unknown as typeof fetch;
|
|
230
|
+
await reportAction("play", { fetchImpl: impl, assumeSignedIn: true, place: "/" });
|
|
231
|
+
await reportAction("search", { fetchImpl: impl, assumeSignedIn: true, place: "/" });
|
|
232
|
+
await flushEngagementActions();
|
|
233
|
+
expect(posts.slice(1)).toEqual([
|
|
234
|
+
{ place: "/", action: "play" },
|
|
235
|
+
{ place: "/", action: "search" },
|
|
236
|
+
]);
|
|
181
237
|
});
|
|
182
238
|
|
|
183
|
-
test("an empty name sends nothing; a throwing fetch is
|
|
239
|
+
test("an empty name sends nothing; a throwing fetch is never a rejection", async () => {
|
|
184
240
|
const { impl, calls } = bodyFetch(true);
|
|
185
241
|
expect(await reportAction("", { fetchImpl: impl })).toBe(false);
|
|
186
242
|
expect(calls).toEqual([]);
|
|
187
243
|
const boom = (async () => {
|
|
188
244
|
throw new Error("offline");
|
|
189
245
|
}) as unknown as typeof fetch;
|
|
190
|
-
expect(await reportAction("play", { fetchImpl: boom, assumeSignedIn: true, place: "/" })).toBe(
|
|
246
|
+
expect(await reportAction("play", { fetchImpl: boom, assumeSignedIn: true, place: "/" })).toBe(true);
|
|
247
|
+
await flushEngagementActions();
|
|
191
248
|
});
|
|
192
249
|
});
|
|
@@ -115,10 +115,15 @@ export function currentLocationPlace(): string {
|
|
|
115
115
|
*/
|
|
116
116
|
let sessionAnswer: Promise<boolean> | null = null;
|
|
117
117
|
|
|
118
|
-
/** Reset the de-duplication memory. Tests, and a sign-out. */
|
|
118
|
+
/** Reset the de-duplication memory, and drop any queued action unsent. Tests, and a sign-out. */
|
|
119
119
|
export function resetEngagementBeacon(): void {
|
|
120
120
|
lastReported = null;
|
|
121
121
|
sessionAnswer = null;
|
|
122
|
+
for (const queue of queues.values()) {
|
|
123
|
+
if (queue.quiet) clearTimeout(queue.quiet);
|
|
124
|
+
if (queue.due) clearTimeout(queue.due);
|
|
125
|
+
}
|
|
126
|
+
queues.clear();
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
@@ -213,11 +218,104 @@ export interface ReportActionOptions extends ReportPlaceOptions {
|
|
|
213
218
|
* against the manifest exactly as it resolves a view, so the default is right for every app.
|
|
214
219
|
*/
|
|
215
220
|
place?: string;
|
|
221
|
+
/** Injected in tests; defaults to `navigator.sendBeacon`. The page-leave flush sends through it. */
|
|
222
|
+
beaconImpl?: (url: string, body: Blob) => boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** How long the action queue waits for quiet before it sends (forge task 4034). */
|
|
226
|
+
export const ACTION_QUIET_MS = 5_000;
|
|
227
|
+
/** The longest an action waits however busy the page is, so a page that crashes loses 30 s at most. */
|
|
228
|
+
export const ACTION_MAX_WAIT_MS = 30_000;
|
|
229
|
+
/** One POST carries at most this many: the recorder's `MAX_ACTION_BATCH` (cursedbelt-server). */
|
|
230
|
+
export const ACTION_BATCH_MAX = 100;
|
|
231
|
+
|
|
232
|
+
interface QueuedAction {
|
|
233
|
+
place: string;
|
|
234
|
+
action: string;
|
|
235
|
+
at: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One queue per recorder mount; almost always one. */
|
|
239
|
+
interface ActionQueue {
|
|
240
|
+
actions: QueuedAction[];
|
|
241
|
+
fetchImpl: typeof fetch;
|
|
242
|
+
beaconImpl: ((url: string, body: Blob) => boolean) | undefined;
|
|
243
|
+
quiet: ReturnType<typeof setTimeout> | null;
|
|
244
|
+
due: ReturnType<typeof setTimeout> | null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const queues = new Map<string, ActionQueue>();
|
|
248
|
+
|
|
249
|
+
/** The window the page-leave listeners sit on, so a second one is never added (tests swap it). */
|
|
250
|
+
let leaveTarget: EventTarget | null = null;
|
|
251
|
+
|
|
252
|
+
function defaultBeacon(): ((url: string, body: Blob) => boolean) | undefined {
|
|
253
|
+
const nav = (globalThis as { navigator?: { sendBeacon?: (url: string, body: Blob) => boolean } }).navigator;
|
|
254
|
+
return typeof nav?.sendBeacon === "function" ? nav.sendBeacon.bind(nav) : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Flush on the way out: `pagehide` (a close, a navigation, the bfcache) and a tab going hidden,
|
|
259
|
+
* which is the last moment a phone gives a page it is about to kill. A timer cannot fire after
|
|
260
|
+
* either, so this is what delivers the actions of a page closed inside its quiet window.
|
|
261
|
+
*/
|
|
262
|
+
function listenForLeave(): void {
|
|
263
|
+
const win = (globalThis as { window?: EventTarget }).window;
|
|
264
|
+
if (!win || typeof win.addEventListener !== "function" || leaveTarget === win) return;
|
|
265
|
+
leaveTarget = win;
|
|
266
|
+
const leave = (): void => {
|
|
267
|
+
void flushEngagementActions({ leaving: true });
|
|
268
|
+
};
|
|
269
|
+
win.addEventListener("pagehide", leave);
|
|
270
|
+
const doc = (globalThis as { document?: EventTarget & { visibilityState?: string } }).document;
|
|
271
|
+
doc?.addEventListener?.("visibilitychange", () => {
|
|
272
|
+
if (doc.visibilityState === "hidden") leave();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Send what is queued, now: ONE POST per mount. A queue of one posts `{ place, action }` as
|
|
278
|
+
* `reportAction` always has, so a recorder older than the batch reads it; more posts
|
|
279
|
+
* `{ actions }`, and a recorder that refuses the batch (an older one answers 400) gets them one by
|
|
280
|
+
* one instead. `leaving` sends through `sendBeacon`, which outlives the page. Never throws.
|
|
281
|
+
*/
|
|
282
|
+
export async function flushEngagementActions(options: { leaving?: boolean } = {}): Promise<void> {
|
|
283
|
+
const sends: Promise<void>[] = [];
|
|
284
|
+
for (const [basePath, queue] of queues) {
|
|
285
|
+
if (queue.quiet) clearTimeout(queue.quiet);
|
|
286
|
+
if (queue.due) clearTimeout(queue.due);
|
|
287
|
+
queues.delete(basePath);
|
|
288
|
+
for (let i = 0; i < queue.actions.length; i += ACTION_BATCH_MAX) {
|
|
289
|
+
sends.push(sendActions(`${basePath}/action`, queue.actions.slice(i, i + ACTION_BATCH_MAX), queue, options.leaving === true));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
await Promise.all(sends);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function sendActions(url: string, actions: QueuedAction[], queue: ActionQueue, leaving: boolean): Promise<void> {
|
|
296
|
+
const [only] = actions;
|
|
297
|
+
const body = JSON.stringify(actions.length === 1 && only ? { place: only.place, action: only.action } : { actions });
|
|
298
|
+
try {
|
|
299
|
+
if (leaving && queue.beaconImpl?.(url, new Blob([body], { type: "application/json" }))) return;
|
|
300
|
+
const res = await queue.fetchImpl(url, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "content-type": "application/json" },
|
|
303
|
+
credentials: "same-origin",
|
|
304
|
+
body,
|
|
305
|
+
keepalive: true,
|
|
306
|
+
});
|
|
307
|
+
await drain(res);
|
|
308
|
+
if (res.status === 400 && actions.length > 1) {
|
|
309
|
+
for (const a of actions) await sendActions(url, [a], queue, false);
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// See the header: telemetry never becomes the user's problem.
|
|
313
|
+
}
|
|
216
314
|
}
|
|
217
315
|
|
|
218
316
|
/**
|
|
219
317
|
* Report that a FEATURE was used — `reportAction("search")` — beside the place it was used on
|
|
220
|
-
* (forge task 3470). Returns whether
|
|
318
|
+
* (forge task 3470). Returns whether it was queued to send.
|
|
221
319
|
*
|
|
222
320
|
* The owner's question is "what features do they use and ignore"; a page view cannot answer it,
|
|
223
321
|
* because one page holds several features. So the app names the action at the press, from the
|
|
@@ -231,6 +329,12 @@ export interface ReportActionOptions extends ReportPlaceOptions {
|
|
|
231
329
|
* · the same silence: session-probed (the probe's answer is shared with `reportPlace`, one per
|
|
232
330
|
* page load), fire-and-forget, never throws, never logs.
|
|
233
331
|
*
|
|
332
|
+
* 🔴 BATCHED (forge task 4034): an action is queued, and the queue goes as ONE request after
|
|
333
|
+
* {@link ACTION_QUIET_MS} of quiet, {@link ACTION_MAX_WAIT_MS} after its first action at most, at
|
|
334
|
+
* {@link ACTION_BATCH_MAX}, and when the page is left ({@link flushEngagementActions}). So a
|
|
335
|
+
* feature the owner adds to the record costs no request per click — his words: *"batching some
|
|
336
|
+
* interactions with debounces will lower traffic"*.
|
|
337
|
+
*
|
|
234
338
|
* NOT de-duplicated, unlike `reportPlace`: three plays are three uses. Only call it from apps
|
|
235
339
|
* whose recorder was mounted WITH `actions` — elsewhere `/action` is a 404 in the console.
|
|
236
340
|
*/
|
|
@@ -243,19 +347,21 @@ export async function reportAction(action: string, options: ReportActionOptions
|
|
|
243
347
|
if (!(await sessionAnswer)) return false;
|
|
244
348
|
}
|
|
245
349
|
const place = options.place ?? (typeof window === "undefined" ? "/" : currentLocationPlace());
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
350
|
+
const basePath = options.basePath ?? ENGAGEMENT_BASE_PATH;
|
|
351
|
+
const queue: ActionQueue = queues.get(basePath) ?? { actions: [], fetchImpl: doFetch, beaconImpl: undefined, quiet: null, due: null };
|
|
352
|
+
queue.fetchImpl = doFetch;
|
|
353
|
+
queue.beaconImpl = options.beaconImpl ?? defaultBeacon();
|
|
354
|
+
queue.actions.push({ place, action, at: Date.now() });
|
|
355
|
+
queues.set(basePath, queue);
|
|
356
|
+
listenForLeave();
|
|
357
|
+
if (queue.actions.length >= ACTION_BATCH_MAX) {
|
|
358
|
+
void flushEngagementActions();
|
|
255
359
|
return true;
|
|
256
|
-
} catch {
|
|
257
|
-
return false;
|
|
258
360
|
}
|
|
361
|
+
if (queue.quiet) clearTimeout(queue.quiet);
|
|
362
|
+
queue.quiet = setTimeout(() => void flushEngagementActions(), ACTION_QUIET_MS);
|
|
363
|
+
queue.due ??= setTimeout(() => void flushEngagementActions(), ACTION_MAX_WAIT_MS);
|
|
364
|
+
return true;
|
|
259
365
|
}
|
|
260
366
|
|
|
261
367
|
/** Detaches the listeners `startLocationBeacon` installed. */
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { APPLE_GATE_DEVICES, type AppleDevice } from './appleDevices';
|
|
5
6
|
import type { DesignViolation } from './designAudit';
|
|
6
7
|
import { compareDesignBaseline, designLedger, formatDesignBaseline, parseDesignBaseline } from './designBaseline';
|
|
7
8
|
|
|
@@ -89,6 +90,41 @@ test('detail() prints every recorded finding with its route, and nothing for a c
|
|
|
89
90
|
expect(ledger.detail()).toContain('button "Pin"');
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
describe('an Apple walk that reads blank is walked again (forge task 4039)', () => {
|
|
94
|
+
/** A page whose walk reads no text for its first `blankReads` design audits, then one LAY-10. */
|
|
95
|
+
const pageBlankFor = (blankReads: number) => {
|
|
96
|
+
let probes = 0;
|
|
97
|
+
return {
|
|
98
|
+
probes: () => probes,
|
|
99
|
+
viewportSize: () => ({ width: 320, height: 568 }),
|
|
100
|
+
evaluate: async <R, A>(_fn: (arg: A) => R | Promise<R>, arg: A): Promise<R> => {
|
|
101
|
+
if (arg && typeof arg === 'object' && 'targets' in arg) return { width: 320, height: 568, scrollWidth: 320, offscreen: [], fields: [], targets: [] } as R; // measurePhone
|
|
102
|
+
if (!(arg && typeof arg === 'object' && 'rules' in arg)) return undefined as R; // settleInPage, atRestInPage
|
|
103
|
+
probes++;
|
|
104
|
+
const textNodes = probes > blankReads ? 12 : 0;
|
|
105
|
+
return { violations: textNodes ? [v('LAY-10')] : [], census: { textNodes, targets: 0, controlRows: 0, tableCells: 0, contrastChecked: 0, coarse: true, innerWidth: 320 } } as R;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const phone = APPLE_GATE_DEVICES[0] as AppleDevice;
|
|
110
|
+
|
|
111
|
+
test('🔴 a shell of icons that gets its screen a moment later is audited once it has', async () => {
|
|
112
|
+
const page = pageBlankFor(2);
|
|
113
|
+
const ledger = designLedger(tmp());
|
|
114
|
+
await ledger.auditApple(page, 'parked', phone);
|
|
115
|
+
expect(page.probes()).toBe(3);
|
|
116
|
+
expect(ledger.detail()).toContain(`parked@${phone.slug}`);
|
|
117
|
+
expect(ledger.detail()).toContain('LAY-10');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('🔴 a screen still blank at the deadline is refused — no empty finding list reads as clean', async () => {
|
|
121
|
+
const page = pageBlankFor(Number.POSITIVE_INFINITY);
|
|
122
|
+
const ledger = designLedger(tmp());
|
|
123
|
+
await expect(ledger.auditApple(page, 'parked', phone, { blankWaitMs: 1_000 })).rejects.toThrow(`parked@${phone.slug} rendered no visible text`);
|
|
124
|
+
expect(page.probes()).toBeGreaterThan(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
92
128
|
describe('a width that reads blank is given its moment to paint (forge task 3890)', () => {
|
|
93
129
|
/** A page whose probe reads no text for its first `blankReads` audits, then one LAY-13. */
|
|
94
130
|
const pageBlankFor = (blankReads: number) => {
|
|
@@ -38,9 +38,26 @@ export interface ResizablePage extends EvaluatingPage {
|
|
|
38
38
|
|
|
39
39
|
export type ColorScheme = 'light' | 'dark';
|
|
40
40
|
|
|
41
|
-
/** How long a
|
|
41
|
+
/** How long a read that finds no visible text is given to paint before the audit calls it blank. */
|
|
42
42
|
const BLANK_WAIT_MS = 10_000;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Reads the page until it has text or the deadline passes. A page a test is holding can be blank
|
|
46
|
+
* for a moment that is the machine's, not the app's: a pane the CSS dropped before its replacement
|
|
47
|
+
* mounts, or a shell of icons before the screen's chunk lands, and a loaded machine stretches that
|
|
48
|
+
* past `settleInPage`'s calm. Both of the ledger's audits come through here; what stays blank at
|
|
49
|
+
* the deadline is the caller's to refuse.
|
|
50
|
+
*/
|
|
51
|
+
async function readUntilPainted<R>(read: () => Promise<R>, blank: (result: R) => boolean, waitMs = BLANK_WAIT_MS): Promise<R> {
|
|
52
|
+
const deadline = Date.now() + waitMs;
|
|
53
|
+
let result = await read();
|
|
54
|
+
while (blank(result) && Date.now() < deadline) {
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
56
|
+
result = await read();
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** The color-scheme store's key (cursedbelt-core `COLOR_SCHEME_STORAGE_KEY`), said here so the
|
|
45
62
|
* ledger stays free of a runtime import from the React half. */
|
|
46
63
|
const SCHEME_KEY = 'cwip.colorScheme';
|
|
@@ -154,20 +171,19 @@ export function designLedger(baselinePath: string) {
|
|
|
154
171
|
const original = page.viewportSize();
|
|
155
172
|
for (const width of options.widths ?? APP_AUDIT_WIDTHS) {
|
|
156
173
|
await page.setViewportSize({ width, height: original?.height ?? 900 });
|
|
157
|
-
// Wait for the page to settle (`settleInPage` says what that means and why).
|
|
158
|
-
await page.evaluate(settleInPage, undefined);
|
|
159
174
|
const label = `${route}@${width}${scheme ? `:${scheme}` : ''}`;
|
|
160
|
-
let { violations, census } = await auditDesign(page, { ...options, route, width });
|
|
161
175
|
// A width change can hide what was on screen (a pane the CSS drops at this width) before the
|
|
162
|
-
// code that mounts its replacement runs
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
// code that mounts its replacement runs: vault's entry read blank at 390 under the ship's
|
|
177
|
+
// three gates at once (forge task 3890). So a blank census is settled and read again.
|
|
178
|
+
const { violations, census } = await readUntilPainted(
|
|
179
|
+
async () => {
|
|
180
|
+
// Wait for the page to settle (`settleInPage` says what that means and why).
|
|
181
|
+
await page.evaluate(settleInPage, undefined);
|
|
182
|
+
return auditDesign(page, { ...options, route, width });
|
|
183
|
+
},
|
|
184
|
+
(read) => read.census.textNodes === 0,
|
|
185
|
+
options.blankWaitMs,
|
|
186
|
+
);
|
|
171
187
|
// A sweep that read no text audited nothing, and an empty finding list from it would
|
|
172
188
|
// read as a clean page — the false green the touch-target sweep was once caught in.
|
|
173
189
|
if (census.textNodes === 0) throw new Error(`design audit: ${label} rendered no visible text — nothing was audited`);
|
|
@@ -205,10 +221,13 @@ export function designLedger(baselinePath: string) {
|
|
|
205
221
|
* a WebKit context made from `appleContext(devices, device)`, judged by the design audit and
|
|
206
222
|
* the phone rules — a long page read through a screen at a time, the keyboard raised over
|
|
207
223
|
* `options.keyboard` — and recorded as `<route>@<device slug>`. The ratchet is the same file.
|
|
224
|
+
* A walk that read no text is walked again: an app's `load` can land on a shell of icons, with
|
|
225
|
+
* a phone's pass the first to see it (forge task 4039 — collections, station, family).
|
|
208
226
|
*/
|
|
209
|
-
async auditApple(page: ApplePage, route: string, device: AppleDevice, options: AuditAppleOptions = {}): Promise<void> {
|
|
227
|
+
async auditApple(page: ApplePage, route: string, device: AppleDevice, options: AuditAppleOptions & { blankWaitMs?: number } = {}): Promise<void> {
|
|
210
228
|
const label = `${route}@${device.slug}`;
|
|
211
|
-
const {
|
|
229
|
+
const { blankWaitMs, ...walk } = options;
|
|
230
|
+
const { violations, textNodes } = await readUntilPainted(() => auditApple(page, route, device, walk), (read) => read.textNodes === 0, blankWaitMs);
|
|
212
231
|
if (textNodes === 0) throw new Error(`design audit: ${label} rendered no visible text — nothing was audited`);
|
|
213
232
|
byRoute.set(label, violations);
|
|
214
233
|
},
|