@nodegx/mcp 0.2.0 → 0.2.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/dist/backend/cli.js +182 -182
- package/dist/cloud-bundle.cjs +747 -745
- package/dist/kit-extract.cjs +228 -228
- package/dist/kit-extract.inputs.json +113 -113
- package/dist/noodl-mcp.cjs +6073 -1090
- package/dist/package.json +1 -1
- package/dist/render-harness/data/node-catalog-enriched.json +242 -9
- package/dist/render-harness/drive-steps.js +222 -18
- package/dist/render-harness/measure-from-disk.js +172 -2
- package/dist/render-harness/render-from-disk.js +136 -136
- package/dist/src/external/viewer/noodl.viewer.js +1 -1
- package/dist/starter-assets/noodl_modules/inter/Inter-Bold.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-Medium.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-Regular.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-SemiBold.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/LICENSE.txt +93 -0
- package/dist/starter-assets/noodl_modules/inter/manifest.json +7 -0
- package/dist/starter-assets/noodl_modules/inter/styles.css +49 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/lucide.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/manifest.json +229 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/styles.css +2007 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/LICENCES.json +445 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-cat.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-dog.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-horse.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-sheep.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-1.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-2.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-3.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-4.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-5.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-6.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-bakery.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-board.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-bread.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-carrots.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-grocer.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-market.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-plate.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-aurora.svg +28 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-canyon.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-city-dusk.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-coast.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-dune.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-forest-fog.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-red-rock.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-ridge.svg +28 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-shore.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-skyline.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/manifest.json +4 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-cafe.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-coffee-shop.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-desk.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-market.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-meeting.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/portrait.svg +11 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-brick.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-coffee.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-earth.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-pebbles.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-snow.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-soil.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-1.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-2.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-3.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-baker.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-carpenter.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-chef.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-hands-clay.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-leather-bench.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-machine-shop.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-potter.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-welder.webp +0 -0
- package/package.json +4 -3
package/dist/package.json
CHANGED
|
@@ -5355,14 +5355,16 @@
|
|
|
5355
5355
|
"runtimeBehavior": "Ports are mirrored from the targeted cloud function component by the editor's CloudFunctionAdapter. The `function` input (edit-only) lists every component that contains a `noodl.cloud.request` node — cloud function components live under the `/#__cloud__/` sheet, and the parameter stores the component name without that prefix. Each name in the Request node's `params` list becomes an `in-<name>` input; each name in the Response nodes' `params` lists becomes an `out-<name>` output. All are type '*'; an authoring tool must read the target component's Request/Response `params` to know which ports exist.",
|
|
5356
5356
|
"examples": [
|
|
5357
5357
|
"fn-cloud-function-roundtrip",
|
|
5358
|
-
"fn-aggregate-stats-function"
|
|
5358
|
+
"fn-aggregate-stats-function",
|
|
5359
|
+
"fn-play-audio-a-cloud-function-returns"
|
|
5359
5360
|
],
|
|
5360
5361
|
"patterns": [
|
|
5361
5362
|
"Button `onClick` → `call`, `out-<name>` → visual inputs: the request/response cycle for an action the client must not perform itself.",
|
|
5362
5363
|
"`done` → a Query Records fetch action: refresh client data after a server-side write."
|
|
5363
5364
|
],
|
|
5364
5365
|
"antiPatterns": [
|
|
5365
|
-
"Wiring `out-` outputs somewhere without also handling `failure` leaves errors invisible — surface `error` in the UI or react to `failure`."
|
|
5366
|
+
"Wiring `out-` outputs somewhere without also handling `failure` leaves errors invisible — surface `error` in the UI or react to `failure`.",
|
|
5367
|
+
"Putting a new WAV header around audio a function returns, or re-encoding it: an API's audio is almost always a finished file (audio/wav, audio/mpeg), and wrapping it again plays its header as a click and anything after the samples as loud static. Return the base64 and its MIME type, and play it as it came; only audio/L16 or audio/pcm is raw samples (example fn-play-audio-a-cloud-function-returns)."
|
|
5366
5368
|
],
|
|
5367
5369
|
"relatedNodes": [
|
|
5368
5370
|
"noodl.cloud.request",
|
|
@@ -69597,7 +69599,8 @@
|
|
|
69597
69599
|
"runtimeBehavior": "Each name in the `params` stringlist becomes a runtime-registered input (stored as `pm-<name>`, type '*') collected into the success reply. Declared port groups make the ports conditional on `status`: `params` (and its `pm-` inputs) exist while status is 'success' or unset, `errorMessage` exists while status is 'failure'. CloudFunctionAdapter mirrors the union of all Response nodes' `params` onto the browser-side caller as `out-<name>` outputs.",
|
|
69598
69600
|
"examples": [
|
|
69599
69601
|
"fn-cloud-function-roundtrip",
|
|
69600
|
-
"fn-aggregate-stats-function"
|
|
69602
|
+
"fn-aggregate-stats-function",
|
|
69603
|
+
"fn-play-audio-a-cloud-function-returns"
|
|
69601
69604
|
],
|
|
69602
69605
|
"patterns": [
|
|
69603
69606
|
"Two Response nodes — one with status 'success', one with status 'failure' and an `errorMessage` — triggered from the two branches of a Condition: explicit success/failure replies.",
|
|
@@ -82118,9 +82121,9 @@
|
|
|
82118
82121
|
{
|
|
82119
82122
|
"id": "call_stats",
|
|
82120
82123
|
"type": "CloudFunction2",
|
|
82121
|
-
"label": "Call
|
|
82124
|
+
"label": "Call Task Stats",
|
|
82122
82125
|
"parameters": {
|
|
82123
|
-
"function": "
|
|
82126
|
+
"function": "Cloud Functions/Task Stats"
|
|
82124
82127
|
}
|
|
82125
82128
|
}
|
|
82126
82129
|
],
|
|
@@ -82140,7 +82143,7 @@
|
|
|
82140
82143
|
]
|
|
82141
82144
|
},
|
|
82142
82145
|
{
|
|
82143
|
-
"name": "/Cloud Functions/Task Stats",
|
|
82146
|
+
"name": "/#__cloud__/Cloud Functions/Task Stats",
|
|
82144
82147
|
"nodes": [
|
|
82145
82148
|
{
|
|
82146
82149
|
"id": "fn_request",
|
|
@@ -82271,9 +82274,9 @@
|
|
|
82271
82274
|
{
|
|
82272
82275
|
"id": "call_add",
|
|
82273
82276
|
"type": "CloudFunction2",
|
|
82274
|
-
"label": "Call
|
|
82277
|
+
"label": "Call Add Numbers",
|
|
82275
82278
|
"parameters": {
|
|
82276
|
-
"function": "
|
|
82279
|
+
"function": "Cloud Functions/Add Numbers"
|
|
82277
82280
|
}
|
|
82278
82281
|
}
|
|
82279
82282
|
],
|
|
@@ -82305,7 +82308,7 @@
|
|
|
82305
82308
|
]
|
|
82306
82309
|
},
|
|
82307
82310
|
{
|
|
82308
|
-
"name": "/Cloud Functions/Add Numbers",
|
|
82311
|
+
"name": "/#__cloud__/Cloud Functions/Add Numbers",
|
|
82309
82312
|
"nodes": [
|
|
82310
82313
|
{
|
|
82311
82314
|
"id": "fn_request",
|
|
@@ -82361,6 +82364,236 @@
|
|
|
82361
82364
|
}
|
|
82362
82365
|
]
|
|
82363
82366
|
},
|
|
82367
|
+
{
|
|
82368
|
+
"id": "fn-play-audio-a-cloud-function-returns",
|
|
82369
|
+
"title": "Speak a phrase: text-to-speech in a cloud function, played in the page as it came",
|
|
82370
|
+
"description": "A text-to-speech (TTS) API is called from a cloud function, so its key stays on the server, and the page plays what comes back. Three decisions matter. First, the key is a NAME: Secret reads GEMINI_API_KEY from the backend's secrets, the Function sends it in the x-goog-api-key header (never in the URL, which ends up in logs), and the key is never a port value the page can see. Second, and the one that is easy to get wrong: the audio an API returns today is almost always a FINISHED FILE (WAV, MP3 or OGG, with its own header, and sometimes extra chunks after the samples), so the cloud function passes the base64 and its MIME type through untouched and the page plays it as it is. Only a MIME type that says raw samples (audio/L16 or audio/pcm, which older Gemini text-to-speech returned) needs a WAV header put around it. Wrapping a finished file in a second header plays its first header as a click and anything after the samples as half a second of loud static. Gemini's current text-to-speech model returns audio/wav, so the wrap branch does not run for it. Third, failures stay readable: a script that threw answers through a failure Response with its own message, and a missing secret answers with a fixed sentence, because the Secret's own error names a server path the page should not show. Playback is a Function calling `new Audio(...)`, run from the call's Done; the call starts from a press, so the browser allows sound. In this function the value inputs have Run On Value Change unticked, because a signal runs each Function.",
|
|
82371
|
+
"demonstrates": [
|
|
82372
|
+
"CloudFunction2",
|
|
82373
|
+
"noodl.cloud.request",
|
|
82374
|
+
"noodl.cloud.secret",
|
|
82375
|
+
"noodl.cloud.response",
|
|
82376
|
+
"JavaScriptFunction"
|
|
82377
|
+
],
|
|
82378
|
+
"components": [
|
|
82379
|
+
{
|
|
82380
|
+
"name": "/Phrase Screen",
|
|
82381
|
+
"nodes": [
|
|
82382
|
+
{
|
|
82383
|
+
"id": "root",
|
|
82384
|
+
"type": "Group",
|
|
82385
|
+
"label": "Screen",
|
|
82386
|
+
"parameters": {
|
|
82387
|
+
"flexDirection": "column"
|
|
82388
|
+
},
|
|
82389
|
+
"children": [
|
|
82390
|
+
"phrase_input",
|
|
82391
|
+
"say_btn",
|
|
82392
|
+
"status_text"
|
|
82393
|
+
]
|
|
82394
|
+
},
|
|
82395
|
+
{
|
|
82396
|
+
"id": "phrase_input",
|
|
82397
|
+
"type": "net.noodl.controls.textinput",
|
|
82398
|
+
"label": "Phrase",
|
|
82399
|
+
"parent": "root",
|
|
82400
|
+
"parameters": {
|
|
82401
|
+
"placeholder": "Bonjour"
|
|
82402
|
+
}
|
|
82403
|
+
},
|
|
82404
|
+
{
|
|
82405
|
+
"id": "say_btn",
|
|
82406
|
+
"type": "net.noodl.controls.button",
|
|
82407
|
+
"label": "Say it",
|
|
82408
|
+
"parent": "root",
|
|
82409
|
+
"parameters": {
|
|
82410
|
+
"label": "Say it"
|
|
82411
|
+
}
|
|
82412
|
+
},
|
|
82413
|
+
{
|
|
82414
|
+
"id": "status_text",
|
|
82415
|
+
"type": "Text",
|
|
82416
|
+
"label": "What went wrong",
|
|
82417
|
+
"parent": "root"
|
|
82418
|
+
},
|
|
82419
|
+
{
|
|
82420
|
+
"id": "call_speak",
|
|
82421
|
+
"type": "CloudFunction2",
|
|
82422
|
+
"label": "Call Speak",
|
|
82423
|
+
"parameters": {
|
|
82424
|
+
"function": "Speak"
|
|
82425
|
+
}
|
|
82426
|
+
},
|
|
82427
|
+
{
|
|
82428
|
+
"id": "play",
|
|
82429
|
+
"type": "JavaScriptFunction",
|
|
82430
|
+
"label": "Play it as it came",
|
|
82431
|
+
"parameters": {
|
|
82432
|
+
"functionScript": "const audio = Inputs.audio;\nconst mimeType = String(Inputs.mimeType || 'audio/mpeg');\nif (!audio) return;\n\n// A finished file (audio/wav, audio/mpeg, audio/ogg ...) plays exactly as it came.\nlet url = 'data:' + mimeType + ';base64,' + audio;\n\n// Only raw 16-bit samples need a WAV header around them.\nif (/^audio\\/(l16|pcm)/i.test(mimeType)) {\n const rate = Number((/rate=(\\d+)/i.exec(mimeType) || [])[1]) || 24000;\n const samples = Uint8Array.from(atob(audio), (c) => c.charCodeAt(0));\n const wav = new DataView(new ArrayBuffer(44 + samples.length));\n const text = (at, s) => [...s].forEach((ch, i) => wav.setUint8(at + i, ch.charCodeAt(0)));\n text(0, 'RIFF'); wav.setUint32(4, 36 + samples.length, true); text(8, 'WAVE');\n text(12, 'fmt '); wav.setUint32(16, 16, true); wav.setUint16(20, 1, true); wav.setUint16(22, 1, true);\n wav.setUint32(24, rate, true); wav.setUint32(28, rate * 2, true); wav.setUint16(32, 2, true); wav.setUint16(34, 16, true);\n text(36, 'data'); wav.setUint32(40, samples.length, true);\n new Uint8Array(wav.buffer, 44).set(samples);\n let bytes = '';\n new Uint8Array(wav.buffer).forEach((b) => (bytes += String.fromCharCode(b)));\n url = 'data:audio/wav;base64,' + btoa(bytes);\n}\n\nawait new Audio(url).play();",
|
|
82433
|
+
"runOnChange-in-audio": false,
|
|
82434
|
+
"runOnChange-in-mimeType": false
|
|
82435
|
+
}
|
|
82436
|
+
}
|
|
82437
|
+
],
|
|
82438
|
+
"connections": [
|
|
82439
|
+
{
|
|
82440
|
+
"fromId": "phrase_input",
|
|
82441
|
+
"fromProperty": "onTextChanged",
|
|
82442
|
+
"toId": "call_speak",
|
|
82443
|
+
"toProperty": "in-text"
|
|
82444
|
+
},
|
|
82445
|
+
{
|
|
82446
|
+
"fromId": "say_btn",
|
|
82447
|
+
"fromProperty": "onClick",
|
|
82448
|
+
"toId": "call_speak",
|
|
82449
|
+
"toProperty": "call"
|
|
82450
|
+
},
|
|
82451
|
+
{
|
|
82452
|
+
"fromId": "call_speak",
|
|
82453
|
+
"fromProperty": "out-audio",
|
|
82454
|
+
"toId": "play",
|
|
82455
|
+
"toProperty": "in-audio"
|
|
82456
|
+
},
|
|
82457
|
+
{
|
|
82458
|
+
"fromId": "call_speak",
|
|
82459
|
+
"fromProperty": "out-mimeType",
|
|
82460
|
+
"toId": "play",
|
|
82461
|
+
"toProperty": "in-mimeType"
|
|
82462
|
+
},
|
|
82463
|
+
{
|
|
82464
|
+
"fromId": "call_speak",
|
|
82465
|
+
"fromProperty": "done",
|
|
82466
|
+
"toId": "play",
|
|
82467
|
+
"toProperty": "run"
|
|
82468
|
+
},
|
|
82469
|
+
{
|
|
82470
|
+
"fromId": "call_speak",
|
|
82471
|
+
"fromProperty": "error",
|
|
82472
|
+
"toId": "status_text",
|
|
82473
|
+
"toProperty": "text"
|
|
82474
|
+
}
|
|
82475
|
+
]
|
|
82476
|
+
},
|
|
82477
|
+
{
|
|
82478
|
+
"name": "/#__cloud__/Speak",
|
|
82479
|
+
"nodes": [
|
|
82480
|
+
{
|
|
82481
|
+
"id": "speak_request",
|
|
82482
|
+
"type": "noodl.cloud.request",
|
|
82483
|
+
"label": "Say this",
|
|
82484
|
+
"parameters": {
|
|
82485
|
+
"params": "text"
|
|
82486
|
+
}
|
|
82487
|
+
},
|
|
82488
|
+
{
|
|
82489
|
+
"id": "speak_key",
|
|
82490
|
+
"type": "noodl.cloud.secret",
|
|
82491
|
+
"label": "GEMINI_API_KEY",
|
|
82492
|
+
"parameters": {
|
|
82493
|
+
"name": "GEMINI_API_KEY"
|
|
82494
|
+
}
|
|
82495
|
+
},
|
|
82496
|
+
{
|
|
82497
|
+
"id": "speak_tts",
|
|
82498
|
+
"type": "JavaScriptFunction",
|
|
82499
|
+
"label": "Ask for the audio, pass it through",
|
|
82500
|
+
"parameters": {
|
|
82501
|
+
"functionScript": "const key = Inputs.key;\nconst text = String(Inputs.text || '');\nif (!key || !text) return;\n\nconst model = 'gemini-3.8-flash-tts';\nconst response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent', {\n method: 'POST',\n headers: { 'content-type': 'application/json', 'x-goog-api-key': key },\n body: JSON.stringify({\n contents: [{ parts: [{ text }] }],\n generationConfig: {\n responseModalities: ['AUDIO'],\n speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } }\n }\n })\n});\nconst body = await response.json();\nif (!response.ok) throw new Error((body.error && body.error.message) || 'Text-to-speech refused the request');\n\nconst part = body.candidates && body.candidates[0] && body.candidates[0].content && body.candidates[0].content.parts[0];\nconst audio = part && part.inlineData;\nif (!audio || !audio.data) throw new Error('Text-to-speech sent no audio');\n\n// Pass the audio through exactly as it came: do not decode it, re-encode it or add a header here.\nOutputs.audio = audio.data;\nOutputs.mimeType = audio.mimeType;",
|
|
82502
|
+
"runOnChange-in-key": false,
|
|
82503
|
+
"runOnChange-in-text": false
|
|
82504
|
+
}
|
|
82505
|
+
},
|
|
82506
|
+
{
|
|
82507
|
+
"id": "speak_ok",
|
|
82508
|
+
"type": "noodl.cloud.response",
|
|
82509
|
+
"label": "The audio, and what kind it is",
|
|
82510
|
+
"parameters": {
|
|
82511
|
+
"params": "audio,mimeType"
|
|
82512
|
+
}
|
|
82513
|
+
},
|
|
82514
|
+
{
|
|
82515
|
+
"id": "speak_failed",
|
|
82516
|
+
"type": "noodl.cloud.response",
|
|
82517
|
+
"label": "Why not",
|
|
82518
|
+
"parameters": {
|
|
82519
|
+
"status": "failure"
|
|
82520
|
+
}
|
|
82521
|
+
},
|
|
82522
|
+
{
|
|
82523
|
+
"id": "speak_not_set_up",
|
|
82524
|
+
"type": "noodl.cloud.response",
|
|
82525
|
+
"label": "Not set up yet",
|
|
82526
|
+
"parameters": {
|
|
82527
|
+
"status": "failure",
|
|
82528
|
+
"errorMessage": "Speech is not set up yet: add GEMINI_API_KEY to the backend's secrets."
|
|
82529
|
+
}
|
|
82530
|
+
}
|
|
82531
|
+
],
|
|
82532
|
+
"connections": [
|
|
82533
|
+
{
|
|
82534
|
+
"fromId": "speak_request",
|
|
82535
|
+
"fromProperty": "receive",
|
|
82536
|
+
"toId": "speak_key",
|
|
82537
|
+
"toProperty": "fetch"
|
|
82538
|
+
},
|
|
82539
|
+
{
|
|
82540
|
+
"fromId": "speak_request",
|
|
82541
|
+
"fromProperty": "pm-text",
|
|
82542
|
+
"toId": "speak_tts",
|
|
82543
|
+
"toProperty": "in-text"
|
|
82544
|
+
},
|
|
82545
|
+
{
|
|
82546
|
+
"fromId": "speak_key",
|
|
82547
|
+
"fromProperty": "value",
|
|
82548
|
+
"toId": "speak_tts",
|
|
82549
|
+
"toProperty": "in-key"
|
|
82550
|
+
},
|
|
82551
|
+
{
|
|
82552
|
+
"fromId": "speak_key",
|
|
82553
|
+
"fromProperty": "done",
|
|
82554
|
+
"toId": "speak_tts",
|
|
82555
|
+
"toProperty": "run"
|
|
82556
|
+
},
|
|
82557
|
+
{
|
|
82558
|
+
"fromId": "speak_key",
|
|
82559
|
+
"fromProperty": "failure",
|
|
82560
|
+
"toId": "speak_not_set_up",
|
|
82561
|
+
"toProperty": "send"
|
|
82562
|
+
},
|
|
82563
|
+
{
|
|
82564
|
+
"fromId": "speak_tts",
|
|
82565
|
+
"fromProperty": "out-audio",
|
|
82566
|
+
"toId": "speak_ok",
|
|
82567
|
+
"toProperty": "pm-audio"
|
|
82568
|
+
},
|
|
82569
|
+
{
|
|
82570
|
+
"fromId": "speak_tts",
|
|
82571
|
+
"fromProperty": "out-mimeType",
|
|
82572
|
+
"toId": "speak_ok",
|
|
82573
|
+
"toProperty": "pm-mimeType"
|
|
82574
|
+
},
|
|
82575
|
+
{
|
|
82576
|
+
"fromId": "speak_tts",
|
|
82577
|
+
"fromProperty": "success",
|
|
82578
|
+
"toId": "speak_ok",
|
|
82579
|
+
"toProperty": "send"
|
|
82580
|
+
},
|
|
82581
|
+
{
|
|
82582
|
+
"fromId": "speak_tts",
|
|
82583
|
+
"fromProperty": "error",
|
|
82584
|
+
"toId": "speak_failed",
|
|
82585
|
+
"toProperty": "errorMessage"
|
|
82586
|
+
},
|
|
82587
|
+
{
|
|
82588
|
+
"fromId": "speak_tts",
|
|
82589
|
+
"fromProperty": "failure",
|
|
82590
|
+
"toId": "speak_failed",
|
|
82591
|
+
"toProperty": "send"
|
|
82592
|
+
}
|
|
82593
|
+
]
|
|
82594
|
+
}
|
|
82595
|
+
]
|
|
82596
|
+
},
|
|
82364
82597
|
{
|
|
82365
82598
|
"id": "fn-send-notification-email",
|
|
82366
82599
|
"title": "Cloud function that sends an email notification",
|
|
@@ -492,6 +492,122 @@ var require_src = __commonJS({
|
|
|
492
492
|
// viewport height with content laid out past it is clipped; a page that is
|
|
493
493
|
// simply short has nothing past it. That difference is the whole of
|
|
494
494
|
// \`clipped-page\`, and neither number alone can tell them apart.
|
|
495
|
+
// P112-OWN019-LAPTOPGAPS (R39) \u2014 where the INK is, not where the boxes are. Richard on RL1's laptop page: a
|
|
496
|
+
// feature number in a Columns "1 4" row had a fifth of the page to itself, ~215 px of nothing before its title;
|
|
497
|
+
// L1's pricing toggle and price sat ~260 px left of their centred heading. Every box there is well-formed \u2014 the
|
|
498
|
+
// number's column box abuts the title's \u2014 so the measure is each child's ink: the union of its shown text runs
|
|
499
|
+
// (a Range, so a full-width text block counts only as far as its words reach) and its painted boxes. Desktop only:
|
|
500
|
+
// that is where a row has room to open a hole. Raw numbers here; the judgement is summarise's.
|
|
501
|
+
const layout = { rowGaps: [], drifts: [] };
|
|
502
|
+
// (The API guard is for the fake DOMs the unit suites hand this expression; every browser has both.)
|
|
503
|
+
if (vw >= 1024 && typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
|
|
504
|
+
const shown = new Set(visible);
|
|
505
|
+
const textInk = [];
|
|
506
|
+
const range = document.createRange();
|
|
507
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
508
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
509
|
+
if (!n.textContent.trim() || !shown.has(n.parentElement)) continue;
|
|
510
|
+
range.selectNodeContents(n);
|
|
511
|
+
const r = range.getBoundingClientRect();
|
|
512
|
+
if (r.width > 0 && r.height > 0) textInk.push({ node: n, r });
|
|
513
|
+
}
|
|
514
|
+
const PAINTED_TAGS = ['IMG', 'SVG', 'CANVAS', 'VIDEO', 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'svg'];
|
|
515
|
+
const paintedInk = visible
|
|
516
|
+
.filter((el) => {
|
|
517
|
+
if (PAINTED_TAGS.includes(el.tagName)) return true;
|
|
518
|
+
const cs = styleOf(el);
|
|
519
|
+
const bg = cs.backgroundColor;
|
|
520
|
+
return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' || parseFloat(cs.borderTopWidth) > 0;
|
|
521
|
+
})
|
|
522
|
+
.map((el) => ({ el, r: el.getBoundingClientRect() }))
|
|
523
|
+
.filter((p) => p.r.width > 0 && p.r.height > 0 && p.r.width < vw * 0.98);
|
|
524
|
+
const inkOf = (el) => {
|
|
525
|
+
let l = Infinity, t = Infinity, rr = -Infinity, b = -Infinity, text = '';
|
|
526
|
+
const take = (r) => { l = Math.min(l, r.left); t = Math.min(t, r.top); rr = Math.max(rr, r.right); b = Math.max(b, r.bottom); };
|
|
527
|
+
for (const x of textInk) if (el.contains(x.node)) { take(x.r); if (!text) text = x.node.textContent.trim().slice(0, 50); }
|
|
528
|
+
for (const p of paintedInk) if (el === p.el || el.contains(p.el)) take(p.r);
|
|
529
|
+
return l === Infinity ? null : { left: l, top: t, right: rr, bottom: b, width: rr - l, height: b - t, text };
|
|
530
|
+
};
|
|
531
|
+
const ROW_GAP_MIN_PX = 160;
|
|
532
|
+
for (const parent of visible) {
|
|
533
|
+
const kids = [...parent.children].filter((k) => shown.has(k));
|
|
534
|
+
if (kids.length < 2) continue;
|
|
535
|
+
const pr = parent.getBoundingClientRect();
|
|
536
|
+
if (pr.width < 600) continue;
|
|
537
|
+
const inks = kids.map(inkOf).filter(Boolean);
|
|
538
|
+
if (inks.length < 2) continue;
|
|
539
|
+
// Side by side: consecutive by left edge, overlapping vertically, the second starting after the first ends.
|
|
540
|
+
const byLeft = [...inks].sort((a, b) => a.left - b.left);
|
|
541
|
+
for (let i = 1; i < byLeft.length; i++) {
|
|
542
|
+
const a = byLeft[i - 1], b = byLeft[i];
|
|
543
|
+
const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
544
|
+
const gap = b.left - a.right;
|
|
545
|
+
if (overlap <= 0 || gap < ROW_GAP_MIN_PX) continue;
|
|
546
|
+
layout.rowGaps.push({ cls: cls(parent), gap: round(gap), rowWidth: round(pr.width), tallest: round(Math.max(a.height, b.height)), left: a.text, right: b.text });
|
|
547
|
+
}
|
|
548
|
+
// Stacked, each starting below the one before, and the parts of ONE section: every part under 240 px tall (a
|
|
549
|
+
// page whose children are whole sections compared section against section \u2014 measured on L1 and GL1, s5). The
|
|
550
|
+
// section's axis is set by a centred part among its first two (its heading, usually); L1's pricing is just
|
|
551
|
+
// two parts, a centred heading block and a toggle-and-price group ~260 px left of it.
|
|
552
|
+
const byTop = [...inks].sort((a, b) => a.top - b.top);
|
|
553
|
+
if (!byTop.every((k, i) => i === 0 || k.top >= byTop[i - 1].bottom - 2)) continue;
|
|
554
|
+
// Two or more tall parts, not one: RL1's waitlist is eyebrow, heading, line and a 290 px form card in ONE group,
|
|
555
|
+
// and "any part > 240" skipped it \u2014 Richard saw the eyebrow off the centred heading (s6). A page of sections
|
|
556
|
+
// has many tall children; a section with one card has one.
|
|
557
|
+
if (byTop.filter((k) => k.height > 240).length > 1) continue;
|
|
558
|
+
const centre = pr.left + pr.width / 2;
|
|
559
|
+
const offOf = (k) => k.left + k.width / 2 - centre;
|
|
560
|
+
const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
|
|
561
|
+
if (!centred.some((k) => byTop.indexOf(k) < 2)) continue;
|
|
562
|
+
for (const k of byTop) {
|
|
563
|
+
if (Math.abs(offOf(k)) > 40 && k.width < pr.width * 0.8) {
|
|
564
|
+
layout.drifts.push({ cls: cls(parent), offBy: round(offOf(k)), sectionWidth: round(pr.width), text: k.text, centredText: centred[0].text });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// P112-OWN019-LETTEREDGE \u2014 words hard against the edge of the box that frames them. Richard on TL2's hero: seven
|
|
571
|
+
// outlined day chips, each letter pressed to its chip's left edge. The chip Group centred its child the way CSS
|
|
572
|
+
// would, but a Text stretches to its Group's width and sets its letters left, so the centring moved nothing. The
|
|
573
|
+
// frame is the nearest of the text's first four ancestors with a border or a fill (a page-wide band is not a frame,
|
|
574
|
+
// nor an inline highlight); the reading is the gap between the words' own ink (a Range) and the frame's inner left
|
|
575
|
+
// and right edges. Every width: a chip is a chip on a phone too. Raw numbers here; the judgement is summarise's.
|
|
576
|
+
layout.edgeTexts = [];
|
|
577
|
+
const EDGE_TEXT_MAX_GAP = 3;
|
|
578
|
+
if (typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
|
|
579
|
+
const shown = new Set(visible);
|
|
580
|
+
const range = document.createRange();
|
|
581
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
582
|
+
const framed = (el) => {
|
|
583
|
+
const cs = styleOf(el);
|
|
584
|
+
if (cs.display === 'inline') return false;
|
|
585
|
+
const bg = cs.backgroundColor;
|
|
586
|
+
return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' ||
|
|
587
|
+
parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0;
|
|
588
|
+
};
|
|
589
|
+
const frames = new Set();
|
|
590
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
591
|
+
const text = n.textContent.trim();
|
|
592
|
+
if (!text || !shown.has(n.parentElement)) continue;
|
|
593
|
+
let frame = null;
|
|
594
|
+
for (let e = n.parentElement, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) {
|
|
595
|
+
if (framed(e)) { frame = e; break; }
|
|
596
|
+
}
|
|
597
|
+
if (!frame || frames.has(frame)) continue;
|
|
598
|
+
const fr = frame.getBoundingClientRect();
|
|
599
|
+
if (fr.width < 16 || fr.width >= vw * 0.98) continue;
|
|
600
|
+
range.selectNodeContents(n);
|
|
601
|
+
const r = range.getBoundingClientRect();
|
|
602
|
+
if (!(r.width > 0 && r.height > 0)) continue;
|
|
603
|
+
const fcs = styleOf(frame);
|
|
604
|
+
const left = r.left - (fr.left + (parseFloat(fcs.borderLeftWidth) || 0));
|
|
605
|
+
const right = fr.right - (parseFloat(fcs.borderRightWidth) || 0) - r.right;
|
|
606
|
+
frames.add(frame);
|
|
607
|
+
if (Math.min(left, right) < EDGE_TEXT_MAX_GAP) layout.edgeTexts.push({ cls: cls(frame), text: text.slice(0, 50), left: round(left), right: round(right), frameWidth: round(fr.width), frameHeight: round(fr.height) });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
495
611
|
const contentBottom = visible.reduce((lowest, el) => {
|
|
496
612
|
const r = el.getBoundingClientRect();
|
|
497
613
|
return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
|
|
@@ -499,6 +615,7 @@ var require_src = __commonJS({
|
|
|
499
615
|
|
|
500
616
|
return {
|
|
501
617
|
lists,
|
|
618
|
+
layout,
|
|
502
619
|
// FLD-012 AC3 \u2014 the SIZE of the population every rule below is computed over, and the number of
|
|
503
620
|
// elements the visibility correction took out of it. Reported directly because a rule's finding
|
|
504
621
|
// count can move for several reasons at once, and 'the fix changed what is measured' is a
|
|
@@ -637,9 +754,24 @@ var require_src = __commonJS({
|
|
|
637
754
|
*/
|
|
638
755
|
SingleGround: "single-ground",
|
|
639
756
|
NoImagery: "no-imagery",
|
|
640
|
-
NoDisplayType: "no-display-type"
|
|
757
|
+
NoDisplayType: "no-display-type",
|
|
758
|
+
/**
|
|
759
|
+
* P112-OWN019-LAPTOPGAPS (R39, Richard 2026-10-14). On a laptop, two things side by side in one row with a hole
|
|
760
|
+
* between their ink — RL1's feature number given a fifth of the page by a Columns "1 4" row, L1's text and card
|
|
761
|
+
* pushed to the row's two edges. A warning: a hole can be meant, and the screenshots are the arbiter.
|
|
762
|
+
*/
|
|
763
|
+
RowGap: "row-gap",
|
|
764
|
+
/** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
|
|
765
|
+
AlignmentDrift: "alignment-drift",
|
|
766
|
+
/**
|
|
767
|
+
* P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
|
|
768
|
+
* box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
|
|
769
|
+
*/
|
|
770
|
+
TextAgainstEdge: "text-against-edge"
|
|
641
771
|
};
|
|
642
772
|
var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
773
|
+
var ROW_GAP_MIN_TALLEST = 48;
|
|
774
|
+
var ROW_GAP_MIN_SHARE = 0.12;
|
|
643
775
|
var POVERTY_FINDINGS = [RenderFinding.SingleGround, RenderFinding.NoImagery, RenderFinding.NoDisplayType];
|
|
644
776
|
function isPovertyFinding(finding) {
|
|
645
777
|
return Boolean(finding) && POVERTY_FINDINGS.indexOf(finding.code) !== -1;
|
|
@@ -855,6 +987,43 @@ var require_src = __commonJS({
|
|
|
855
987
|
evidence: v.grounds.values
|
|
856
988
|
});
|
|
857
989
|
}
|
|
990
|
+
if (isDesktop && v.layout) {
|
|
991
|
+
const gaps = (v.layout.rowGaps || []).filter((g) => (g.left || g.right) && g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
|
|
992
|
+
const seen2 = /* @__PURE__ */ new Set();
|
|
993
|
+
for (const g of gaps) {
|
|
994
|
+
const key = `${g.left}|${g.right}`;
|
|
995
|
+
if (seen2.has(key) || seen2.size >= 3) continue;
|
|
996
|
+
seen2.add(key);
|
|
997
|
+
add({
|
|
998
|
+
code: RenderFinding.RowGap,
|
|
999
|
+
severity: "warning",
|
|
1000
|
+
viewport: name,
|
|
1001
|
+
message: `"${g.left}" and "${g.right}" sit side by side with ${g.gap}px of empty space between them (${Math.round(g.gap / g.rowWidth * 100)}% of the ${g.rowWidth}px row). A label or number beside text reads as one thing at its own width with a small gap \u2014 a row Group, the label at its natural width and the text growing \u2014 not a column given a share of the page (Columns widths are proportions); two parts pushed to a wide row's edges leave the same hole: give the row a max width and a gap. Look at this width's screenshot.`,
|
|
1002
|
+
evidence: g
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
const drifts = (v.layout.drifts || []).slice(0, 3);
|
|
1006
|
+
for (const d of drifts) {
|
|
1007
|
+
add({
|
|
1008
|
+
code: RenderFinding.AlignmentDrift,
|
|
1009
|
+
severity: "warning",
|
|
1010
|
+
viewport: name,
|
|
1011
|
+
message: `This section centres "${d.centredText}", but "${d.text}" sits ${Math.abs(d.offBy)}px ${d.offBy < 0 ? "left" : "right"} of that centre. One section, one alignment: centre the rest too, or align everything to the same left edge. Look at this width's screenshot.`,
|
|
1012
|
+
evidence: d
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const edges = v.layout && v.layout.edgeTexts || [];
|
|
1017
|
+
if (edges.length) {
|
|
1018
|
+
const named = edges.slice(0, 3).map((e) => `"${e.text}" ${Math.max(0, Math.min(e.left, e.right))}px from the ${e.left <= e.right ? "left" : "right"} edge of its ${e.frameWidth}\xD7${e.frameHeight}px box`);
|
|
1019
|
+
add({
|
|
1020
|
+
code: RenderFinding.TextAgainstEdge,
|
|
1021
|
+
severity: "warning",
|
|
1022
|
+
viewport: name,
|
|
1023
|
+
message: `${plural(edges.length, "text sits", "texts sit")} hard against the edge of the outlined or filled box that frames ${edges.length === 1 ? "it" : "them"}: ${named.join("; ")}. Centring a Group does not centre a Text's letters \u2014 the Text stretches to the Group's width and sets them left \u2014 so give the Text textAlignX: center, or give the box padding. Look at this width's screenshot.`,
|
|
1024
|
+
evidence: edges.slice(0, 6)
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
858
1027
|
if (v.consoleErrors && v.consoleErrors.length) {
|
|
859
1028
|
add({
|
|
860
1029
|
code: RenderFinding.ConsoleError,
|
|
@@ -964,6 +1133,7 @@ var require_render_report = __commonJS({
|
|
|
964
1133
|
var WS_MODULE = HARNESS_PATHS.WS_MODULE.path;
|
|
965
1134
|
var {
|
|
966
1135
|
DEFAULT_VIEWPORTS,
|
|
1136
|
+
NAMED_VIEWPORTS,
|
|
967
1137
|
DESKTOP_WIDTH,
|
|
968
1138
|
RenderFinding,
|
|
969
1139
|
SEVERITY_ORDER,
|
|
@@ -1499,7 +1669,7 @@ var require_render_report = __commonJS({
|
|
|
1499
1669
|
}
|
|
1500
1670
|
function parseViewports2(spec) {
|
|
1501
1671
|
if (!spec) return DEFAULT_VIEWPORTS;
|
|
1502
|
-
const known = new Map(
|
|
1672
|
+
const known = new Map(NAMED_VIEWPORTS.map((v) => [v.name, v]));
|
|
1503
1673
|
return spec.split(",").map((token) => {
|
|
1504
1674
|
const trimmed = token.trim();
|
|
1505
1675
|
if (known.has(trimmed)) return known.get(trimmed);
|
|
@@ -2312,10 +2482,12 @@ function locateExpression(target) {
|
|
|
2312
2482
|
if (all.length === 0) return { found: false, matched: 0 };
|
|
2313
2483
|
let last;
|
|
2314
2484
|
for (const el of all) {
|
|
2315
|
-
|
|
2485
|
+
// 'instant': under \`html { scroll-behavior: smooth }\` a plain scrollIntoView glides, the rect below is read
|
|
2486
|
+
// before it arrives, and every target off screen was refused as off screen (P112-OWN019-TAPHOLD, L1's page).
|
|
2487
|
+
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
2316
2488
|
const r = el.getBoundingClientRect();
|
|
2317
2489
|
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
2318
|
-
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)' }; continue; }
|
|
2490
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)', target: describe(el) }; continue; }
|
|
2319
2491
|
// Lent pointer-events for the one measurement: elementFromPoint skips an element that has none.
|
|
2320
2492
|
const before = el.style.pointerEvents;
|
|
2321
2493
|
el.style.pointerEvents = 'auto';
|
|
@@ -2540,6 +2712,20 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
|
|
|
2540
2712
|
const value = JSON.parse(last);
|
|
2541
2713
|
return value && typeof value === "object" ? { ...value, settledMs: Date.now() - t0 } : value;
|
|
2542
2714
|
};
|
|
2715
|
+
const SCROLLS = `(() => { const a = [scrollX, scrollY]; for (const e of document.querySelectorAll('*')) if (e.scrollTop || e.scrollLeft) a.push(e.scrollTop, e.scrollLeft); return a.join(','); })()`;
|
|
2716
|
+
const scrollSettled = async () => {
|
|
2717
|
+
const t0 = Date.now();
|
|
2718
|
+
let last = await evaluate(SCROLLS);
|
|
2719
|
+
let moved = false;
|
|
2720
|
+
for (let same = 0; same < 2 && Date.now() - t0 < 2e3; ) {
|
|
2721
|
+
await frames();
|
|
2722
|
+
const next = await evaluate(SCROLLS);
|
|
2723
|
+
if (next === last) same++;
|
|
2724
|
+
else same = 0, moved = true;
|
|
2725
|
+
last = next;
|
|
2726
|
+
}
|
|
2727
|
+
return moved ? { scrolledMs: Date.now() - t0, still: Date.now() - t0 >= 2e3 } : null;
|
|
2728
|
+
};
|
|
2543
2729
|
const press = async (target) => {
|
|
2544
2730
|
let p = await evaluate(locateExpression(target));
|
|
2545
2731
|
for (let i = 0; i < 20 && !p.found; i++) {
|
|
@@ -2576,6 +2762,11 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
|
|
|
2576
2762
|
row.pressed = p.target;
|
|
2577
2763
|
if (p.matched > 1) row.matched = p.matched;
|
|
2578
2764
|
await frames();
|
|
2765
|
+
const glide = await scrollSettled();
|
|
2766
|
+
if (glide) {
|
|
2767
|
+
row.scrolledMs = glide.scrolledMs;
|
|
2768
|
+
if (glide.still) row.stillScrolling = true;
|
|
2769
|
+
}
|
|
2579
2770
|
} else if (verb === "type") {
|
|
2580
2771
|
if (typeof step.type !== "string") throw new UsageError(`Step ${i + 1}: type takes a string.`);
|
|
2581
2772
|
if (step.into !== void 0) await press(step.into);
|
|
@@ -2629,27 +2820,40 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
|
|
|
2629
2820
|
row.url = await evaluate("location.pathname + location.hash");
|
|
2630
2821
|
} else if (verb === "watch") {
|
|
2631
2822
|
const w = step.watch && typeof step.watch === "object" ? step.watch : {};
|
|
2632
|
-
if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T }.`);
|
|
2633
|
-
const
|
|
2634
|
-
|
|
2823
|
+
if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T } or { look: [T, T, \u2026] }.`);
|
|
2824
|
+
const unknown = Object.keys(w).filter((k) => !["look", "for_ms", "expect"].includes(k));
|
|
2825
|
+
if (unknown.length) throw new UsageError(`Step ${i + 1}: watch does not take ${unknown.map((k) => JSON.stringify(k)).join(", ")} \u2014 it takes look, for_ms and expect.`);
|
|
2826
|
+
if (step.expect !== void 0 && w.expect !== void 0) throw new UsageError(`Step ${i + 1}: give watch one expect, beside it or inside it, not both.`);
|
|
2827
|
+
const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? w.for_ms ?? 1500)));
|
|
2828
|
+
const expect = step.expect !== void 0 ? step.expect : w.expect;
|
|
2829
|
+
const many = Array.isArray(w.look);
|
|
2830
|
+
const targets = many ? w.look : [w.look];
|
|
2831
|
+
if (targets.length === 0 || targets.length > 8) throw new UsageError(`Step ${i + 1}: watch looks at 1 to 8 targets.`);
|
|
2832
|
+
const expr = `Promise.all([${targets.map((t) => lookExpression(t)).join(", ")}])`;
|
|
2635
2833
|
const keyOf = (v) => v && typeof v === "object" ? JSON.stringify({ text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity === void 0 ? void 0 : Math.round(v.opacity * 10) / 10 }) : "null";
|
|
2636
|
-
const states = [];
|
|
2834
|
+
const states = targets.map(() => []);
|
|
2637
2835
|
const t0 = Date.now();
|
|
2638
2836
|
while (Date.now() - t0 <= forMs) {
|
|
2639
|
-
const
|
|
2837
|
+
const vs = await evaluate(expr);
|
|
2640
2838
|
const at = Date.now() - t0;
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2839
|
+
vs.forEach((v, k) => {
|
|
2840
|
+
const list = states[k];
|
|
2841
|
+
const last = list[list.length - 1];
|
|
2842
|
+
if (last && last.key === keyOf(v)) last.until = at;
|
|
2843
|
+
else list.push({ key: keyOf(v), from: at, until: at, look: v && typeof v === "object" ? { text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity } : null });
|
|
2844
|
+
});
|
|
2644
2845
|
await wait(25);
|
|
2645
2846
|
}
|
|
2646
|
-
|
|
2647
|
-
|
|
2847
|
+
const shown = (list) => list.map(({ from, until, look }) => ({ from, until, look }));
|
|
2848
|
+
row.value = many ? targets.map((t, k) => ({ target: t, states: shown(states[k]) })) : shown(states[0]);
|
|
2849
|
+
const never = expect && typeof expect === "object" ? expect.never : void 0;
|
|
2648
2850
|
if (never && typeof never === "object") {
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2851
|
+
for (let k = 0; k < targets.length && row.ok; k++) {
|
|
2852
|
+
const hit = states[k].find((st) => st.look && Object.keys(never).every((key) => matches(st.look[key], never[key])));
|
|
2853
|
+
if (hit) {
|
|
2854
|
+
row.ok = false;
|
|
2855
|
+
row.error = `${many ? `${JSON.stringify(targets[k])}: ` : ""}${JSON.stringify(never)} was seen from ${hit.from} to ${hit.until} ms: ${JSON.stringify(hit.look)}`;
|
|
2856
|
+
}
|
|
2653
2857
|
}
|
|
2654
2858
|
}
|
|
2655
2859
|
} else if (verb === "viewport") {
|