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.
- package/README.md +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
package/src/lib/util.mjs
ADDED
|
@@ -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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
+
}
|