kaloko 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +31 -0
- package/schemas/run.v1.json +11 -0
- package/schemas/scenario.v1.json +9 -5
- package/skills/kaloko/SKILL.md +3 -1
- package/skills/kaloko/reference/acceptance.md +8 -2
- package/skills/kaloko/reference/evaluation.md +12 -0
- package/src/cli.mjs +25 -10
- package/src/lib/build.mjs +3 -1
- package/src/lib/capture.mjs +72 -28
- package/src/lib/evaluate.mjs +112 -12
- package/src/lib/mail.mjs +6 -4
- package/src/lib/manifest-core.mjs +17 -2
- package/src/lib/pii.mjs +607 -0
- package/src/lib/recording.mjs +11 -2
- package/src/lib/run.mjs +3 -1
- package/src/lib/secrets.mjs +5 -3
- package/src/lib/triage-core.mjs +165 -0
- package/src/lib/triage.mjs +123 -0
- package/templates/scenario.yml +1 -0
- package/viewer/fonts/OFL-IBMPlexMono.txt +93 -0
- package/viewer/fonts/OFL-InstrumentSans.txt +93 -0
- package/viewer/fonts/ibm-plex-mono-400-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-400-latin.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin-ext.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin.woff2 +0 -0
- package/viewer/index.html +112 -13
package/README.md
CHANGED
|
@@ -219,6 +219,10 @@ Every AI verdict comes with one sentence saying why. Criteria marked
|
|
|
219
219
|
`vision: true`, and answers the text evaluator is unsure about, get a second
|
|
220
220
|
look at the screenshot from a vision model (`ANTHROPIC_API_KEY`, or through
|
|
221
221
|
Kaloko); `kaloko build` lists what to act on with the score and the reason.
|
|
222
|
+
As an experiment you can switch on in Settings, Clef on Cloudflare also names
|
|
223
|
+
the kind of problem each screen and its recording show (overlap, cut-off text,
|
|
224
|
+
a raw translation key, an endless spinner); reviewers turn the right findings
|
|
225
|
+
into comments with one click, and findings never change a verdict.
|
|
222
226
|
The `a11y` pack walks the page with Tab (order, visible focus, traps, skip
|
|
223
227
|
link) and reads names from the accessibility tree. Pixel diffs skip the
|
|
224
228
|
regions a step ignores; `kaloko walk --stability 3` and `kaloko stability`
|
|
@@ -410,6 +414,16 @@ captured HTML, page text and console output.
|
|
|
410
414
|
`npx kaloko doctor` checks that every variable the config names is set and that
|
|
411
415
|
each environment lets you in.
|
|
412
416
|
|
|
417
|
+
**Personal data.** `capture.mask` (CSS selectors) hides personal data in
|
|
418
|
+
everything a step stores: a striped box on the screenshot, `[hidden]` of the
|
|
419
|
+
same length in the HTML and the page outline, and the same places in traces,
|
|
420
|
+
recordings and captured e-mails. `pii: redact` on a scenario finds e-mails,
|
|
421
|
+
phone numbers, birth numbers, IBANs and addresses by itself; `pii:
|
|
422
|
+
pseudonymize` puts consistent fake values in their place instead. An
|
|
423
|
+
environment with `pii: required` gets no run without it, and `npx kaloko share`
|
|
424
|
+
refuses a run that still holds an unmasked e-mail or phone number, naming the
|
|
425
|
+
step and element (`--allow-pii` overrides). Checks always read the real page.
|
|
426
|
+
|
|
413
427
|
Set `browser.channel: chrome` to use an installed Chrome, or run
|
|
414
428
|
`npx kaloko browser install` for the Chromium of kaloko's Playwright
|
|
415
429
|
(`KALOKO_BROWSER_CHANNEL=chromium` then uses it, which is how CI runs).
|
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -78,6 +78,28 @@
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
]
|
|
81
|
+
},
|
|
82
|
+
"triage": {
|
|
83
|
+
"description": "Triage (#65, experimental): Cloudflare's Clef names the kind of problem on each evaluated screen and in chosen frames of its recording (overlap, cut-off text, raw key, endless spinner…). Findings are advisory and never change a verdict. Runs through the organization's service once an admin switches the experiment on (Settings → Security → Experiments), or with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. false turns it off.",
|
|
84
|
+
"oneOf": [
|
|
85
|
+
{ "const": false },
|
|
86
|
+
{
|
|
87
|
+
"type": "object",
|
|
88
|
+
"additionalProperties": false,
|
|
89
|
+
"properties": {
|
|
90
|
+
"model": { "enum": ["clef", "clef-flash"], "default": "clef", "description": "clef found more on the first bench; clef-flash answers about twice as fast." },
|
|
91
|
+
"keep": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.7, "description": "Findings at or above this probability show on the canvas; lower ones fold under Less likely." },
|
|
92
|
+
"maybe": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5, "description": "Findings below this probability are dropped." },
|
|
93
|
+
"max_width": { "type": "integer", "minimum": 320, "maximum": 2048, "default": 1024 },
|
|
94
|
+
"tile_height": { "type": "integer", "minimum": 400, "maximum": 4000, "default": 1400 },
|
|
95
|
+
"max_tiles": { "type": "integer", "minimum": 1, "maximum": 4, "default": 4 },
|
|
96
|
+
"frames_per_step": { "type": "integer", "minimum": 0, "maximum": 12, "default": 6, "description": "Frames chosen from a step's recording (around taps, sharp changes and the end); 0 skips recordings." },
|
|
97
|
+
"frames_per_run": { "type": "integer", "minimum": 0, "default": 60 },
|
|
98
|
+
"max_calls": { "type": "integer", "minimum": 0, "default": 200, "description": "Uncached triage requests per evaluation." },
|
|
99
|
+
"cascade": { "type": "boolean", "default": false, "description": "Let Clef settle semantic criteria the text evaluator is unsure about when its answer is clear (#71); the vision evaluator is then asked only for the rest. Off: Clef's answer is recorded beside the verdict (clef_score) for comparison." }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
]
|
|
81
103
|
}
|
|
82
104
|
}
|
|
83
105
|
},
|
|
@@ -102,6 +124,14 @@
|
|
|
102
124
|
"trace": { "type": "boolean", "default": true, "description": "Keep a trace per walked step (actions, network requests, console) shown under Technical; false or walk --no-trace turns it off." }
|
|
103
125
|
}
|
|
104
126
|
},
|
|
127
|
+
"pii": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": false,
|
|
130
|
+
"description": "Personal data (#64).",
|
|
131
|
+
"properties": {
|
|
132
|
+
"allow": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Values that look like personal data but are not, such as the company's contact e-mail or phone number. * matches any text (*@acme.example). Neither masked nor stopped by kaloko share." }
|
|
133
|
+
}
|
|
134
|
+
},
|
|
105
135
|
"share": {
|
|
106
136
|
"type": "object",
|
|
107
137
|
"additionalProperties": false,
|
|
@@ -205,6 +235,7 @@
|
|
|
205
235
|
"description": "Base URL, or per-locale base URLs (key \"default\" is the fallback). ${VAR} is replaced from the environment, e.g. https://${REVIEW_HOST} for review apps."
|
|
206
236
|
},
|
|
207
237
|
"readonly": { "type": "boolean", "default": false },
|
|
238
|
+
"pii": { "enum": ["required", "redact", "pseudonymize", "off"], "description": "Personal data in runs of this environment. redact or pseudonymize: every run hides it (placeholders, or consistent fake values). required: a run is only created when the scenario or kaloko start --pii hides it, e.g. for production or a local copy of production data." },
|
|
208
239
|
"app": {
|
|
209
240
|
"type": "object",
|
|
210
241
|
"additionalProperties": false,
|
package/schemas/run.v1.json
CHANGED
|
@@ -358,6 +358,13 @@
|
|
|
358
358
|
"type": "object",
|
|
359
359
|
"description": "node|locale → revision, entry, status and author of a draft head."
|
|
360
360
|
},
|
|
361
|
+
"pii": {
|
|
362
|
+
"type": [
|
|
363
|
+
"object",
|
|
364
|
+
"null"
|
|
365
|
+
],
|
|
366
|
+
"description": "Personal data hidden in this run (#64): { hidden: true, mode: redact | pseudonymize, auto, captures }. The canvas says so, so a blank place is not read as a bug."
|
|
367
|
+
},
|
|
361
368
|
"app_version": {
|
|
362
369
|
"type": [
|
|
363
370
|
"string",
|
|
@@ -497,6 +504,10 @@
|
|
|
497
504
|
"quality": {
|
|
498
505
|
"type": "object",
|
|
499
506
|
"description": "Capture quality when not the canvas default: format, scale, masked (count), annotations [{n, criterion, x, y}]"
|
|
507
|
+
},
|
|
508
|
+
"pii": {
|
|
509
|
+
"type": "object",
|
|
510
|
+
"description": "Personal data hidden on this capture (#64): mode (redact, pseudonymize), auto (detection on), kinds {kind: count}, rects [{x, y, w, h, kind}] in CSS px of the page, marked on the canvas."
|
|
500
511
|
}
|
|
501
512
|
}
|
|
502
513
|
}
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -186,6 +186,10 @@
|
|
|
186
186
|
},
|
|
187
187
|
"capture": {
|
|
188
188
|
"$ref": "#/$defs/capture"
|
|
189
|
+
},
|
|
190
|
+
"pii": {
|
|
191
|
+
"enum": ["redact", "pseudonymize"],
|
|
192
|
+
"description": "Personal data in every capture of the flow: redact hides it behind placeholders and boxes, pseudonymize replaces it with fake values that stay the same for one value throughout the run. Both detect e-mails, phone numbers, birth dates, birth numbers, IBANs, account numbers and addresses; capture.mask adds selectors."
|
|
189
193
|
}
|
|
190
194
|
},
|
|
191
195
|
"$defs": {
|
|
@@ -849,11 +853,11 @@
|
|
|
849
853
|
"description": "WebP quality"
|
|
850
854
|
},
|
|
851
855
|
"mask": {
|
|
852
|
-
"
|
|
853
|
-
|
|
854
|
-
"
|
|
855
|
-
|
|
856
|
-
"description": "CSS selectors
|
|
856
|
+
"oneOf": [
|
|
857
|
+
{ "type": "array", "items": { "type": "string", "minLength": 1 } },
|
|
858
|
+
{ "const": "auto" }
|
|
859
|
+
],
|
|
860
|
+
"description": "Personal data to hide in every output of the capture (screenshot, HTML, page outline, evaluators, trace, recording): CSS selectors, auto (detect e-mails, phone numbers, birth dates, birth numbers, IBANs, account numbers and addresses), or selectors with auto among them. The scenario's and the step's masks add up."
|
|
857
861
|
},
|
|
858
862
|
"annotate": {
|
|
859
863
|
"type": "boolean",
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -102,7 +102,9 @@ step or accept a run on a person's behalf, and never hand out a signed
|
|
|
102
102
|
acceptance record (`kaloko signoff`) for a run a person has not accepted; report what waits for them
|
|
103
103
|
(`kaloko review`, MCP `get_review_queue`) instead. Ask the person
|
|
104
104
|
for secret values (passwords, tokens, gate credentials) and for sign-ins only
|
|
105
|
-
they can do; never write secrets into the config, a scenario or a script.
|
|
105
|
+
they can do; never write secrets into the config, a scenario or a script. When
|
|
106
|
+
`kaloko share` stops on personal data, mask it and capture again; `--allow-pii`
|
|
107
|
+
only when a person says that value may be uploaded.
|
|
106
108
|
|
|
107
109
|
## Where to look
|
|
108
110
|
|
|
@@ -153,7 +153,7 @@ documentation, manuals and training material set print quality on the scenario o
|
|
|
153
153
|
capture:
|
|
154
154
|
format: png # lossless
|
|
155
155
|
scale: 2 # 2× device pixels, crisp in a PDF
|
|
156
|
-
mask: ["[data-qa=email]", ".customer-name"] #
|
|
156
|
+
mask: ["[data-qa=email]", ".customer-name"] # personal data hidden in the image, HTML, outline, trace and video
|
|
157
157
|
annotate: true # numbered marks on the elements of criteria with `highlight`, in criteria order
|
|
158
158
|
```
|
|
159
159
|
|
|
@@ -161,7 +161,13 @@ A node's `capture:` overrides the scenario's. Crops for a manual take padding in
|
|
|
161
161
|
`await crop('#checkout-summary', 'Order summary', { padding: 16 })`. Print quality costs storage (a
|
|
162
162
|
2× PNG is several times a 1× WebP), so keep it for documentation scenarios, not for every run. The
|
|
163
163
|
canvas still loads a small WebP thumbnail; the lightbox and `latest.image` serve the full file.
|
|
164
|
-
Masks
|
|
164
|
+
Masks cover every output of the step: the screenshot gets a striped box, the HTML and the page outline get
|
|
165
|
+
`[hidden]` of the same length, traces, recordings and captured e-mails are covered too; checks still read the real
|
|
166
|
+
page. On data copied from production add `pii: redact` to the scenario (automatic detection of e-mails, phones,
|
|
167
|
+
birth numbers, IBANs, addresses) or `pii: pseudonymize` (consistent fake values, readable screens). An environment
|
|
168
|
+
with `pii: required` refuses a run without it, and `kaloko share` stops on an unmasked e-mail or phone number and
|
|
169
|
+
names the step and element: mask it and capture again, list a company contact under `pii.allow` in the config, and
|
|
170
|
+
use `--allow-pii` only when a person says the value may go up.
|
|
165
171
|
|
|
166
172
|
## Keeping plan and scenario aligned
|
|
167
173
|
|
|
@@ -68,6 +68,18 @@ evaluators:
|
|
|
68
68
|
Report the reason with each `needs_review` or failing criterion; when the
|
|
69
69
|
evaluator saw the screenshot, say so.
|
|
70
70
|
|
|
71
|
+
## Triage findings (experimental)
|
|
72
|
+
|
|
73
|
+
When the organization switched the experiment on, `kaloko evaluate` also asks
|
|
74
|
+
Clef, a model on Cloudflare, what kind of problem each screen and a few frames
|
|
75
|
+
of its recording show: overlap, cut-off text, raw keys, placeholders, errors,
|
|
76
|
+
endless loading, layout jumps and more. They land in the eval file and the run
|
|
77
|
+
as `findings` (kind, probability, severity, frame times) and never change a
|
|
78
|
+
verdict. Mention the shown ones (`shown: true`) as "possible problems", apart
|
|
79
|
+
from the verdicts, and fix the clear ones; `get_findings` lists them with the
|
|
80
|
+
answers reviewers gave. Do not answer findings on a reviewer's behalf unless
|
|
81
|
+
asked. `evaluators.triage: false` turns it off.
|
|
82
|
+
|
|
71
83
|
When a criterion comes back `needs_review`, read the reduced state the
|
|
72
84
|
evaluator saw (`steps/<node>/<locale>/<viewport>.capture.json`, key `state`)
|
|
73
85
|
and the screenshot. Either the page is genuinely unclear (report it), the
|
package/src/cli.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantF
|
|
|
62
62
|
import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
|
|
63
63
|
import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
|
|
64
64
|
import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
65
|
+
import { piiSettings, piiFindings, guardAllow, formatFindings } from './lib/pii.mjs'
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
|
|
@@ -117,6 +118,8 @@ Run
|
|
|
117
118
|
[--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
|
|
118
119
|
[--token-modes brand-a,brand-b | --token-modes none]
|
|
119
120
|
Design environments: the token modes to render (default every mode of the set)
|
|
121
|
+
[--pii redact|pseudonymize|off] Hide personal data in every capture (placeholders or consistent fake values);
|
|
122
|
+
default from the environment's or the scenario's pii
|
|
120
123
|
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
121
124
|
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
122
125
|
browser install [--with-deps] [--browsers chromium,webkit,firefox]
|
|
@@ -198,6 +201,8 @@ Results
|
|
|
198
201
|
hash, so unchanged captures are not sent again and a broken-off share resumes;
|
|
199
202
|
--pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
|
|
200
203
|
CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
|
|
204
|
+
[--allow-pii] An unmasked e-mail, phone number, birth number or IBAN in the run stops the upload
|
|
205
|
+
and names the step and element; --allow-pii uploads anyway
|
|
201
206
|
feedback [--run id] [--scenario id] [--json]
|
|
202
207
|
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
|
|
203
208
|
returned steps first, with their notes
|
|
@@ -518,7 +523,7 @@ async function designImport (args) {
|
|
|
518
523
|
await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
|
|
519
524
|
const current = await resolveRun({ root, output: config.output })
|
|
520
525
|
await commands.walk({ _: [], run: current.dir })
|
|
521
|
-
await commands.share({ _: [], run: current.dir, ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
|
|
526
|
+
await commands.share({ _: [], run: current.dir, ...(args['allow-pii'] ? { 'allow-pii': true } : {}), ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
|
|
522
527
|
return r
|
|
523
528
|
}
|
|
524
529
|
|
|
@@ -606,7 +611,7 @@ async function catalogFor (config, root) {
|
|
|
606
611
|
const expandRun = (value, short) => (value || '').replace('{run}', short)
|
|
607
612
|
|
|
608
613
|
// Finds the e-mail (or takes the one given) and captures it as the node's screens.
|
|
609
|
-
async function captureMail ({ config, root, run, env, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
|
|
614
|
+
async function captureMail ({ config, root, run, env, scenario = null, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
|
|
610
615
|
if (!message) {
|
|
611
616
|
const client = mailClient(env.mail, root, env)
|
|
612
617
|
// only messages that arrived during this run unless told otherwise (#52): stale e-mails are not captured silently
|
|
@@ -631,8 +636,9 @@ async function captureMail ({ config, root, run, env, node, locale, to, subject,
|
|
|
631
636
|
// Remote images are allowed for the screenshot but must not hold the capture hostage.
|
|
632
637
|
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
|
633
638
|
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {})
|
|
634
|
-
const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), date: message.date }
|
|
635
|
-
|
|
639
|
+
const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), ...(message.cc?.length ? { cc: message.cc.join(', ') } : {}), date: message.date }
|
|
640
|
+
// the scenario's masks apply to e-mails too (#64); recipients are masked whenever anything is
|
|
641
|
+
const results = await captureStep({ page, node, locale, viewports: scopeViewports(run, viewports), run, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { mail } })
|
|
636
642
|
return { subject: mail.subject, count: results.length }
|
|
637
643
|
} finally {
|
|
638
644
|
await browser.close()
|
|
@@ -803,7 +809,7 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
803
809
|
if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
|
|
804
810
|
if (node.kind === 'email' && group !== groups[0]) continue // one message, captured on the first device
|
|
805
811
|
if (node.kind === 'email' && node.mail?.to) {
|
|
806
|
-
try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
|
|
812
|
+
try { const r = await captureMail({ config, root, run, env, scenario, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
|
|
807
813
|
continue
|
|
808
814
|
}
|
|
809
815
|
const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
|
|
@@ -1077,6 +1083,8 @@ const commands = {
|
|
|
1077
1083
|
const env = environment(config, args.env)
|
|
1078
1084
|
if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
|
|
1079
1085
|
if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
|
|
1086
|
+
// personal data (#64): an environment with pii: required gets no run that would store it unmasked
|
|
1087
|
+
const pii = piiSettings({ config, env, scenario, args })
|
|
1080
1088
|
const locales = list(args.locales) || scenario.locales || ['default']
|
|
1081
1089
|
// design environments render every mode of their token set (#46): the modes come from the folder's set
|
|
1082
1090
|
const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
|
|
@@ -1089,8 +1097,9 @@ const commands = {
|
|
|
1089
1097
|
const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
|
|
1090
1098
|
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
1091
1099
|
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
1092
|
-
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
|
|
1100
|
+
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
|
|
1093
1101
|
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
1102
|
+
if (pii) say(` personal data: ${pii.mode === 'pseudonymize' ? 'replaced with fake values' : 'hidden'} in every capture`)
|
|
1094
1103
|
const addr = recipient(env.mail, meta.short)
|
|
1095
1104
|
if (addr) say(` test e-mail: ${addr}`)
|
|
1096
1105
|
say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
|
|
@@ -1189,7 +1198,7 @@ const commands = {
|
|
|
1189
1198
|
const target = args.url === true || (!conn && !args.url) ? urlFor(env, node, locale) : args.url
|
|
1190
1199
|
if (target) await page.goto(target, { waitUntil: 'domcontentloaded' })
|
|
1191
1200
|
if (args.crop) {
|
|
1192
|
-
const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run })
|
|
1201
|
+
const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run, options: captureOptions(scenario, node) })
|
|
1193
1202
|
say(`Attached ${entry.file} to ${node.id}`)
|
|
1194
1203
|
return
|
|
1195
1204
|
}
|
|
@@ -1222,7 +1231,7 @@ const commands = {
|
|
|
1222
1231
|
let since
|
|
1223
1232
|
if (args['any-time']) since = null
|
|
1224
1233
|
else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
|
|
1225
|
-
const results = await captureMail({ config, root, run, env, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
|
|
1234
|
+
const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
|
|
1226
1235
|
say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
|
|
1227
1236
|
},
|
|
1228
1237
|
|
|
@@ -1382,7 +1391,7 @@ const commands = {
|
|
|
1382
1391
|
// an e-mail node with a recipient is walked like a step: wait for the message and capture it
|
|
1383
1392
|
try {
|
|
1384
1393
|
const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
|
|
1385
|
-
const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
|
|
1394
|
+
const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
|
|
1386
1395
|
say(`✓ ${node.id} ${locale}${tag}: "${results.subject}"`)
|
|
1387
1396
|
} catch (error) {
|
|
1388
1397
|
failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
|
|
@@ -1406,7 +1415,7 @@ const commands = {
|
|
|
1406
1415
|
const records = []
|
|
1407
1416
|
const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
|
|
1408
1417
|
try {
|
|
1409
|
-
await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1418
|
+
await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0, options: captureOptions(scenario, node) }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1410
1419
|
if (!captured) await capture()
|
|
1411
1420
|
} catch (error) { await rec?.afterStep(node, { error }); throw error }
|
|
1412
1421
|
await rec?.afterStep(node, { records })
|
|
@@ -1735,6 +1744,7 @@ const commands = {
|
|
|
1735
1744
|
const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
|
|
1736
1745
|
say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
|
|
1737
1746
|
if (report.vision?.asked) say(`Vision evaluator ${report.vision.model}: ${report.vision.asked} capture(s) with flagged or uncertain criteria, ${report.vision.calls} call(s), ${report.vision.cached} cached${report.vision.skipped.length ? `; not asked: ${[...new Set(report.vision.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}` : ''}.`)
|
|
1747
|
+
if (report.triage) say(report.triage.screens ? `Triage (experimental, ${report.triage.model}): ${report.triage.screens} screen(s)${report.triage.frames ? ` and ${report.triage.frames} recording frame(s)` : ''}, ${report.triage.shown} finding(s) to look at${report.triage.findings > report.triage.shown ? ` and ${report.triage.findings - report.triage.shown} less likely` : ''}; they never change a verdict.${report.triage.skipped ? ` Stopped early: ${report.triage.skipped}` : ''}` : `Triage (experimental): not run: ${report.triage.skipped}`)
|
|
1738
1748
|
if (report.skipped.length) {
|
|
1739
1749
|
// one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
|
|
1740
1750
|
const byReason = new Map()
|
|
@@ -1800,6 +1810,11 @@ const commands = {
|
|
|
1800
1810
|
for (const it of idx.items) for (const byVp of Object.values(it.captures || {})) for (const c of Object.values(byVp)) [c.png, c.thumb, c.html, c.eval].filter(Boolean).forEach((p) => files.add(p))
|
|
1801
1811
|
}
|
|
1802
1812
|
}
|
|
1813
|
+
// personal data (#64): nothing goes up with an unmasked e-mail, phone number, birth number or IBAN in it
|
|
1814
|
+
const views = manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))).map((id) => `live/${id}/index.html`)
|
|
1815
|
+
const findings = await piiFindings({ dir: run.dir, files: [...files, ...views], manifest, allow: guardAllow({ config, env, scenario, run }) })
|
|
1816
|
+
if (findings.length && !args['allow-pii']) throw new UserError(`Not shared: the run holds ${findings.length} unmasked personal data value(s):\n${formatFindings(findings)}\nMask them (capture.mask in the scenario, or pii: redact for automatic detection) and capture the steps again. Values that are not personal data, such as a company contact, go under pii.allow in kaloko.config.yml. To share anyway: kaloko share --allow-pii.`)
|
|
1817
|
+
if (findings.length) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
|
|
1803
1818
|
const days = Number(args.expires) || config.share?.expires_days || 30
|
|
1804
1819
|
// by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
|
|
1805
1820
|
let entries = await hashFiles(run.dir, [...files])
|
package/src/lib/build.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readJson, writeJson } from './util.mjs'
|
|
|
6
6
|
import { readCaptures, runPaths, listRuns } from './run.mjs'
|
|
7
7
|
import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
|
|
8
8
|
import { layout } from './scenario.mjs'
|
|
9
|
-
import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
|
|
9
|
+
import { assembleNode, assembleStack, summarize, runPii } from './manifest-core.mjs'
|
|
10
10
|
import { packageRoot } from './schema.mjs'
|
|
11
11
|
import { variantOf } from './dimensions.mjs'
|
|
12
12
|
|
|
@@ -82,6 +82,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
82
82
|
nodes,
|
|
83
83
|
edges: scenario.edges.map((e) => ({ from: e.from, to: e.to, label: e.label || '', kind: e.kind || 'main' })),
|
|
84
84
|
summary,
|
|
85
|
+
// "personal data hidden" (#64): the canvas says so, so a blank is not taken for a bug
|
|
86
|
+
...(runPii(meta, nodes) ? { pii: runPii(meta, nodes) } : {}),
|
|
85
87
|
coverage: cov,
|
|
86
88
|
history,
|
|
87
89
|
catalog,
|
package/src/lib/capture.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { probe } from './probe.mjs'
|
|
|
5
5
|
import { pageSpecScript } from './devspec.mjs'
|
|
6
6
|
import { writeJson, readJsonl } from './util.mjs'
|
|
7
7
|
import { addCapture, runPaths } from './run.mjs'
|
|
8
|
+
import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders } from './pii.mjs'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
|
|
@@ -16,7 +17,14 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
|
|
|
16
17
|
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
|
|
17
18
|
const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
|
|
18
19
|
const g = full.getContext('2d'); g.drawImage(img, 0, 0)
|
|
19
|
-
|
|
20
|
+
// masked personal data: a dark box with diagonal stripes, so a reader sees "hidden" and not a broken page (#64)
|
|
21
|
+
for (const r of masks) {
|
|
22
|
+
const x = Math.floor(r.x * scale); const y = Math.floor(r.y * scale); const w = Math.ceil(r.w * scale); const h = Math.ceil(r.h * scale)
|
|
23
|
+
g.fillStyle = '#1f1f1d'; g.fillRect(x, y, w, h)
|
|
24
|
+
g.save(); g.beginPath(); g.rect(x, y, w, h); g.clip(); g.strokeStyle = '#3d3d38'; g.lineWidth = 3 * scale
|
|
25
|
+
for (let i = -h; i < w; i += 12 * scale) { g.beginPath(); g.moveTo(x + i, y + h); g.lineTo(x + i + h, y); g.stroke() }
|
|
26
|
+
g.restore()
|
|
27
|
+
}
|
|
20
28
|
for (const m of marks) {
|
|
21
29
|
const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
|
|
22
30
|
g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
|
|
@@ -50,12 +58,15 @@ export async function stepSpec (page, tokens = null, mode = null) {
|
|
|
50
58
|
|
|
51
59
|
export function captureOptions (scenario, node) {
|
|
52
60
|
const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
|
|
61
|
+
// masks add up: a step's own selectors never drop the scenario's (personal data stays hidden on every step)
|
|
62
|
+
const masks = [maskList(scenario?.capture?.mask), maskList(node?.capture?.mask)]
|
|
53
63
|
const q = Number(o.quality)
|
|
54
64
|
return {
|
|
55
65
|
format: o.format === 'png' ? 'png' : 'webp',
|
|
56
66
|
scale: Number(o.scale) === 2 ? 2 : 1,
|
|
57
67
|
quality: Number.isFinite(q) && q > 0 ? Math.min(1, Math.max(0.3, q)) : 0.82,
|
|
58
|
-
mask:
|
|
68
|
+
mask: [...new Set(masks.flatMap((m) => m.selectors))],
|
|
69
|
+
maskAuto: masks.some((m) => m.auto),
|
|
59
70
|
annotate: o.annotate === true,
|
|
60
71
|
// stack pages default to the first screen: hundreds of full-page shots would swamp storage
|
|
61
72
|
fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack'
|
|
@@ -121,6 +132,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
121
132
|
const original = page.viewportSize()
|
|
122
133
|
const results = []
|
|
123
134
|
let netResults = null
|
|
135
|
+
// personal data (#64): what this capture masks; recipients of a captured e-mail are masked before the body
|
|
136
|
+
const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
|
|
137
|
+
if (mask.active && extra.mail) extra = { ...extra, mail: maskMailHeaders(extra.mail, mask) }
|
|
124
138
|
// batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
|
|
125
139
|
const engine = engineOf(page)
|
|
126
140
|
const scope = run.meta?.scope
|
|
@@ -128,14 +142,38 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
128
142
|
const now = page.viewportSize()
|
|
129
143
|
if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
|
|
130
144
|
const undoVariant = await applyVariant(page, scope?.variants?.[viewport], scope?.devices?.[scope?.variants?.[viewport]?.device] || null)
|
|
145
|
+
let masking = null
|
|
131
146
|
try {
|
|
132
147
|
await settle(page)
|
|
133
148
|
const html = paths.step(stepId, locale, viewport, 'html')
|
|
134
149
|
await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
|
|
135
150
|
await mkdir(dirname(html), { recursive: true })
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
151
|
+
// the checks read the page as it is: masks change what is stored, never a verdict
|
|
152
|
+
let result
|
|
153
|
+
for (let attempt = 0; ; attempt++) {
|
|
154
|
+
try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
|
|
155
|
+
// the page navigated under us (redirect, Turbo); wait once and probe the new document
|
|
156
|
+
if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
161
|
+
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
162
|
+
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
|
|
163
|
+
await liveValueChecks({ page, node, result, url: page.url(), tokens })
|
|
164
|
+
// One mask for every output (#64): from here to the end of the capture the page shows placeholders or fake
|
|
165
|
+
// values instead of personal data, so the screenshot, HTML, outline, spec, keyboard shots and HTML view agree
|
|
166
|
+
if (mask.active) {
|
|
167
|
+
masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
|
|
168
|
+
rememberPii(masking.pairs)
|
|
169
|
+
await page.evaluate((known) => window.__kalokoMask?.update({ known }), knownPii()).catch(() => {})
|
|
170
|
+
// the outline the evaluators read comes from the masked page; checks and boxes keep the first probe
|
|
171
|
+
if (masking.rects.length || masking.pairs.length || Object.keys(masking.counts).length) {
|
|
172
|
+
const again = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)).catch(() => null)
|
|
173
|
+
if (again) for (const k of ['state', 'scoped', 'overflow', 'meta', 'design']) if (again[k] !== undefined) result[k] = again[k]
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const maskRects = (masking?.rects || []).filter((r) => r.box)
|
|
139
177
|
let shot
|
|
140
178
|
// a device preset's pixel ratio (3 on an iPhone) shapes the layout, not the image: captures stay in CSS pixels
|
|
141
179
|
// (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
|
|
@@ -152,22 +190,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
152
190
|
// WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
|
|
153
191
|
// (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
|
|
154
192
|
if (opts.scale === 2 && dpr !== 2) shotScale = 1
|
|
155
|
-
shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css'
|
|
193
|
+
shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
|
|
156
194
|
}
|
|
157
195
|
// secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
|
|
158
196
|
await writeFile(html, redact(await page.content()))
|
|
159
|
-
let result
|
|
160
|
-
for (let attempt = 0; ; attempt++) {
|
|
161
|
-
try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
|
|
162
|
-
// the page navigated under us (redirect, Turbo); wait once and probe the new document
|
|
163
|
-
if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
|
|
164
|
-
throw error
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
197
|
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
168
198
|
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
|
|
169
|
-
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
170
|
-
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
171
199
|
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
|
172
200
|
const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
|
|
173
201
|
// numbered marks on the highlighted elements, in criteria order (first box of each)
|
|
@@ -176,7 +204,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
176
204
|
let thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
|
|
177
205
|
let thumbWritten = true
|
|
178
206
|
try {
|
|
179
|
-
|
|
207
|
+
// mask boxes are drawn while encoding, at the image's own scale (Playwright's mask only works at the page's pixel ratio)
|
|
208
|
+
const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: shotScale, masks: maskRects })
|
|
180
209
|
// the extension follows the bytes (WebKit encodes PNG), so the service accepts the file
|
|
181
210
|
if (enc.imageType === 'image/png') image = paths.step(stepId, locale, viewport, 'png')
|
|
182
211
|
if (enc.thumbType === 'image/png') thumb = paths.step(stepId, locale, viewport, 'thumb.png')
|
|
@@ -184,13 +213,11 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
184
213
|
await writeFile(thumb, enc.thumb)
|
|
185
214
|
} catch (error) {
|
|
186
215
|
// never store an unmasked image when masks were asked for
|
|
187
|
-
if (
|
|
216
|
+
if (maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
|
|
188
217
|
image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
|
|
189
218
|
await writeFile(image, shot)
|
|
190
219
|
}
|
|
191
220
|
const url = page.url()
|
|
192
|
-
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the keyboard walk moves focus
|
|
193
|
-
await liveValueChecks({ page, node, result, url, tokens })
|
|
194
221
|
const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
|
|
195
222
|
const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
|
|
196
223
|
// requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
|
|
@@ -236,7 +263,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
236
263
|
let view = null
|
|
237
264
|
if (htmlView) {
|
|
238
265
|
try {
|
|
239
|
-
// the
|
|
266
|
+
// the page is still masked here (#64); the selectors are covered in the copy as well (#27)
|
|
240
267
|
const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
|
|
241
268
|
await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
|
|
242
269
|
view = { revision: rev.id, entry: rev.entry }
|
|
@@ -254,6 +281,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
254
281
|
...extra,
|
|
255
282
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
256
283
|
capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
284
|
+
// what was hidden and where (#64): the canvas marks these places, so a blank is not read as a bug
|
|
285
|
+
...(mask.active ? { pii: { mode: mask.mode, auto: mask.auto, selectors: mask.selectors.length, kinds: masking?.counts || {}, rects: (masking?.rects || []).slice(0, 200).map(({ x, y, w, h, kind }) => ({ x, y, w, h, kind })) } } : {}),
|
|
257
286
|
...(ignore.length ? { ignore } : {}),
|
|
258
287
|
...(unstable ? { unstable } : {}),
|
|
259
288
|
...(keyboard ? { keyboard } : {}),
|
|
@@ -265,27 +294,42 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
265
294
|
await writeJson(file, redactDeep(record))
|
|
266
295
|
await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
|
|
267
296
|
results.push(record)
|
|
268
|
-
} finally {
|
|
297
|
+
} finally {
|
|
298
|
+
// the page gets its own text back, also when the capture failed half-way
|
|
299
|
+
if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
|
|
300
|
+
await undoVariant()
|
|
301
|
+
}
|
|
269
302
|
}
|
|
270
303
|
const last = page.viewportSize()
|
|
271
304
|
if (original && (!last || last.width !== original.width || last.height !== original.height)) await page.setViewportSize(original)
|
|
272
305
|
return results
|
|
273
306
|
}
|
|
274
307
|
|
|
275
|
-
export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0 }) {
|
|
308
|
+
export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0, options = null }) {
|
|
276
309
|
const paths = runPaths(run.dir)
|
|
277
310
|
const el = page.locator(selector).first()
|
|
278
311
|
const name = `${node.id}-${locale}-${Date.now().toString(36)}.png`
|
|
279
312
|
const file = paths.attachment(name)
|
|
280
313
|
await mkdir(dirname(file), { recursive: true })
|
|
281
|
-
|
|
282
|
-
|
|
314
|
+
// the same mask as the step's capture (#64): masked text in the page, boxes over masked elements
|
|
315
|
+
const opts = options || captureOptions(null, node)
|
|
316
|
+
const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
|
|
317
|
+
let masking = null
|
|
318
|
+
try {
|
|
319
|
+
if (mask.active) { masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs) }
|
|
320
|
+
const boxes = (masking?.rects || []).filter((r) => r.box)
|
|
321
|
+
const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
|
|
283
322
|
const box = await el.boundingBox()
|
|
284
323
|
if (!box) throw new Error(`crop: ${selector} is not visible`)
|
|
285
|
-
|
|
324
|
+
// element plus a margin of surrounding page (documentation crops), clipped from a full-page shot
|
|
286
325
|
const x = Math.max(0, box.x + doc.x - padding); const y = Math.max(0, box.y + doc.y - padding)
|
|
287
|
-
|
|
288
|
-
|
|
326
|
+
const clip = { x, y, width: Math.min(doc.w - x, box.width + padding * 2), height: Math.min(doc.h - y, box.height + padding * 2) }
|
|
327
|
+
const png = padding > 0 || boxes.length ? await page.screenshot({ fullPage: true, clip }) : await el.screenshot()
|
|
328
|
+
if (boxes.length) {
|
|
329
|
+
const enc = await encodeImage(page, png, { format: 'png', masks: boxes.map((r) => ({ ...r, x: r.x - clip.x, y: r.y - clip.y })) })
|
|
330
|
+
await writeFile(file, enc.image)
|
|
331
|
+
} else await writeFile(file, png)
|
|
332
|
+
} finally { if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
|
|
289
333
|
const entry = { type: 'attachment', kind: 'crop', node: node.id, locale, title: title || selector, selector, file: relative(run.dir, file), viewport: page.viewportSize(), at: new Date().toISOString() }
|
|
290
334
|
await addCapture(run.dir, entry)
|
|
291
335
|
return entry
|