lynkeus 0.2.0 → 0.4.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/lib/cases/ahead.d.ts +31 -0
- package/lib/cases/ahead.js +90 -0
- package/lib/cases/fixtures.d.ts +12 -0
- package/lib/cases/fixtures.js +32 -0
- package/lib/cases/flakes.d.ts +28 -0
- package/lib/cases/flakes.js +61 -0
- package/lib/cases/format.d.ts +5 -0
- package/lib/cases/format.js +30 -4
- package/lib/cases/portClaim.d.ts +10 -0
- package/lib/cases/portClaim.js +55 -0
- package/lib/cases/runfile.d.ts +24 -0
- package/lib/cases/runfile.js +108 -0
- package/lib/cases/runner.d.ts +82 -3
- package/lib/cases/runner.js +394 -40
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +14 -4
- package/lib/cases/traces.d.ts +58 -0
- package/lib/cases/traces.js +250 -0
- package/lib/cli.js +28 -7
- package/lib/commands/cases.js +411 -42
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +132 -8
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +295 -33
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +26 -1
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +12 -0
- package/lib/device/devices.d.ts +5 -0
- package/lib/device/devices.js +27 -0
- package/lib/device/driver.js +2 -2
- package/lib/flow/runner.js +33 -1
- package/lib/flow/types.d.ts +2 -1
- package/lib/knowledge/contrast.d.ts +45 -0
- package/lib/knowledge/contrast.js +114 -0
- package/lib/knowledge/errors.js +1 -1
- package/lib/knowledge/lint.d.ts +2 -0
- package/lib/knowledge/lint.js +13 -3
- package/lib/mcp/server.js +31 -2
- package/lib/registry.d.ts +5 -0
- package/lib/targets.d.ts +10 -1
- package/lib/targets.js +16 -3
- package/package.json +10 -6
- package/src/cases/ahead.ts +111 -0
- package/src/cases/fixtures.ts +37 -0
- package/src/cases/flakes.ts +67 -0
- package/src/cases/format.ts +31 -6
- package/src/cases/portClaim.ts +52 -0
- package/src/cases/runfile.ts +119 -0
- package/src/cases/runner.ts +454 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +29 -7
- package/src/commands/cases.ts +428 -41
- package/src/commands/check.ts +11 -4
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +143 -8
- package/src/commands/init.ts +338 -30
- package/src/commands/report.ts +272 -0
- package/src/config.ts +39 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- package/src/device/driver.ts +2 -2
- package/src/flow/runner.ts +35 -1
- package/src/flow/types.ts +2 -2
- package/src/knowledge/contrast.ts +135 -0
- package/src/knowledge/errors.ts +1 -1
- package/src/knowledge/lint.ts +15 -4
- package/src/mcp/server.ts +31 -2
- package/src/registry.ts +2 -0
- package/src/targets.ts +17 -3
- package/lib/__tests__/args.test.d.ts +0 -1
- package/lib/__tests__/args.test.js +0 -69
- package/lib/__tests__/check.test.d.ts +0 -1
- package/lib/__tests__/check.test.js +0 -60
- package/lib/__tests__/init.test.d.ts +0 -1
- package/lib/__tests__/init.test.js +0 -42
- package/lib/__tests__/record.test.d.ts +0 -1
- package/lib/__tests__/record.test.js +0 -44
- package/lib/__tests__/snapshot.test.d.ts +0 -1
- package/lib/__tests__/snapshot.test.js +0 -47
- package/lib/__tests__/targets.test.d.ts +0 -1
- package/lib/__tests__/targets.test.js +0 -127
- package/lib/cases/__tests__/format.test.d.ts +0 -1
- package/lib/cases/__tests__/format.test.js +0 -61
- package/lib/cases/__tests__/runner.test.d.ts +0 -1
- package/lib/cases/__tests__/runner.test.js +0 -188
- package/lib/knowledge/__tests__/edges.test.d.ts +0 -1
- package/lib/knowledge/__tests__/edges.test.js +0 -25
- package/lib/knowledge/__tests__/events.test.d.ts +0 -1
- package/lib/knowledge/__tests__/events.test.js +0 -39
- package/lib/knowledge/__tests__/extract.test.d.ts +0 -1
- package/lib/knowledge/__tests__/extract.test.js +0 -69
- package/lib/knowledge/__tests__/frontier.test.d.ts +0 -1
- package/lib/knowledge/__tests__/frontier.test.js +0 -57
- package/lib/knowledge/__tests__/funnels.test.d.ts +0 -1
- package/lib/knowledge/__tests__/funnels.test.js +0 -40
- package/lib/knowledge/__tests__/lint.test.d.ts +0 -1
- package/lib/knowledge/__tests__/lint.test.js +0 -53
- package/lib/knowledge/__tests__/perf.test.d.ts +0 -1
- package/lib/knowledge/__tests__/perf.test.js +0 -34
- package/lib/knowledge/__tests__/plan.test.d.ts +0 -1
- package/lib/knowledge/__tests__/plan.test.js +0 -35
- package/lib/knowledge/__tests__/query.test.d.ts +0 -1
- package/lib/knowledge/__tests__/query.test.js +0 -55
- package/lib/knowledge/__tests__/screens.test.d.ts +0 -1
- package/lib/knowledge/__tests__/screens.test.js +0 -30
- package/lib/knowledge/__tests__/why.test.d.ts +0 -1
- package/lib/knowledge/__tests__/why.test.js +0 -28
- package/src/__tests__/args.test.ts +0 -82
- package/src/__tests__/check.test.ts +0 -71
- package/src/__tests__/init.test.ts +0 -52
- package/src/__tests__/record.test.ts +0 -49
- package/src/__tests__/snapshot.test.ts +0 -51
- package/src/__tests__/targets.test.ts +0 -132
- package/src/cases/__tests__/format.test.ts +0 -76
- package/src/cases/__tests__/runner.test.ts +0 -230
- package/src/knowledge/__tests__/edges.test.ts +0 -34
- package/src/knowledge/__tests__/events.test.ts +0 -45
- package/src/knowledge/__tests__/extract.test.ts +0 -88
- package/src/knowledge/__tests__/frontier.test.ts +0 -77
- package/src/knowledge/__tests__/funnels.test.ts +0 -58
- package/src/knowledge/__tests__/lint.test.ts +0 -66
- package/src/knowledge/__tests__/perf.test.ts +0 -41
- package/src/knowledge/__tests__/plan.test.ts +0 -52
- package/src/knowledge/__tests__/query.test.ts +0 -74
- package/src/knowledge/__tests__/screens.test.ts +0 -37
- package/src/knowledge/__tests__/why.test.ts +0 -40
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { define } from '../registry.js';
|
|
5
|
+
|
|
6
|
+
type Fetch = typeof fetch;
|
|
7
|
+
|
|
8
|
+
export type Pushed = { url: string; files: number; refused: string[]; changes?: { newFailures: string[]; fixed: string[] } };
|
|
9
|
+
|
|
10
|
+
/** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
|
|
11
|
+
export const describeChanges = (changes: Pushed['changes']): string[] =>
|
|
12
|
+
changes
|
|
13
|
+
? [
|
|
14
|
+
...(changes.newFailures.length ? [`newly failing (${changes.newFailures.length}): ${changes.newFailures.join(', ')}`] : []),
|
|
15
|
+
...(changes.fixed.length ? [`fixed since the run before (${changes.fixed.length}): ${changes.fixed.join(', ')}`] : []),
|
|
16
|
+
]
|
|
17
|
+
: [];
|
|
18
|
+
|
|
19
|
+
const answer = async (response: Response, doing: string) => {
|
|
20
|
+
if (response.ok) return response.json() as Promise<Record<string, unknown>>;
|
|
21
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
22
|
+
throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
27
|
+
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
28
|
+
*/
|
|
29
|
+
export const pushRun = async (dir: string, o: { url: string; key: string; project: string; fetch?: Fetch }): Promise<Pushed> => {
|
|
30
|
+
const call = o.fetch ?? fetch;
|
|
31
|
+
const auth = { authorization: `Bearer ${o.key}` };
|
|
32
|
+
const base = o.url.replace(/\/+$/, '');
|
|
33
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, 'run.json'), 'utf8')) as unknown;
|
|
34
|
+
const cases = fs.readFileSync(path.join(dir, 'cases.jsonl'), 'utf8');
|
|
35
|
+
|
|
36
|
+
const created = await answer(
|
|
37
|
+
await call(`${base}/v1/runs`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { ...auth, 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ project: o.project, run }),
|
|
41
|
+
}),
|
|
42
|
+
'creating the run',
|
|
43
|
+
);
|
|
44
|
+
const id = String(created.id);
|
|
45
|
+
await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
|
|
46
|
+
|
|
47
|
+
const named = new Set<string>();
|
|
48
|
+
for (const line of cases.split('\n')) {
|
|
49
|
+
if (!line.trim()) continue;
|
|
50
|
+
const c = JSON.parse(line) as { video?: string; steps?: { screenshot?: string }[] };
|
|
51
|
+
for (const p of [c.video, ...(c.steps ?? []).map((s) => s.screenshot)]) if (p) named.add(p);
|
|
52
|
+
}
|
|
53
|
+
let files = 0;
|
|
54
|
+
const refused: string[] = [];
|
|
55
|
+
for (const file of named) {
|
|
56
|
+
const local = path.join(dir, file);
|
|
57
|
+
if (!fs.existsSync(local)) continue;
|
|
58
|
+
const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
|
|
59
|
+
method: 'PUT',
|
|
60
|
+
headers: auth,
|
|
61
|
+
body: fs.readFileSync(local),
|
|
62
|
+
});
|
|
63
|
+
if (response.status === 402) refused.push(file);
|
|
64
|
+
else {
|
|
65
|
+
await answer(response, `sending ${file}`);
|
|
66
|
+
files += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
|
|
70
|
+
const changes = finished.changes as Pushed['changes'] | undefined;
|
|
71
|
+
return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type Service = { url: string; key: string; fetch?: Fetch };
|
|
75
|
+
|
|
76
|
+
const ask = async (o: Service, method: string, route: string, doing: string) =>
|
|
77
|
+
answer(await (o.fetch ?? fetch)(`${o.url.replace(/\/+$/, '')}${route}`, { method, headers: { authorization: `Bearer ${o.key}` } }), doing);
|
|
78
|
+
|
|
79
|
+
export type ListedRun = { project: string; platform: string | null; totals: Record<string, number>; createdAt: string; url: string };
|
|
80
|
+
|
|
81
|
+
export const listRuns = async (o: Service & { limit?: number }) =>
|
|
82
|
+
(await ask(o, 'GET', `/v1/runs?limit=${o.limit ?? 20}`, 'listing the runs')).runs as ListedRun[];
|
|
83
|
+
|
|
84
|
+
export type Flaky = {
|
|
85
|
+
id: string;
|
|
86
|
+
title: string;
|
|
87
|
+
runs: number;
|
|
88
|
+
firstTryRate: number;
|
|
89
|
+
state: 'flaky' | 'broken';
|
|
90
|
+
recent: ('passed' | 'retried' | 'failed')[];
|
|
91
|
+
lastFailure: string | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const flakes = async (o: Service & { project: string; runs?: number }) =>
|
|
95
|
+
(await ask(o, 'GET', `/v1/flakes?project=${encodeURIComponent(o.project)}&runs=${o.runs ?? 30}`, 'reading flake trends')) as {
|
|
96
|
+
runs: number;
|
|
97
|
+
cases: Flaky[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type CaseRun = { at: string; outcome: 'passed' | 'retried' | 'failed'; ms: number | null; branch?: string | null; commit?: string | null; url: string };
|
|
101
|
+
|
|
102
|
+
export const caseHistory = async (o: Service & { project: string; id: string; runs?: number }) =>
|
|
103
|
+
(await ask(
|
|
104
|
+
o,
|
|
105
|
+
'GET',
|
|
106
|
+
`/v1/cases/history?project=${encodeURIComponent(o.project)}&case=${encodeURIComponent(o.id)}&runs=${o.runs ?? 50}`,
|
|
107
|
+
'reading the case’s history',
|
|
108
|
+
)) as { title: string; runs: CaseRun[] };
|
|
109
|
+
|
|
110
|
+
export type Plan = { name: string; plan: string; days: number; projects: number | null; film: boolean };
|
|
111
|
+
|
|
112
|
+
/** The organization's plan, and with `upgrade` or `manage` the Stripe page that changes it. */
|
|
113
|
+
export const plan = async (o: Service & { go?: 'upgrade' | 'manage' }) => {
|
|
114
|
+
const now = (await ask(o, 'GET', '/v1/billing', 'reading the plan')) as Plan;
|
|
115
|
+
if (!o.go) return { ...now, url: null };
|
|
116
|
+
const route = o.go === 'upgrade' ? '/v1/billing/checkout' : '/v1/billing/portal';
|
|
117
|
+
return { ...now, url: String((await ask(o, 'POST', route, o.go === 'upgrade' ? 'opening checkout' : 'opening the billing portal')).url) };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const service = (): Service | string => {
|
|
121
|
+
const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
|
|
122
|
+
const key = process.env.LYNKEUS_REPORTS_KEY;
|
|
123
|
+
return key ? { url, key } : 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); LYNKEUS_REPORTS_URL names another service';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const reportCommands = [
|
|
127
|
+
define({
|
|
128
|
+
name: 'report push',
|
|
129
|
+
group: 'runs',
|
|
130
|
+
summary: 'Upload a run to the reports service and print the report’s address',
|
|
131
|
+
details:
|
|
132
|
+
'Uploads what `case run --out <dir>` wrote: the run, its cases, and every frame and film they name. The report shows the film in step with the steps, the events each step sent, and why a case failed. The service and the organization’s key come from LYNKEUS_REPORTS_URL and LYNKEUS_REPORTS_KEY; a film the organization’s plan does not keep is left out and said so.',
|
|
133
|
+
positionals: [{ name: 'dir', help: 'the run directory `case run --out` wrote', required: true }],
|
|
134
|
+
flags: {
|
|
135
|
+
project: { type: 'string', help: 'the project the run is filed under (default: the app’s folder name)', value: 'name' },
|
|
136
|
+
},
|
|
137
|
+
needs: 'nothing',
|
|
138
|
+
session: false,
|
|
139
|
+
mcp: false,
|
|
140
|
+
run: async (ctx) => {
|
|
141
|
+
const dir = path.resolve(ctx.args[0]!);
|
|
142
|
+
if (!fs.existsSync(path.join(dir, 'cases.jsonl')))
|
|
143
|
+
return { text: `${ctx.args[0]}: no cases.jsonl; pass the directory \`case run --out\` wrote`, code: 2 };
|
|
144
|
+
const to = service();
|
|
145
|
+
if (typeof to === 'string') return { text: to, code: 2 };
|
|
146
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
147
|
+
try {
|
|
148
|
+
const pushed = await pushRun(dir, { ...to, project });
|
|
149
|
+
const notes = [...describeChanges(pushed.changes), ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : [])];
|
|
150
|
+
return { text: [pushed.url, ...notes].join('\n'), json: pushed };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { text: (error as Error).message, code: 1 };
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
define({
|
|
157
|
+
name: 'report list',
|
|
158
|
+
group: 'runs',
|
|
159
|
+
summary: 'List the organization’s reports, newest first',
|
|
160
|
+
details: 'Each line is when the run started, its project and platform, how many cases passed, and the report’s address.',
|
|
161
|
+
flags: { limit: { type: 'string', help: 'how many runs (default 20, at most 100)', value: 'n' } },
|
|
162
|
+
needs: 'nothing',
|
|
163
|
+
session: false,
|
|
164
|
+
mcp: false,
|
|
165
|
+
run: async (ctx) => {
|
|
166
|
+
const to = service();
|
|
167
|
+
if (typeof to === 'string') return { text: to, code: 2 };
|
|
168
|
+
try {
|
|
169
|
+
const runs = await listRuns({ ...to, limit: Number(ctx.flags.limit) || 20 });
|
|
170
|
+
if (runs.length === 0) return { text: 'no reports yet: `lynkeus report push <dir>` uploads one', json: runs };
|
|
171
|
+
const lines = runs.map(
|
|
172
|
+
(r) =>
|
|
173
|
+
`${r.createdAt.slice(0, 16).replace('T', ' ')} ${r.project}${r.platform ? ` ${r.platform}` : ''} ${r.totals.passed ?? 0}/${r.totals.cases ?? 0} passed ${r.url}`,
|
|
174
|
+
);
|
|
175
|
+
return { text: lines.join('\n'), json: runs };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { text: (error as Error).message, code: 1 };
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
define({
|
|
182
|
+
name: 'report plan',
|
|
183
|
+
group: 'runs',
|
|
184
|
+
summary: 'Show the organization’s plan, or open the page that changes it',
|
|
185
|
+
details:
|
|
186
|
+
'Free keeps frames for 7 days and one project; Team keeps films too, for 90 days, and any number of projects. `--upgrade` prints the Stripe checkout for Team; `--manage` prints Stripe’s portal for the card, the invoices and cancelling.',
|
|
187
|
+
flags: {
|
|
188
|
+
upgrade: { type: 'boolean', help: 'print the Stripe checkout address for the Team plan' },
|
|
189
|
+
manage: { type: 'boolean', help: 'print the Stripe portal address for a paying organization' },
|
|
190
|
+
},
|
|
191
|
+
needs: 'nothing',
|
|
192
|
+
session: false,
|
|
193
|
+
mcp: false,
|
|
194
|
+
run: async (ctx) => {
|
|
195
|
+
const to = service();
|
|
196
|
+
if (typeof to === 'string') return { text: to, code: 2 };
|
|
197
|
+
const go = ctx.flags.upgrade ? 'upgrade' : ctx.flags.manage ? 'manage' : undefined;
|
|
198
|
+
try {
|
|
199
|
+
const now = await plan({ ...to, go });
|
|
200
|
+
const keeps = `${now.film ? 'films and frames' : 'frames'} for ${now.days} days, ${now.projects ?? 'any number of'} project${now.projects === 1 ? '' : 's'}`;
|
|
201
|
+
return { text: `${now.name}: ${now.plan} (${keeps})${now.url ? `\n${now.url}` : ''}`, json: now };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { text: (error as Error).message, code: 1 };
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
define({
|
|
208
|
+
name: 'report flakes',
|
|
209
|
+
group: 'runs',
|
|
210
|
+
summary: 'List the cases that do not always pass at the first try, across the project’s last runs',
|
|
211
|
+
details:
|
|
212
|
+
'Reads the reports pushed for the project and ranks its cases by how often they passed at the first try, the least reliable first. Each line shows the recent runs newest first (✓ passed, ↻ passed on a retry, ✗ failed) and the last report where it went wrong. A case that failed its last three runs is broken rather than flaky. On the Team plan.',
|
|
213
|
+
flags: {
|
|
214
|
+
project: { type: 'string', help: 'the project (default: the app’s folder name)', value: 'name' },
|
|
215
|
+
runs: { type: 'string', help: 'how many of the latest runs to read (default 30)', value: 'n' },
|
|
216
|
+
},
|
|
217
|
+
needs: 'nothing',
|
|
218
|
+
session: false,
|
|
219
|
+
mcp: false,
|
|
220
|
+
run: async (ctx) => {
|
|
221
|
+
const to = service();
|
|
222
|
+
if (typeof to === 'string') return { text: to, code: 2 };
|
|
223
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
224
|
+
try {
|
|
225
|
+
const found = await flakes({ ...to, project, runs: Number(ctx.flags.runs) || 30 });
|
|
226
|
+
if (found.cases.length === 0) return { text: `${project}: every case passed at the first try in the last ${found.runs} runs`, json: found };
|
|
227
|
+
const mark = { passed: '✓', retried: '↻', failed: '✗' } as const;
|
|
228
|
+
const lines = found.cases.map(
|
|
229
|
+
(c) =>
|
|
230
|
+
`${c.state === 'broken' ? 'broken' : 'flaky '} ${Math.round(c.firstTryRate * 100)
|
|
231
|
+
.toString()
|
|
232
|
+
.padStart(3)}% ${c.recent.map((r) => mark[r]).join('')} ${c.id}${c.lastFailure ? ` ${c.lastFailure}` : ''}`,
|
|
233
|
+
);
|
|
234
|
+
return { text: [`${project}: ${found.runs} runs`, ...lines].join('\n'), json: found };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { text: (error as Error).message, code: 1 };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
define({
|
|
241
|
+
name: 'report history',
|
|
242
|
+
group: 'runs',
|
|
243
|
+
summary: 'Show how one case came out in each of the project’s last runs',
|
|
244
|
+
details:
|
|
245
|
+
'Newest first: ✓ passed, ↻ passed on a retry, ✗ failed, with how long it took, the branch and commit the run was made from, and the report to open. What tells a case that fails now and then from one a commit broke.',
|
|
246
|
+
positionals: [{ name: 'case', help: 'the case id', required: true }],
|
|
247
|
+
flags: {
|
|
248
|
+
project: { type: 'string', help: 'the project (default: the app’s folder name)', value: 'name' },
|
|
249
|
+
runs: { type: 'string', help: 'how many of the latest runs to read (default 50)', value: 'n' },
|
|
250
|
+
},
|
|
251
|
+
needs: 'nothing',
|
|
252
|
+
session: false,
|
|
253
|
+
mcp: false,
|
|
254
|
+
run: async (ctx) => {
|
|
255
|
+
const to = service();
|
|
256
|
+
if (typeof to === 'string') return { text: to, code: 2 };
|
|
257
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
258
|
+
try {
|
|
259
|
+
const found = await caseHistory({ ...to, project, id: ctx.args[0]!, runs: Number(ctx.flags.runs) || 50 });
|
|
260
|
+
if (found.runs.length === 0) return { text: `${ctx.args[0]} is in none of ${project}'s recent runs`, json: found };
|
|
261
|
+
const mark = { passed: '✓', retried: '↻', failed: '✗' } as const;
|
|
262
|
+
const lines = found.runs.map(
|
|
263
|
+
(r) =>
|
|
264
|
+
`${mark[r.outcome]} ${r.at.slice(0, 16).replace('T', ' ')} ${(r.ms === null ? '—' : `${(r.ms / 1000).toFixed(1)}s`).padStart(6)} ${[r.branch, r.commit].filter(Boolean).join(' @ ') || '—'} ${r.url}`,
|
|
265
|
+
);
|
|
266
|
+
return { text: [`${found.title} (${project})`, ...lines].join('\n'), json: found };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return { text: (error as Error).message, code: 1 };
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
];
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -10,6 +11,33 @@ export const graphFile = (root?: string) => path.join(lynkeusDir(root), 'graph.j
|
|
|
10
11
|
export const edgesFile = (root?: string) => path.join(lynkeusDir(root), 'edges.jsonl');
|
|
11
12
|
export const frontierFile = (root?: string) => path.join(lynkeusDir(root), 'frontier.jsonl');
|
|
12
13
|
export const runsDir = (root?: string) => path.join(lynkeusDir(root), 'runs');
|
|
14
|
+
export const tokenFile = (root?: string) => path.join(lynkeusDir(root), 'token');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
|
|
18
|
+
* `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
|
|
19
|
+
*/
|
|
20
|
+
export const readToken = (root?: string): string | undefined => {
|
|
21
|
+
if (process.env.LYNKEUS_TOKEN) return process.env.LYNKEUS_TOKEN;
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
|
|
30
|
+
export const ensureToken = (root?: string): { token: string; made: boolean } => {
|
|
31
|
+
const had = readToken(root);
|
|
32
|
+
if (had) return { token: had, made: false };
|
|
33
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
34
|
+
fs.mkdirSync(lynkeusDir(root), { recursive: true });
|
|
35
|
+
fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
|
|
36
|
+
const ignore = path.join(lynkeusDir(root), '.gitignore');
|
|
37
|
+
const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
38
|
+
if (!ignored.split('\n').includes('token')) fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
|
|
39
|
+
return { token, made: true };
|
|
40
|
+
};
|
|
13
41
|
|
|
14
42
|
export const loadGraph = (root?: string): ScreenGraph | null => {
|
|
15
43
|
const file = graphFile(root);
|
|
@@ -27,6 +55,11 @@ export type LynkeusConfig = {
|
|
|
27
55
|
entry?: string;
|
|
28
56
|
/** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
|
|
29
57
|
apiEnv?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
|
|
60
|
+
* starting the host over MCP cannot export a variable.
|
|
61
|
+
*/
|
|
62
|
+
apiUrl?: string;
|
|
30
63
|
/** The driver port the app dials. */
|
|
31
64
|
port?: number;
|
|
32
65
|
/** Simulator udid or name. */
|
|
@@ -46,8 +79,12 @@ export type LynkeusConfig = {
|
|
|
46
79
|
lintBaseline?: string;
|
|
47
80
|
/** Regular expressions for the text this app shows when it is broken; `smoke` and `crawl` report a match. */
|
|
48
81
|
errorCopy?: string[];
|
|
49
|
-
/**
|
|
50
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The shape of an untranslated key, the smallest acceptable touch target in points, and how text contrast is
|
|
84
|
+
* judged: `false` turns it off; otherwise the least ratio for ordinary and for large text (4.5 and 3, WCAG 2 AA)
|
|
85
|
+
* and the testIDs to leave out, as regular expressions.
|
|
86
|
+
*/
|
|
87
|
+
lint?: { i18nKeyPatterns?: string[]; minTouchTarget?: number; contrast?: false | { normal?: number; large?: number; ignore?: string[] } };
|
|
51
88
|
/**
|
|
52
89
|
* Regular expressions for the testIDs of controls whose content changes on
|
|
53
90
|
* its own (a ticking clock, a countdown). Their text stops counting as a
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
import type { HelloEvent } from 'lynkeus-protocol';
|
|
4
|
+
|
|
5
|
+
export const CLI_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An agent of another release can answer without an error and still do something else (type into
|
|
9
|
+
* nothing): what to tell the person running it, or nothing when the two match.
|
|
10
|
+
*/
|
|
11
|
+
export const agentMismatch = (hello: Pick<HelloEvent, 'agentVersion'>, cli = CLI_VERSION): string | undefined => {
|
|
12
|
+
if (hello.agentVersion === cli) return undefined;
|
|
13
|
+
const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
|
|
14
|
+
return `⚠️ The app runs ${agent}, this CLI is lynkeus ${cli}: they can misread each other without an error. Install lynkeus-agent@${cli} in the app and rebuild it (or run the lynkeus CLI of the agent's version).`;
|
|
15
|
+
};
|
package/src/device/devices.ts
CHANGED
|
@@ -29,6 +29,34 @@ export const bootedSimulators = async (): Promise<DeviceInfo[]> => {
|
|
|
29
29
|
.map((d) => ({ id: d.udid, name: d.name, platform: 'ios', type: 'simulator', connected: true }));
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/** The serials `adb devices` lists as ready, in its order; none when there is no adb to ask. */
|
|
33
|
+
export const parseAdbDevices = (stdout: string): DeviceInfo[] =>
|
|
34
|
+
stdout
|
|
35
|
+
.split('\n')
|
|
36
|
+
.slice(1)
|
|
37
|
+
.map((line) => line.trim().split(/\s+/))
|
|
38
|
+
.filter(([serial, state]) => serial && state === 'device')
|
|
39
|
+
.map(([serial]) => ({ id: serial!, name: serial!, platform: 'android', type: serial!.startsWith('emulator-') ? 'emulator' : 'physical', connected: true }));
|
|
40
|
+
|
|
41
|
+
export const androidDevices = async (): Promise<DeviceInfo[]> => {
|
|
42
|
+
try {
|
|
43
|
+
return parseAdbDevices((await run('adb', ['devices'])).stdout);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The Android device an app that said it runs on Android is on: the one asked for, or the first that is up. */
|
|
51
|
+
export const pickAndroidDevice = async (wanted?: string): Promise<DeviceInfo> => {
|
|
52
|
+
const devices = await androidDevices();
|
|
53
|
+
const id = wanted ?? process.env.LYNKEUS_DEVICE;
|
|
54
|
+
// A name meant for a simulator (the config's `device` when both platforms are driven) is not a reason to fail here.
|
|
55
|
+
const device = (id ? devices.find((d) => d.id === id) : undefined) ?? devices[0];
|
|
56
|
+
if (!device) throw new Error('No Android device or emulator is up (adb devices lists none).');
|
|
57
|
+
return device;
|
|
58
|
+
};
|
|
59
|
+
|
|
32
60
|
export const HEADLESS_HOST: DeviceInfo = { id: 'headless', name: 'headless host', platform: 'ios', type: 'simulator', connected: true };
|
|
33
61
|
|
|
34
62
|
export const pickAgentDevice = async (wanted?: string): Promise<DeviceInfo> => {
|
package/src/device/driver.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { AgentServer } from 'lynkeus-client';
|
|
7
7
|
import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
|
|
8
8
|
|
|
9
|
-
import { portFromEnv } from '../config.js';
|
|
9
|
+
import { portFromEnv, readToken } from '../config.js';
|
|
10
10
|
import { withoutHost } from '../knowledge/problems.js';
|
|
11
11
|
import type { DeviceInfo } from './devices.js';
|
|
12
12
|
import { lifecycleFor, type PlatformLifecycle } from './platform.js';
|
|
@@ -23,7 +23,7 @@ export class Device {
|
|
|
23
23
|
this.info = info;
|
|
24
24
|
this.#resolving = options.resolving;
|
|
25
25
|
// The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
|
|
26
|
-
this.server = options.server ?? new AgentServer({ token:
|
|
26
|
+
this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
|
|
27
27
|
this.#lifecycle = lifecycleFor(info.platform);
|
|
28
28
|
this.#appId = options.appId ?? null;
|
|
29
29
|
}
|
package/src/flow/runner.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
+
import type { Element } from 'lynkeus-protocol';
|
|
4
|
+
|
|
3
5
|
import type { Device } from '../device/driver.js';
|
|
4
6
|
import { writeJson } from '../files.js';
|
|
5
7
|
import { drainEvents, type RunEvent, writeEvents } from '../knowledge/events.js';
|
|
@@ -98,6 +100,34 @@ export const runFlow = async (flow: Flow, options: RunFlowOptions): Promise<Flow
|
|
|
98
100
|
return report;
|
|
99
101
|
};
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A keypad the app raises slides in, and under load its keys are listed a while before they can take a
|
|
105
|
+
* press: disabled, under the screen it is leaving, or still moving. A press then lands on nothing, and the
|
|
106
|
+
* code comes up a digit short.
|
|
107
|
+
*/
|
|
108
|
+
const keyReady = async (device: Device, key: { testId: string }, timeoutMs: number): Promise<void> => {
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
let was: string | undefined;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const found = (await device.server.call('find', key)) as Element | null;
|
|
113
|
+
const at = found ? JSON.stringify(found.frame) : undefined;
|
|
114
|
+
const why =
|
|
115
|
+
!found || (found.hidden && found.hidden !== 'inert')
|
|
116
|
+
? 'not on screen'
|
|
117
|
+
: !found.enabled
|
|
118
|
+
? 'disabled'
|
|
119
|
+
: found.covered
|
|
120
|
+
? 'covered'
|
|
121
|
+
: at !== was
|
|
122
|
+
? 'moving'
|
|
123
|
+
: undefined;
|
|
124
|
+
if (!why) return;
|
|
125
|
+
if (Date.now() > deadline) throw new Error(`keypad key #${key.testId} is ${why} after ${timeoutMs} ms`);
|
|
126
|
+
was = at;
|
|
127
|
+
await device.idle({ quietMs: 50, timeoutMs: 200 }).catch(() => undefined);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
101
131
|
const execute = async (step: FlowStep, flow: Flow, device: Device, vars: Record<string, unknown>): Promise<void> => {
|
|
102
132
|
if ('note' in step) return;
|
|
103
133
|
if ('launch' in step) {
|
|
@@ -132,7 +162,11 @@ const execute = async (step: FlowStep, flow: Flow, device: Device, vars: Record<
|
|
|
132
162
|
}
|
|
133
163
|
if ('keypad' in step) {
|
|
134
164
|
for (const digit of step.keypad.digits) {
|
|
135
|
-
|
|
165
|
+
const key = { testId: step.keypad.pattern.replace('{d}', digit) };
|
|
166
|
+
await keyReady(device, key, step.keypad.timeoutMs ?? 5000);
|
|
167
|
+
await device.press({ ...key, mode: step.keypad.mode });
|
|
168
|
+
// A code field fed by the app's state drops a digit pressed before the last one rendered.
|
|
169
|
+
await device.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
|
|
136
170
|
}
|
|
137
171
|
return;
|
|
138
172
|
}
|
package/src/flow/types.ts
CHANGED
|
@@ -18,8 +18,8 @@ export type FlowStep =
|
|
|
18
18
|
| { waitFor: { route?: string; target?: Target; anyOf?: Target[]; gone?: boolean; timeoutMs?: number } }
|
|
19
19
|
| { idle: { quietMs?: number; timeoutMs?: number } }
|
|
20
20
|
| { assert: { route?: string; target?: Target; gone?: boolean } }
|
|
21
|
-
/** Presses one button per digit, e.g. a code on `digit-{d}-button
|
|
22
|
-
| { keypad: { digits: string; pattern: string; mode?: 'native' | 'js' } }
|
|
21
|
+
/** Presses one button per digit, e.g. a code on `digit-{d}-button`, each once it is there, enabled, uncovered and still (`timeoutMs` per key, 5000 by default). */
|
|
22
|
+
| { keypad: { digits: string; pattern: string; mode?: 'native' | 'js'; timeoutMs?: number } }
|
|
23
23
|
/** An app command registered with `qa.register`; the result is saved under `as`. */
|
|
24
24
|
| { call: { method: string; params?: unknown; as?: string } }
|
|
25
25
|
| { note: string };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How well a screen's text stands out from what is behind it, measured on the pixels of a screenshot: the
|
|
3
|
+
* colors an app declares say nothing of a label over a photo, a gradient or a translucent sheet.
|
|
4
|
+
*/
|
|
5
|
+
import type { Element, Screen } from 'lynkeus-protocol';
|
|
6
|
+
|
|
7
|
+
import type { Finding } from './lint.js';
|
|
8
|
+
|
|
9
|
+
export type Pixels = { width: number; height: number; data: Uint8Array | Buffer };
|
|
10
|
+
export type Rgb = [number, number, number];
|
|
11
|
+
export type Contrast = { ratio: number; text: Rgb; background: Rgb; plain: boolean };
|
|
12
|
+
|
|
13
|
+
export type ContrastOptions = {
|
|
14
|
+
/** The least ratio for ordinary text (WCAG 2 AA asks 4.5). */
|
|
15
|
+
normal?: number;
|
|
16
|
+
/** The least ratio for large text: 18 points, or 14 and bold (WCAG 2 AA asks 3). */
|
|
17
|
+
large?: number;
|
|
18
|
+
/** testIDs left out: a watermark, a brand color nobody may change. */
|
|
19
|
+
ignore?: RegExp[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const AA_NORMAL = 4.5;
|
|
23
|
+
const AA_LARGE = 3;
|
|
24
|
+
// A text box this uniform has a flat background; under it the text sits on a picture or a gradient.
|
|
25
|
+
const PLAIN_SHARE = 0.5;
|
|
26
|
+
// Enough pixels of one color to be glyphs and not the fringe where a glyph meets its background.
|
|
27
|
+
const LEAST_SHARE = 0.004;
|
|
28
|
+
const LEAST_PIXELS = 6;
|
|
29
|
+
|
|
30
|
+
const channel = (value: number): number => {
|
|
31
|
+
const v = value / 255;
|
|
32
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** WCAG's relative luminance of an sRGB color. */
|
|
36
|
+
export const luminance = ([r, g, b]: Rgb): number => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
37
|
+
|
|
38
|
+
/** WCAG's contrast ratio, from 1 (the same color) to 21 (black on white). */
|
|
39
|
+
export const contrastRatio = (a: Rgb, b: Rgb): number => {
|
|
40
|
+
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
|
41
|
+
return (light + 0.05) / (dark + 0.05);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const hex = (rgb: Rgb): string => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The contrast of the text inside a rectangle of the picture, in pixels. The background is the color most
|
|
48
|
+
* of the rectangle has; the text is the color, among those with enough pixels to be glyphs, that stands
|
|
49
|
+
* furthest from it. Anything else in the box (an icon beside a label) can only raise the figure, so a low one
|
|
50
|
+
* is a finding and a high one is not a certificate.
|
|
51
|
+
*/
|
|
52
|
+
export const measureContrast = (picture: Pixels, box: { x: number; y: number; w: number; h: number }): Contrast | undefined => {
|
|
53
|
+
const left = Math.max(0, Math.floor(box.x));
|
|
54
|
+
const top = Math.max(0, Math.floor(box.y));
|
|
55
|
+
const right = Math.min(picture.width, Math.ceil(box.x + box.w));
|
|
56
|
+
const bottom = Math.min(picture.height, Math.ceil(box.y + box.h));
|
|
57
|
+
if (right - left < 2 || bottom - top < 2) return undefined;
|
|
58
|
+
|
|
59
|
+
// Colors are gathered 8 levels to a bucket: antialiasing and compression never give one flat value.
|
|
60
|
+
const buckets = new Map<number, { count: number; r: number; g: number; b: number }>();
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (let y = top; y < bottom; y++) {
|
|
63
|
+
for (let x = left; x < right; x++) {
|
|
64
|
+
const at = (y * picture.width + x) * 4;
|
|
65
|
+
const r = picture.data[at]!;
|
|
66
|
+
const g = picture.data[at + 1]!;
|
|
67
|
+
const b = picture.data[at + 2]!;
|
|
68
|
+
const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
|
|
69
|
+
const bucket = buckets.get(key);
|
|
70
|
+
if (bucket) {
|
|
71
|
+
bucket.count++;
|
|
72
|
+
bucket.r += r;
|
|
73
|
+
bucket.g += g;
|
|
74
|
+
bucket.b += b;
|
|
75
|
+
} else {
|
|
76
|
+
buckets.set(key, { count: 1, r, g, b });
|
|
77
|
+
}
|
|
78
|
+
total++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const colors = [...buckets.values()].map((bucket) => ({
|
|
82
|
+
count: bucket.count,
|
|
83
|
+
rgb: [bucket.r / bucket.count, bucket.g / bucket.count, bucket.b / bucket.count] as Rgb,
|
|
84
|
+
}));
|
|
85
|
+
const background = colors.reduce((most, color) => (color.count > most.count ? color : most));
|
|
86
|
+
const least = Math.max(LEAST_PIXELS, total * LEAST_SHARE);
|
|
87
|
+
let best: { ratio: number; rgb: Rgb } | undefined;
|
|
88
|
+
for (const color of colors) {
|
|
89
|
+
if (color === background || color.count < least) continue;
|
|
90
|
+
const ratio = contrastRatio(color.rgb, background.rgb);
|
|
91
|
+
if (!best || ratio > best.ratio) best = { ratio, rgb: color.rgb };
|
|
92
|
+
}
|
|
93
|
+
// Nothing but background: an empty field, or text the same color as what is behind it.
|
|
94
|
+
if (!best || best.ratio < 1.05) return undefined;
|
|
95
|
+
return { ratio: best.ratio, text: best.rgb, background: background.rgb, plain: background.count / total >= PLAIN_SHARE };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const isLarge = (font: Element['font']): boolean => !!font && (font.size >= 18 || (font.size >= 14 && font.bold === true));
|
|
99
|
+
|
|
100
|
+
/** WCAG leaves out what cannot be used (a disabled control) and what is not there to be read. */
|
|
101
|
+
const judged = (e: Element): boolean =>
|
|
102
|
+
!e.hidden &&
|
|
103
|
+
!e.covered &&
|
|
104
|
+
!e.offscreen &&
|
|
105
|
+
e.enabled !== false &&
|
|
106
|
+
(e.kind === 'text' || e.kind === 'button' || e.kind === 'input') &&
|
|
107
|
+
(e.text ?? e.value ?? e.placeholder) !== undefined;
|
|
108
|
+
|
|
109
|
+
/** A finding for every text on the screen whose contrast falls short, most serious first. */
|
|
110
|
+
export const lintContrast = (screen: Screen, picture: Pixels, options: ContrastOptions = {}): Finding[] => {
|
|
111
|
+
const window = screen.window;
|
|
112
|
+
if (!window || window.w <= 0) return [];
|
|
113
|
+
const scale = picture.width / window.w;
|
|
114
|
+
const out: Finding[] = [];
|
|
115
|
+
for (const e of screen.elements ?? []) {
|
|
116
|
+
if (!judged(e) || !e.frame || e.frame.w <= 0 || e.frame.h <= 0) continue;
|
|
117
|
+
if (e.testId && options.ignore?.some((pattern) => pattern.test(e.testId!))) continue;
|
|
118
|
+
const measured = measureContrast(picture, { x: e.frame.x * scale, y: e.frame.y * scale, w: e.frame.w * scale, h: e.frame.h * scale });
|
|
119
|
+
if (!measured) continue;
|
|
120
|
+
const large = isLarge(e.font);
|
|
121
|
+
const needed = large ? (options.large ?? AA_LARGE) : (options.normal ?? AA_NORMAL);
|
|
122
|
+
if (measured.ratio >= needed) continue;
|
|
123
|
+
const said = (e.text ?? e.value ?? e.placeholder ?? '').slice(0, 40);
|
|
124
|
+
out.push({
|
|
125
|
+
rule: 'low-contrast',
|
|
126
|
+
// Over a picture the background is a guess, so it is said and left to a person.
|
|
127
|
+
severity: measured.plain ? 'warn' : 'info',
|
|
128
|
+
element: e.i,
|
|
129
|
+
// The text, not the ratio: a baseline knows the finding again when the measurement moves.
|
|
130
|
+
subject: said,
|
|
131
|
+
message: `"${said}" ${measured.ratio.toFixed(1)}:1 (${hex(measured.text)} on ${hex(measured.background)}${measured.plain ? '' : ', over a varied background'}), needs ${needed}:1${large ? ' for large text' : ''}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
};
|
package/src/knowledge/errors.ts
CHANGED
|
@@ -35,7 +35,7 @@ const RULES: Rule[] = [
|
|
|
35
35
|
re: /Cannot read propert(?:y|ies) of undefined \(reading '(\w+)'\)/,
|
|
36
36
|
cause: (m) => `something the app expected to exist is undefined (reading '${m[1]}')`,
|
|
37
37
|
fix: () =>
|
|
38
|
-
`look one frame up the stack for the module: a mock returning {} instead of the shape the app reads, or a store not
|
|
38
|
+
`look one frame up the stack for the module: a mock returning {} instead of the shape the app reads, or a store not initialized because a service failed earlier.`,
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
re: /Jest did not exit one second after the test run has completed/,
|