kaloko 0.3.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.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. package/viewer/index.html +2772 -0
@@ -0,0 +1,97 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile, appendFile } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ export const sha256 = (value) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
6
+ export const shortId = () => randomBytes(3).toString('hex')
7
+
8
+ export function slugify (value) {
9
+ return String(value)
10
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
11
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
12
+ .slice(0, 60)
13
+ }
14
+
15
+ export function today (date = new Date()) {
16
+ return date.toISOString().slice(0, 10)
17
+ }
18
+
19
+ /** Pick a localized value: string, or object keyed by locale with optional `default`. */
20
+ export function localized (value, locale) {
21
+ if (value == null) return ''
22
+ if (typeof value === 'string') return value
23
+ return value[locale] ?? value.default ?? Object.values(value)[0] ?? ''
24
+ }
25
+
26
+ export async function readJson (file, fallback) {
27
+ try {
28
+ return JSON.parse(await readFile(file, 'utf8'))
29
+ } catch (error) {
30
+ if (fallback !== undefined && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return fallback
31
+ throw error
32
+ }
33
+ }
34
+
35
+ export async function writeJson (file, data) {
36
+ await mkdir(dirname(file), { recursive: true })
37
+ await writeFile(file, JSON.stringify(data, null, 2) + '\n')
38
+ }
39
+
40
+ export async function appendJsonl (file, data) {
41
+ await mkdir(dirname(file), { recursive: true })
42
+ await appendFile(file, JSON.stringify(data) + '\n')
43
+ }
44
+
45
+ export async function readJsonl (file) {
46
+ try {
47
+ const text = await readFile(file, 'utf8')
48
+ return text.split('\n').filter(Boolean).map((line) => JSON.parse(line))
49
+ } catch (error) {
50
+ if (error.code === 'ENOENT') return []
51
+ throw error
52
+ }
53
+ }
54
+
55
+ /** Minimal glob → RegExp for path patterns: `*` within a segment, `**` across segments. */
56
+ export function globToRegExp (glob) {
57
+ let out = ''
58
+ for (let i = 0; i < glob.length; i++) {
59
+ const c = glob[i]
60
+ if (c === '*' && glob[i + 1] === '*') { out += '.*'; i++; if (glob[i + 1] === '/') i++ }
61
+ else if (c === '*') out += '[^/]*'
62
+ else if (c === '?') out += '[^/]'
63
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
64
+ }
65
+ return new RegExp('^' + out + '$')
66
+ }
67
+
68
+ export class UserError extends Error {}
69
+
70
+ /**
71
+ * Tracks consecutive step failures per session. When `limit` steps in a row fail with the same first
72
+ * error line, the remaining steps of that session are almost certainly failing on a shared
73
+ * precondition (sign-in, gate, environment down); walking them only repeats it, and repeated
74
+ * sign-in attempts can lock an account. `record` returns true once the streak reaches the limit.
75
+ */
76
+ export function failureStreak (limit = 3) {
77
+ const streaks = new Map()
78
+ return {
79
+ record (key, message) {
80
+ const line = String(message || '').split('\n')[0]
81
+ const s = streaks.get(key)
82
+ const next = s && s.line === line ? { line, n: s.n + 1 } : { line, n: 1 }
83
+ streaks.set(key, next)
84
+ return limit > 0 && next.n >= limit
85
+ },
86
+ ok (key) { streaks.delete(key) },
87
+ stopped (key) { const s = streaks.get(key); return !!(s && limit > 0 && s.n >= limit) },
88
+ line (key) { return streaks.get(key)?.line }
89
+ }
90
+ }
91
+
92
+ /** Message when the service granted a shorter share lifetime than requested (plan retention cap), else null. */
93
+ export function expiryNote (requested, granted) {
94
+ if (!Number.isFinite(requested) || !Number.isFinite(granted) || granted >= requested) return null
95
+ return `Requested ${requested} days, but the organization's plan keeps shared runs for at most ${granted} days. ` +
96
+ `The run expires in ${granted} days; use \`kaloko export\` before then to keep a copy.`
97
+ }
@@ -0,0 +1,55 @@
1
+ # Acceptance plan TASK-<id>
2
+
3
+ Source of truth for intent. Machine scenario: `qa/scenario.yml`.
4
+
5
+ ## Goal and scope
6
+
7
+ What this plan verifies and what it does not.
8
+
9
+ ## Flow
10
+
11
+ ```mermaid
12
+ flowchart LR
13
+ A[Step] --> D{Decision}
14
+ D -->|yes| B[Step]
15
+ D -->|no| C[Step]
16
+ ```
17
+
18
+ ## Steps and criteria
19
+
20
+ Session: the first screen is captured as a new visitor with the consent
21
+ dialog not yet accepted (when the site has one); the next step accepts it and
22
+ later screens run without it. Change this here when the plan tests consent
23
+ itself or needs another session state.
24
+
25
+ ### <step-id> — <title> (`screen` | `email` | `external`)
26
+
27
+ - Path: `/...` (per locale)
28
+ - Purpose: what the screen must achieve for the user.
29
+ - AC1: measurable criterion.
30
+ - AC2: measurable criterion.
31
+
32
+ ## Variants
33
+
34
+ Branches of the flow and the data that triggers them.
35
+
36
+ ## Locales and viewports
37
+
38
+ Locales; desktop 1280 × 800, mobile 390 × 844.
39
+
40
+ ## Environments and limits
41
+
42
+ Where the plan may run, whether it is `readonly`, which data it needs.
43
+ Production: `readonly` only, as an ordinary user.
44
+
45
+ ## Execution
46
+
47
+ | Step | Strategy | Why |
48
+ |---|---|---|
49
+ | <step-id> | playwright / agent / manual | reason |
50
+
51
+ ## Change log
52
+
53
+ | Date | Commit | Change |
54
+ |---|---|---|
55
+ | YYYY-MM-DD | — | First version |
@@ -0,0 +1,55 @@
1
+ # Akceptační plán TASK-<id>
2
+
3
+ Zdroj pravdy pro záměr. Strojový scénář: `qa/scenario.yml`.
4
+
5
+ ## Cíl a rozsah běhu
6
+
7
+ Co tento plán ověřuje a co ne.
8
+
9
+ ## Flow
10
+
11
+ ```mermaid
12
+ flowchart LR
13
+ A[Krok] --> D{Rozhodnutí}
14
+ D -->|ano| B[Krok]
15
+ D -->|ne| C[Krok]
16
+ ```
17
+
18
+ ## Kroky a kritéria
19
+
20
+ Session: první obrazovku snímáme jako nový návštěvník s dosud nepřijatým
21
+ consent dialogem (pokud ho web má); další krok ho přijme a další obrazovky
22
+ běží bez něj. Pokud plán testuje consent samotný nebo potřebuje jiný stav
23
+ session, změňte to zde.
24
+
25
+ ### <id-kroku> — <název> (`screen` | `email` | `external`)
26
+
27
+ - Cesta: `/cs/...` (pro každý jazyk)
28
+ - Účel: co musí obrazovka splnit pro uživatele.
29
+ - AC1: měřitelné kritérium.
30
+ - AC2: měřitelné kritérium.
31
+
32
+ ## Varianty
33
+
34
+ Větve flow a data, která je vyvolají.
35
+
36
+ ## Jazyky a viewporty
37
+
38
+ Seznam jazyků; desktop 1280 × 800, mobil 390 × 844.
39
+
40
+ ## Prostředí a omezení
41
+
42
+ Kde plán smí běžet, zda je `readonly`, jaká data potřebuje.
43
+ Na produkci jen `readonly` jako běžný uživatel.
44
+
45
+ ## Způsob provedení
46
+
47
+ | Krok | Způsob | Důvod |
48
+ |---|---|---|
49
+ | <id-kroku> | playwright / agent / manual | proč |
50
+
51
+ ## Historie změn
52
+
53
+ | Datum | Commit | Změna |
54
+ |---|---|---|
55
+ | RRRR-MM-DD | — | První verze |
@@ -0,0 +1,76 @@
1
+ schema: qawalk.config.v1
2
+ # Organization slug on kaloko.app
3
+ org: my-org
4
+ project: my-project
5
+
6
+ # Where scenarios live: next to tasks and as long-lived flows
7
+ scenarios:
8
+ - docs/tasks/*/qa/scenario.yml
9
+ - qa/flows/*.yml
10
+
11
+ output: tmp/kaloko
12
+
13
+ browser:
14
+ # Use an installed Chrome instead of downloading Playwright's Chromium
15
+ channel: chrome
16
+ port: 9333
17
+
18
+ environments:
19
+ local:
20
+ base_url:
21
+ default: http://localhost:3000
22
+ mail:
23
+ provider: mailpit
24
+ url: http://localhost:8025
25
+ address: qa+{run}@example.test
26
+ # An app instead of (or next to) a web base URL, for scenarios with platform: android | ios | electron | desktop
27
+ # emulator:
28
+ # app:
29
+ # android: { package: com.example.app.debug, apk: app/build/outputs/apk/debug/app-debug.apk, reset: true }
30
+ # ios: { bundle: com.example.app, app: build/Example.app, device: iPhone 17 }
31
+ # electron: { main: dist/main.js }
32
+ # desktop: { name: Example } # a macOS app by its window owner name
33
+ staging:
34
+ base_url: https://staging.example.com
35
+ # Secrets are never written here: every *_env and { env: NAME } names a variable you choose,
36
+ # read from the environment, the project .env or ~/.config/kaloko/.env.
37
+ # Protection in front of the whole environment, sent to these origins only:
38
+ # access:
39
+ # origins: [https://staging.example.com, https://api.staging.example.com]
40
+ # basic: { user_env: STAGING_USER, password_env: STAGING_PASSWORD }
41
+ # headers: # service tokens, e.g. Cloudflare Access, Vercel
42
+ # CF-Access-Client-Id: { env: CF_ACCESS_CLIENT_ID }
43
+ # CF-Access-Client-Secret: { env: CF_ACCESS_CLIENT_SECRET }
44
+ # client_certificate: { cert_file: certs/qa.pem, key_env: QA_CLIENT_KEY } # mutual TLS
45
+ # People who sign in, keyed like `account` of the scenario sessions:
46
+ # accounts:
47
+ # member: { user: member@example.test, password_env: STAGING_MEMBER_PASSWORD }
48
+ # admin: { user_env: STAGING_ADMIN_USER, password_env: STAGING_ADMIN_PASSWORD, totp_env: STAGING_ADMIN_TOTP }
49
+ # sso: {} # SSO/MFA: a person runs kaloko auth save --env staging --account sso
50
+ mail:
51
+ provider: mailpit
52
+ url: https://mailpit.example.com
53
+ auth: { basic: { user_env: MAILPIT_USER, password_env: MAILPIT_PASSWORD } }
54
+ address: qa+{run}@example.test
55
+ # Any other mailbox: a project adapter (letter_opener, Mailosaur, IMAP, the app's own API):
56
+ # provider: script
57
+ # script: qa/mail/inbox.mjs # see templates/mail/letter-opener.mjs in the package
58
+ # review: # review apps: the URL comes from CI
59
+ # base_url: https://${REVIEW_HOST}
60
+ production:
61
+ # production is always readonly: no data creation, no admin, no writes
62
+ base_url: https://www.example.com
63
+ readonly: true
64
+ blocked_paths: ["/admin/**", "/console/**", "/users/sign_in"]
65
+ allowed_post_paths: ["/cart/**"]
66
+
67
+ evaluators:
68
+ jev:
69
+ model: jev-1.13.0
70
+ api_key_env: TYPESAFE_API_KEY
71
+ thresholds: { pass: 0.85, fail: 0.15 }
72
+
73
+ share:
74
+ url: https://kaloko.app
75
+ token_env: KALOKO_TOKEN
76
+ expires_days: 30
@@ -0,0 +1,47 @@
1
+ // Mail adapter for Rails letter_opener (and letter_opener_web): one directory per message under
2
+ // options.path (default tmp/letter_opener) with rich.html and/or plain.html.
3
+ // Copy it into the project and point the environment at it:
4
+ //
5
+ // mail:
6
+ // provider: script
7
+ // script: qa/mail/letter-opener.mjs
8
+ // options: { path: tmp/letter_opener }
9
+ // address: qa+{run}@example.test
10
+ //
11
+ // The contract for any adapter: default export async ({ to, subject, since, options, root, env })
12
+ // returning the newest matching message { id, subject, from, to: [address], date, html, text } or null.
13
+ import { readdir, readFile, stat } from 'node:fs/promises'
14
+ import { join, resolve } from 'node:path'
15
+
16
+ const decode = (text) => text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
17
+ // the header block of the template: <dt>Subject:</dt> <dd>…</dd>
18
+ const header = (html, name) => {
19
+ const m = html.match(new RegExp(`<dt>${name}:</dt>\\s*<dd>([\\s\\S]*?)</dd>`))
20
+ return m ? decode(m[1].replace(/<[^>]+>/g, '')).trim() : ''
21
+ }
22
+ const addresses = (value) => value.split(',').map((v) => v.trim()).filter(Boolean).map((v) => v.replace(/^.*<([^>]+)>.*$/, '$1'))
23
+
24
+ async function parse (dir, name) {
25
+ const folder = join(dir, name)
26
+ const html = await readFile(join(folder, 'rich.html'), 'utf8').catch(() => null)
27
+ const plain = await readFile(join(folder, 'plain.html'), 'utf8').catch(() => null)
28
+ const source = html || plain
29
+ if (!source) return null
30
+ const body = html
31
+ ? decode((html.match(/srcdoc="<base target='_top'>([\s\S]*?)"><\/iframe>/) || [])[1] || '')
32
+ : decode((plain.match(/<pre id="message_body">([\s\S]*?)<\/pre>/) || [])[1] || '').replace(/<[^>]+>/g, '')
33
+ const date = (await stat(folder)).mtime
34
+ return { id: name, subject: header(source, 'Subject'), from: header(source, 'From'), to: addresses(header(source, 'To')), date: date.toISOString(), html: html ? body : null, text: html ? null : body }
35
+ }
36
+
37
+ export default async function find ({ to, subject, since, options = {}, root }) {
38
+ const dir = resolve(root, options.path || 'tmp/letter_opener')
39
+ const names = await readdir(dir).catch(() => [])
40
+ const messages = (await Promise.all(names.map((n) => parse(dir, n)))).filter(Boolean)
41
+ const wantTo = (to || '').toLowerCase()
42
+ const wantSubject = (subject || '').toLowerCase()
43
+ return messages
44
+ .filter((m) => !since || m.date >= since)
45
+ .filter((m) => (!wantTo || m.to.some((t) => t.toLowerCase() === wantTo)) && (!wantSubject || m.subject.toLowerCase().includes(wantSubject)))
46
+ .sort((a, b) => (a.date < b.date ? 1 : -1))[0] || null
47
+ }
@@ -0,0 +1,119 @@
1
+ schema: qawalk.scenario.v1
2
+ id: TASK-123-example-flow
3
+ title: Example flow
4
+ kind: task # task | flow
5
+ source: docs/tasks/TASK-123-example/ACCEPTANCE.md
6
+ # project: shop # optional: sort this flow into a project other than the config's / repository's
7
+ version: 1
8
+ locales: [en]
9
+ viewports:
10
+ desktop: [1280, 800]
11
+ mobile: [390, 844]
12
+ environments: [local, staging]
13
+ # platform: android # web (default) | android | ios | electron | desktop — an app instead of pages:
14
+ # # the environment's app block in kaloko.config.yml names it, paths are deep links
15
+ # # (shop://product/42), step scripts get `app` (tap, type, wait, link); see the skill's apps reference
16
+ # capture: { format: png, scale: 2, mask: ["[data-qa=email]"], annotate: true } # print quality for documentation runs
17
+ readonly: false
18
+ lanes: [Main path, Alternative]
19
+ # People in the flow (optional). With `sessions`, every non-decision node names one;
20
+ # `kaloko walk` keeps a separate browser context (cookies, sign-in) per session and locale,
21
+ # e-mail nodes default to the session's mailbox, and the canvas groups steps by person.
22
+ # sessions:
23
+ # visitor: { title: New visitor, mailbox: "qa+{run}-visitor@example.com" }
24
+ # member: { title: Signed-in member, account: member } # `account` is a project key for step scripts
25
+ # nodes then carry `session: visitor` / `session: member`; an edge between sessions is a hand-off.
26
+
27
+ nodes:
28
+ - id: landing
29
+ kind: screen
30
+ title: Landing page
31
+ path: /
32
+ purpose: A visitor understands the offer and can start the flow.
33
+ # packs: [seo, usability] # adds the SEO basics (AC101…) and usability questions (AC201…) to this step
34
+ execution:
35
+ strategy: playwright # playwright | agent | manual
36
+ script: steps/landing.mjs
37
+ instructions: Open the landing page as an anonymous visitor.
38
+ criteria:
39
+ - id: AC1
40
+ text: The page has exactly one main heading.
41
+ check: { type: deterministic, assert: count, selector: h1, min: 1, max: 1 }
42
+ - id: AC2
43
+ text: The primary call to action is clear.
44
+ check:
45
+ type: semantic
46
+ question: Does the page show one clear primary call to action that starts the flow?
47
+ scope: main
48
+ - id: AC3
49
+ text: No horizontal overflow on mobile.
50
+ check: { type: deterministic, assert: no_overflow }
51
+ - id: AC4
52
+ text: The consent dialog is shown to a new visitor. # drop AC4 and the consent step when the site has no consent dialog
53
+ check: { type: deterministic, assert: exists, selector: "#cm, #CybotCookiebotDialog, #onetrust-banner-sdk" }
54
+
55
+ - id: consent
56
+ kind: screen
57
+ title: Consent accepted
58
+ path: /
59
+ purpose: The visitor accepts cookies once; the dialog does not return on later screens.
60
+ execution:
61
+ strategy: playwright
62
+ script: steps/consent.mjs
63
+ instructions: Accept the consent dialog and stay on the page.
64
+ criteria:
65
+ - id: AC1
66
+ text: The consent dialog is gone after accepting.
67
+ check: { type: deterministic, assert: absent, selector: "#cm, #CybotCookiebotDialog, #onetrust-banner-sdk" }
68
+
69
+ - id: choice
70
+ kind: decision
71
+ title: Is the visitor signed in?
72
+
73
+ - id: signup
74
+ kind: screen
75
+ title: Sign-up
76
+ path: /signup
77
+ purpose: A new visitor can create an account.
78
+ execution:
79
+ strategy: agent
80
+ instructions: Click the primary call to action as a new visitor and stop on the sign-up form.
81
+ criteria:
82
+ - id: AC1
83
+ text: Marketing consent is separate and unchecked.
84
+ check:
85
+ type: semantic
86
+ question: Is there a marketing consent checkbox that is unchecked by default and separate from the terms?
87
+
88
+ - id: welcome-mail
89
+ kind: email
90
+ title: Welcome e-mail
91
+ mail: { subject: Welcome }
92
+ execution:
93
+ strategy: agent
94
+ instructions: After sign-up, capture the welcome e-mail with "kaloko mail --step welcome-mail".
95
+ criteria:
96
+ - id: AC1
97
+ text: The e-mail links back to the account.
98
+ check: { type: deterministic, assert: exists, selector: "a[href*='/account']" }
99
+
100
+ # Many pages of one template (articles, products, an SEO catalogue): one stack step, every page captured
101
+ # with these criteria; the canvas shows a stack with a searchable index. See the skill's reference/stacks.md.
102
+ # - id: articles
103
+ # kind: stack
104
+ # title: Articles
105
+ # purpose: Every article reads well and is findable.
106
+ # packs: [seo]
107
+ # items: { sitemap: /sitemap.xml, match: '^/blog/', limit: 500, semantic_sample: 20 }
108
+ # fields:
109
+ # - { id: title, label: Title, from: title }
110
+ # - { id: words, label: Words, from: words }
111
+ # - { id: http, label: HTTP, from: http }
112
+ # criteria:
113
+ # - { id: AC1, text: Has a cover image, check: { type: deterministic, assert: exists, selector: "article img" } }
114
+
115
+ edges:
116
+ - { from: landing, to: consent, label: accept cookies }
117
+ - { from: consent, to: choice }
118
+ - { from: choice, to: signup, label: no }
119
+ - { from: signup, to: welcome-mail, label: submit }
@@ -0,0 +1,33 @@
1
+ // Accepts the cookie/consent dialog on the current page, if the site has one.
2
+ // Default first action of a session (see skills/kaloko/reference/acceptance.md);
3
+ // when no dialog is found the step just captures the page as it is.
4
+ const ACCEPT = [
5
+ '#c-p-bn', '#cc-main .cm__btn[data-role="all"]', // orestbida cookieconsent v2 / v3
6
+ '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll', '#CybotCookiebotDialogBodyButtonAccept', // Cookiebot
7
+ '#onetrust-accept-btn-handler', // OneTrust
8
+ '.cm-btn-accept-all', '.cm-btn-accept', // Klaro
9
+ '.cc-btn.cc-allow', '.cc-btn.cc-dismiss', // insites cookieconsent
10
+ '#tarteaucitronPersonalize2', // tarteaucitron
11
+ '[data-cookieconsent="accept"]', '[data-consent="accept"]', '[data-testid*="accept" i]',
12
+ 'button:has-text(/^(accept( all)?|allow( all)?|agree|i agree|ok(ay)?|souhlasím|přijmout( vše)?|povolit( vše)?|prijať( všetko)?|súhlasím|akzeptieren|alle akzeptieren|aceptar( todo)?|accepter( tout)?|accetta( tutto)?)$/i)'
13
+ ]
14
+
15
+ export async function acceptConsent (page, { timeout = 4000 } = {}) {
16
+ for (const selector of ACCEPT) {
17
+ const button = page.locator(selector).first()
18
+ if (!(await button.isVisible().catch(() => false))) continue
19
+ await button.click({ timeout })
20
+ await button.waitFor({ state: 'hidden', timeout }).catch(() => {})
21
+ return selector
22
+ }
23
+ // dialogs often render after load; wait once for the most common ones
24
+ const late = page.locator(ACCEPT.slice(0, 6).join(', ')).first()
25
+ if (await late.waitFor({ state: 'visible', timeout }).then(() => true, () => false)) return acceptConsent(page, { timeout })
26
+ return null
27
+ }
28
+
29
+ export default async ({ page, url }) => {
30
+ if (page.url() !== url()) await page.goto(url(), { waitUntil: 'domcontentloaded' })
31
+ const used = await acceptConsent(page)
32
+ console.log(used ? ` consent accepted via ${used}` : ' no consent dialog found; capturing as is')
33
+ }