lynkeus 0.2.0 → 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/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 +80 -3
- package/lib/cases/runner.js +387 -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 +27 -6
- package/lib/commands/cases.js +360 -37
- package/lib/commands/check.d.ts +6 -0
- package/lib/commands/check.js +8 -3
- package/lib/commands/drive.js +32 -8
- package/lib/commands/headless.js +12 -2
- package/lib/commands/init.d.ts +6 -0
- package/lib/commands/init.js +78 -21
- package/lib/commands/report.d.ts +97 -0
- package/lib/commands/report.js +228 -0
- package/lib/config.d.ts +10 -1
- 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/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/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 +446 -46
- package/src/cases/templates.ts +14 -4
- package/src/cases/traces.ts +298 -0
- package/src/cli.ts +28 -6
- package/src/commands/cases.ts +375 -37
- package/src/commands/check.ts +11 -4
- package/src/commands/drive.ts +31 -8
- package/src/commands/headless.ts +12 -2
- package/src/commands/init.ts +84 -20
- package/src/commands/report.ts +272 -0
- package/src/config.ts +6 -2
- package/src/device/agentVersion.ts +15 -0
- package/src/device/devices.ts +28 -0
- 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/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
package/lib/commands/init.d.ts
CHANGED
|
@@ -10,4 +10,10 @@ export type InitOptions = {
|
|
|
10
10
|
language?: string;
|
|
11
11
|
};
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
15
|
+
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
16
|
+
* loads the router.
|
|
17
|
+
*/
|
|
18
|
+
export declare const mountAgent: (root: string) => string[];
|
|
13
19
|
export declare const init: (root: string, opts: InitOptions) => string[];
|
package/lib/commands/init.js
CHANGED
|
@@ -43,6 +43,82 @@ const { default: App } = require('${appImport}')
|
|
|
43
43
|
export { App }
|
|
44
44
|
`;
|
|
45
45
|
};
|
|
46
|
+
const readManifest = (root) => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** Expo inlines only the variables that start with EXPO_PUBLIC_; anything else is undefined in the app. */
|
|
55
|
+
const tokenIn = (expo) => (expo ? 'process.env.EXPO_PUBLIC_LYNKEUS_TOKEN' : 'process.env.LYNKEUS_TOKEN');
|
|
56
|
+
const mountLines = (expo) => `import { auto } from 'lynkeus-agent/auto'\n// Dev-only QA agent; a release build never evaluates the client.\nauto({ token: ${tokenIn(expo)} })\n`;
|
|
57
|
+
const BABEL_CANDIDATES = ['babel.config.js', 'babel.config.cjs'];
|
|
58
|
+
const TOKEN_PLUGIN = 'lynkeus-agent/babel';
|
|
59
|
+
/** React Native gives the app no environment: the plugin writes LYNKEUS_TOKEN where the entry reads it. */
|
|
60
|
+
const addTokenPlugin = (root) => {
|
|
61
|
+
const byHand = `add '${TOKEN_PLUGIN}' to the plugins in babel.config.js, so the app can read LYNKEUS_TOKEN`;
|
|
62
|
+
const file = findFirst(root, BABEL_CANDIDATES);
|
|
63
|
+
if (!file)
|
|
64
|
+
return byHand;
|
|
65
|
+
const text = fs.readFileSync(path.join(root, file), 'utf8');
|
|
66
|
+
if (text.includes(TOKEN_PLUGIN))
|
|
67
|
+
return `${file} already has ${TOKEN_PLUGIN}`;
|
|
68
|
+
const plugins = /\bplugins\s*:\s*\[/.exec(text);
|
|
69
|
+
const presets = /^([ \t]*)presets\s*:/m.exec(text);
|
|
70
|
+
if (!plugins && !presets)
|
|
71
|
+
return byHand;
|
|
72
|
+
const next = plugins
|
|
73
|
+
? `${text.slice(0, plugins.index + plugins[0].length)}'${TOKEN_PLUGIN}', ${text.slice(plugins.index + plugins[0].length)}`
|
|
74
|
+
: `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`;
|
|
75
|
+
fs.writeFileSync(path.join(root, file), next);
|
|
76
|
+
return `added ${TOKEN_PLUGIN} to ${file}`;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
80
|
+
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
81
|
+
* loads the router.
|
|
82
|
+
*/
|
|
83
|
+
export const mountAgent = (root) => {
|
|
84
|
+
const notes = [];
|
|
85
|
+
const manifest = readManifest(root);
|
|
86
|
+
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
87
|
+
const index = findFirst(root, INDEX_CANDIDATES);
|
|
88
|
+
const routed = manifest.main === 'expo-router/entry';
|
|
89
|
+
if (routed && !index) {
|
|
90
|
+
fs.writeFileSync(path.join(root, 'index.js'), `${mountLines(true)}\nimport 'expo-router/entry'\n`);
|
|
91
|
+
fs.writeFileSync(path.join(root, 'package.json'), `${JSON.stringify({ ...JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')), main: 'index.js' }, null, 2)}\n`);
|
|
92
|
+
notes.push("wrote index.js (mounts the agent, then loads expo-router) and pointed package.json's main at it");
|
|
93
|
+
}
|
|
94
|
+
else if (!index) {
|
|
95
|
+
notes.push(`no index.js to add the auto mount to; add it yourself: import { auto } from 'lynkeus-agent/auto'; auto({ token: ${tokenIn(expo)} })`);
|
|
96
|
+
return notes;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const indexPath = path.join(root, index);
|
|
100
|
+
const text = fs.readFileSync(indexPath, 'utf8');
|
|
101
|
+
if (text.includes('lynkeus-agent/auto')) {
|
|
102
|
+
notes.push(`${index} already mounts the agent`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// After the last import, so the app's own polyfills still run first.
|
|
106
|
+
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
107
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
|
|
108
|
+
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
109
|
+
notes.push(`added auto() to ${index}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!expo) {
|
|
113
|
+
notes.push(addTokenPlugin(root));
|
|
114
|
+
notes.push('start Metro with LYNKEUS_TOKEN set to the value the CLI reads; after changing it, start Metro with --reset-cache');
|
|
115
|
+
}
|
|
116
|
+
if (expo) {
|
|
117
|
+
notes.push('Expo: the agent has native code, so it runs in a development build (npx expo run:ios, npx expo run:android), not in Expo Go');
|
|
118
|
+
notes.push('Expo: start the dev server with EXPO_PUBLIC_LYNKEUS_TOKEN set to the same value as LYNKEUS_TOKEN; Expo passes the app only variables with that prefix');
|
|
119
|
+
}
|
|
120
|
+
return notes;
|
|
121
|
+
};
|
|
46
122
|
export const init = (root, opts) => {
|
|
47
123
|
const notes = [];
|
|
48
124
|
const entryPath = path.join(root, opts.entry);
|
|
@@ -57,27 +133,8 @@ export const init = (root, opts) => {
|
|
|
57
133
|
fs.writeFileSync(entryPath, entryTemplate(relImport(entryPath, path.join(root, app)), opts.apiEnv, packageName(root), opts.locale));
|
|
58
134
|
notes.push(`wrote ${opts.entry} (app: ${app})`);
|
|
59
135
|
}
|
|
60
|
-
if (opts.auto)
|
|
61
|
-
|
|
62
|
-
if (!index) {
|
|
63
|
-
notes.push("no index.js to add the auto mount to; add it yourself: import { auto } from 'lynkeus-agent/auto'; auto({ token: ... })");
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
const indexPath = path.join(root, index);
|
|
67
|
-
const text = fs.readFileSync(indexPath, 'utf8');
|
|
68
|
-
if (text.includes('lynkeus-agent/auto')) {
|
|
69
|
-
notes.push(`${index} already mounts the agent`);
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
const line = "import { auto } from 'lynkeus-agent/auto'\n// Dev-only QA agent; a release build never evaluates the client.\nauto({ token: process.env.LYNKEUS_TOKEN })\n";
|
|
73
|
-
// After the last import, so the app's own polyfills still run first.
|
|
74
|
-
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
75
|
-
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
|
|
76
|
-
fs.writeFileSync(indexPath, text.slice(0, at) + line + text.slice(at));
|
|
77
|
-
notes.push(`added auto() to ${index}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
136
|
+
if (opts.auto)
|
|
137
|
+
notes.push(...mountAgent(root));
|
|
81
138
|
notes.push("jest: make sure transformIgnorePatterns lets 'lynkeus-[^/]+' through, next to react-native (lynkeus headless doctor checks it)");
|
|
82
139
|
notes.push(`next: LYNKEUS_TOKEN=<token> ${opts.apiEnv}=http://127.0.0.1:3000/ lynkeus headless start`);
|
|
83
140
|
notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
type Fetch = typeof fetch;
|
|
2
|
+
export type Pushed = {
|
|
3
|
+
url: string;
|
|
4
|
+
files: number;
|
|
5
|
+
refused: string[];
|
|
6
|
+
changes?: {
|
|
7
|
+
newFailures: string[];
|
|
8
|
+
fixed: string[];
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
|
|
12
|
+
export declare const describeChanges: (changes: Pushed["changes"]) => string[];
|
|
13
|
+
/**
|
|
14
|
+
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
15
|
+
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
16
|
+
*/
|
|
17
|
+
export declare const pushRun: (dir: string, o: {
|
|
18
|
+
url: string;
|
|
19
|
+
key: string;
|
|
20
|
+
project: string;
|
|
21
|
+
fetch?: Fetch;
|
|
22
|
+
}) => Promise<Pushed>;
|
|
23
|
+
type Service = {
|
|
24
|
+
url: string;
|
|
25
|
+
key: string;
|
|
26
|
+
fetch?: Fetch;
|
|
27
|
+
};
|
|
28
|
+
export type ListedRun = {
|
|
29
|
+
project: string;
|
|
30
|
+
platform: string | null;
|
|
31
|
+
totals: Record<string, number>;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
url: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const listRuns: (o: Service & {
|
|
36
|
+
limit?: number;
|
|
37
|
+
}) => Promise<ListedRun[]>;
|
|
38
|
+
export type Flaky = {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
runs: number;
|
|
42
|
+
firstTryRate: number;
|
|
43
|
+
state: 'flaky' | 'broken';
|
|
44
|
+
recent: ('passed' | 'retried' | 'failed')[];
|
|
45
|
+
lastFailure: string | null;
|
|
46
|
+
};
|
|
47
|
+
export declare const flakes: (o: Service & {
|
|
48
|
+
project: string;
|
|
49
|
+
runs?: number;
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
runs: number;
|
|
52
|
+
cases: Flaky[];
|
|
53
|
+
}>;
|
|
54
|
+
export type CaseRun = {
|
|
55
|
+
at: string;
|
|
56
|
+
outcome: 'passed' | 'retried' | 'failed';
|
|
57
|
+
ms: number | null;
|
|
58
|
+
branch?: string | null;
|
|
59
|
+
commit?: string | null;
|
|
60
|
+
url: string;
|
|
61
|
+
};
|
|
62
|
+
export declare const caseHistory: (o: Service & {
|
|
63
|
+
project: string;
|
|
64
|
+
id: string;
|
|
65
|
+
runs?: number;
|
|
66
|
+
}) => Promise<{
|
|
67
|
+
title: string;
|
|
68
|
+
runs: CaseRun[];
|
|
69
|
+
}>;
|
|
70
|
+
export type Plan = {
|
|
71
|
+
name: string;
|
|
72
|
+
plan: string;
|
|
73
|
+
days: number;
|
|
74
|
+
projects: number | null;
|
|
75
|
+
film: boolean;
|
|
76
|
+
};
|
|
77
|
+
/** The organization's plan, and with `upgrade` or `manage` the Stripe page that changes it. */
|
|
78
|
+
export declare const plan: (o: Service & {
|
|
79
|
+
go?: "upgrade" | "manage";
|
|
80
|
+
}) => Promise<{
|
|
81
|
+
url: null;
|
|
82
|
+
name: string;
|
|
83
|
+
plan: string;
|
|
84
|
+
days: number;
|
|
85
|
+
projects: number | null;
|
|
86
|
+
film: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
url: string;
|
|
89
|
+
name: string;
|
|
90
|
+
plan: string;
|
|
91
|
+
days: number;
|
|
92
|
+
projects: number | null;
|
|
93
|
+
film: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
export declare const service: () => Service | string;
|
|
96
|
+
export declare const reportCommands: import("../registry.js").Command[];
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { define } from '../registry.js';
|
|
4
|
+
/** What a run changed against the one before, in a line or two for a terminal or a pull request comment. */
|
|
5
|
+
export const describeChanges = (changes) => changes
|
|
6
|
+
? [
|
|
7
|
+
...(changes.newFailures.length ? [`newly failing (${changes.newFailures.length}): ${changes.newFailures.join(', ')}`] : []),
|
|
8
|
+
...(changes.fixed.length ? [`fixed since the run before (${changes.fixed.length}): ${changes.fixed.join(', ')}`] : []),
|
|
9
|
+
]
|
|
10
|
+
: [];
|
|
11
|
+
const answer = async (response, doing) => {
|
|
12
|
+
if (response.ok)
|
|
13
|
+
return response.json();
|
|
14
|
+
const body = (await response.json().catch(() => ({})));
|
|
15
|
+
throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
19
|
+
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
20
|
+
*/
|
|
21
|
+
export const pushRun = async (dir, o) => {
|
|
22
|
+
const call = o.fetch ?? fetch;
|
|
23
|
+
const auth = { authorization: `Bearer ${o.key}` };
|
|
24
|
+
const base = o.url.replace(/\/+$/, '');
|
|
25
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, 'run.json'), 'utf8'));
|
|
26
|
+
const cases = fs.readFileSync(path.join(dir, 'cases.jsonl'), 'utf8');
|
|
27
|
+
const created = await answer(await call(`${base}/v1/runs`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { ...auth, 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ project: o.project, run }),
|
|
31
|
+
}), 'creating the run');
|
|
32
|
+
const id = String(created.id);
|
|
33
|
+
await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
|
|
34
|
+
const named = new Set();
|
|
35
|
+
for (const line of cases.split('\n')) {
|
|
36
|
+
if (!line.trim())
|
|
37
|
+
continue;
|
|
38
|
+
const c = JSON.parse(line);
|
|
39
|
+
for (const p of [c.video, ...(c.steps ?? []).map((s) => s.screenshot)])
|
|
40
|
+
if (p)
|
|
41
|
+
named.add(p);
|
|
42
|
+
}
|
|
43
|
+
let files = 0;
|
|
44
|
+
const refused = [];
|
|
45
|
+
for (const file of named) {
|
|
46
|
+
const local = path.join(dir, file);
|
|
47
|
+
if (!fs.existsSync(local))
|
|
48
|
+
continue;
|
|
49
|
+
const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
|
|
50
|
+
method: 'PUT',
|
|
51
|
+
headers: auth,
|
|
52
|
+
body: fs.readFileSync(local),
|
|
53
|
+
});
|
|
54
|
+
if (response.status === 402)
|
|
55
|
+
refused.push(file);
|
|
56
|
+
else {
|
|
57
|
+
await answer(response, `sending ${file}`);
|
|
58
|
+
files += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
|
|
62
|
+
const changes = finished.changes;
|
|
63
|
+
return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
|
|
64
|
+
};
|
|
65
|
+
const ask = async (o, method, route, doing) => answer(await (o.fetch ?? fetch)(`${o.url.replace(/\/+$/, '')}${route}`, { method, headers: { authorization: `Bearer ${o.key}` } }), doing);
|
|
66
|
+
export const listRuns = async (o) => (await ask(o, 'GET', `/v1/runs?limit=${o.limit ?? 20}`, 'listing the runs')).runs;
|
|
67
|
+
export const flakes = async (o) => (await ask(o, 'GET', `/v1/flakes?project=${encodeURIComponent(o.project)}&runs=${o.runs ?? 30}`, 'reading flake trends'));
|
|
68
|
+
export const caseHistory = async (o) => (await ask(o, 'GET', `/v1/cases/history?project=${encodeURIComponent(o.project)}&case=${encodeURIComponent(o.id)}&runs=${o.runs ?? 50}`, 'reading the case’s history'));
|
|
69
|
+
/** The organization's plan, and with `upgrade` or `manage` the Stripe page that changes it. */
|
|
70
|
+
export const plan = async (o) => {
|
|
71
|
+
const now = (await ask(o, 'GET', '/v1/billing', 'reading the plan'));
|
|
72
|
+
if (!o.go)
|
|
73
|
+
return { ...now, url: null };
|
|
74
|
+
const route = o.go === 'upgrade' ? '/v1/billing/checkout' : '/v1/billing/portal';
|
|
75
|
+
return { ...now, url: String((await ask(o, 'POST', route, o.go === 'upgrade' ? 'opening checkout' : 'opening the billing portal')).url) };
|
|
76
|
+
};
|
|
77
|
+
export const service = () => {
|
|
78
|
+
const url = process.env.LYNKEUS_REPORTS_URL;
|
|
79
|
+
const key = process.env.LYNKEUS_REPORTS_KEY;
|
|
80
|
+
return url && key ? { url, key } : 'set LYNKEUS_REPORTS_URL and LYNKEUS_REPORTS_KEY (the organization’s API key)';
|
|
81
|
+
};
|
|
82
|
+
export const reportCommands = [
|
|
83
|
+
define({
|
|
84
|
+
name: 'report push',
|
|
85
|
+
group: 'runs',
|
|
86
|
+
summary: 'Upload a run to the reports service and print the report’s address',
|
|
87
|
+
details: '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.',
|
|
88
|
+
positionals: [{ name: 'dir', help: 'the run directory `case run --out` wrote', required: true }],
|
|
89
|
+
flags: {
|
|
90
|
+
project: { type: 'string', help: 'the project the run is filed under (default: the app’s folder name)', value: 'name' },
|
|
91
|
+
},
|
|
92
|
+
needs: 'nothing',
|
|
93
|
+
session: false,
|
|
94
|
+
mcp: false,
|
|
95
|
+
run: async (ctx) => {
|
|
96
|
+
const dir = path.resolve(ctx.args[0]);
|
|
97
|
+
if (!fs.existsSync(path.join(dir, 'cases.jsonl')))
|
|
98
|
+
return { text: `${ctx.args[0]}: no cases.jsonl; pass the directory \`case run --out\` wrote`, code: 2 };
|
|
99
|
+
const to = service();
|
|
100
|
+
if (typeof to === 'string')
|
|
101
|
+
return { text: to, code: 2 };
|
|
102
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
103
|
+
try {
|
|
104
|
+
const pushed = await pushRun(dir, { ...to, project });
|
|
105
|
+
const notes = [...describeChanges(pushed.changes), ...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : [])];
|
|
106
|
+
return { text: [pushed.url, ...notes].join('\n'), json: pushed };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { text: error.message, code: 1 };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
define({
|
|
114
|
+
name: 'report list',
|
|
115
|
+
group: 'runs',
|
|
116
|
+
summary: 'List the organization’s reports, newest first',
|
|
117
|
+
details: 'Each line is when the run started, its project and platform, how many cases passed, and the report’s address.',
|
|
118
|
+
flags: { limit: { type: 'string', help: 'how many runs (default 20, at most 100)', value: 'n' } },
|
|
119
|
+
needs: 'nothing',
|
|
120
|
+
session: false,
|
|
121
|
+
mcp: false,
|
|
122
|
+
run: async (ctx) => {
|
|
123
|
+
const to = service();
|
|
124
|
+
if (typeof to === 'string')
|
|
125
|
+
return { text: to, code: 2 };
|
|
126
|
+
try {
|
|
127
|
+
const runs = await listRuns({ ...to, limit: Number(ctx.flags.limit) || 20 });
|
|
128
|
+
if (runs.length === 0)
|
|
129
|
+
return { text: 'no reports yet: `lynkeus report push <dir>` uploads one', json: runs };
|
|
130
|
+
const lines = runs.map((r) => `${r.createdAt.slice(0, 16).replace('T', ' ')} ${r.project}${r.platform ? ` ${r.platform}` : ''} ${r.totals.passed ?? 0}/${r.totals.cases ?? 0} passed ${r.url}`);
|
|
131
|
+
return { text: lines.join('\n'), json: runs };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { text: error.message, code: 1 };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
define({
|
|
139
|
+
name: 'report plan',
|
|
140
|
+
group: 'runs',
|
|
141
|
+
summary: 'Show the organization’s plan, or open the page that changes it',
|
|
142
|
+
details: '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.',
|
|
143
|
+
flags: {
|
|
144
|
+
upgrade: { type: 'boolean', help: 'print the Stripe checkout address for the Team plan' },
|
|
145
|
+
manage: { type: 'boolean', help: 'print the Stripe portal address for a paying organization' },
|
|
146
|
+
},
|
|
147
|
+
needs: 'nothing',
|
|
148
|
+
session: false,
|
|
149
|
+
mcp: false,
|
|
150
|
+
run: async (ctx) => {
|
|
151
|
+
const to = service();
|
|
152
|
+
if (typeof to === 'string')
|
|
153
|
+
return { text: to, code: 2 };
|
|
154
|
+
const go = ctx.flags.upgrade ? 'upgrade' : ctx.flags.manage ? 'manage' : undefined;
|
|
155
|
+
try {
|
|
156
|
+
const now = await plan({ ...to, go });
|
|
157
|
+
const keeps = `${now.film ? 'films and frames' : 'frames'} for ${now.days} days, ${now.projects ?? 'any number of'} project${now.projects === 1 ? '' : 's'}`;
|
|
158
|
+
return { text: `${now.name}: ${now.plan} (${keeps})${now.url ? `\n${now.url}` : ''}`, json: now };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return { text: error.message, code: 1 };
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
define({
|
|
166
|
+
name: 'report flakes',
|
|
167
|
+
group: 'runs',
|
|
168
|
+
summary: 'List the cases that do not always pass at the first try, across the project’s last runs',
|
|
169
|
+
details: '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.',
|
|
170
|
+
flags: {
|
|
171
|
+
project: { type: 'string', help: 'the project (default: the app’s folder name)', value: 'name' },
|
|
172
|
+
runs: { type: 'string', help: 'how many of the latest runs to read (default 30)', value: 'n' },
|
|
173
|
+
},
|
|
174
|
+
needs: 'nothing',
|
|
175
|
+
session: false,
|
|
176
|
+
mcp: false,
|
|
177
|
+
run: async (ctx) => {
|
|
178
|
+
const to = service();
|
|
179
|
+
if (typeof to === 'string')
|
|
180
|
+
return { text: to, code: 2 };
|
|
181
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
182
|
+
try {
|
|
183
|
+
const found = await flakes({ ...to, project, runs: Number(ctx.flags.runs) || 30 });
|
|
184
|
+
if (found.cases.length === 0)
|
|
185
|
+
return { text: `${project}: every case passed at the first try in the last ${found.runs} runs`, json: found };
|
|
186
|
+
const mark = { passed: '✓', retried: '↻', failed: '✗' };
|
|
187
|
+
const lines = found.cases.map((c) => `${c.state === 'broken' ? 'broken' : 'flaky '} ${Math.round(c.firstTryRate * 100)
|
|
188
|
+
.toString()
|
|
189
|
+
.padStart(3)}% ${c.recent.map((r) => mark[r]).join('')} ${c.id}${c.lastFailure ? ` ${c.lastFailure}` : ''}`);
|
|
190
|
+
return { text: [`${project}: ${found.runs} runs`, ...lines].join('\n'), json: found };
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
return { text: error.message, code: 1 };
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
define({
|
|
198
|
+
name: 'report history',
|
|
199
|
+
group: 'runs',
|
|
200
|
+
summary: 'Show how one case came out in each of the project’s last runs',
|
|
201
|
+
details: '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.',
|
|
202
|
+
positionals: [{ name: 'case', help: 'the case id', required: true }],
|
|
203
|
+
flags: {
|
|
204
|
+
project: { type: 'string', help: 'the project (default: the app’s folder name)', value: 'name' },
|
|
205
|
+
runs: { type: 'string', help: 'how many of the latest runs to read (default 50)', value: 'n' },
|
|
206
|
+
},
|
|
207
|
+
needs: 'nothing',
|
|
208
|
+
session: false,
|
|
209
|
+
mcp: false,
|
|
210
|
+
run: async (ctx) => {
|
|
211
|
+
const to = service();
|
|
212
|
+
if (typeof to === 'string')
|
|
213
|
+
return { text: to, code: 2 };
|
|
214
|
+
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
215
|
+
try {
|
|
216
|
+
const found = await caseHistory({ ...to, project, id: ctx.args[0], runs: Number(ctx.flags.runs) || 50 });
|
|
217
|
+
if (found.runs.length === 0)
|
|
218
|
+
return { text: `${ctx.args[0]} is in none of ${project}'s recent runs`, json: found };
|
|
219
|
+
const mark = { passed: '✓', retried: '↻', failed: '✗' };
|
|
220
|
+
const lines = found.runs.map((r) => `${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}`);
|
|
221
|
+
return { text: [`${found.title} (${project})`, ...lines].join('\n'), json: found };
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return { text: error.message, code: 1 };
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
];
|
package/lib/config.d.ts
CHANGED
|
@@ -39,10 +39,19 @@ export type LynkeusConfig = {
|
|
|
39
39
|
lintBaseline?: string;
|
|
40
40
|
/** Regular expressions for the text this app shows when it is broken; `smoke` and `crawl` report a match. */
|
|
41
41
|
errorCopy?: string[];
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The shape of an untranslated key, the smallest acceptable touch target in points, and how text contrast is
|
|
44
|
+
* judged: `false` turns it off; otherwise the least ratio for ordinary and for large text (4.5 and 3, WCAG 2 AA)
|
|
45
|
+
* and the testIDs to leave out, as regular expressions.
|
|
46
|
+
*/
|
|
43
47
|
lint?: {
|
|
44
48
|
i18nKeyPatterns?: string[];
|
|
45
49
|
minTouchTarget?: number;
|
|
50
|
+
contrast?: false | {
|
|
51
|
+
normal?: number;
|
|
52
|
+
large?: number;
|
|
53
|
+
ignore?: string[];
|
|
54
|
+
};
|
|
46
55
|
};
|
|
47
56
|
/**
|
|
48
57
|
* Regular expressions for the testIDs of controls whose content changes on
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HelloEvent } from 'lynkeus-protocol';
|
|
2
|
+
export declare const CLI_VERSION: string;
|
|
3
|
+
/**
|
|
4
|
+
* An agent of another release can answer without an error and still do something else (type into
|
|
5
|
+
* nothing): what to tell the person running it, or nothing when the two match.
|
|
6
|
+
*/
|
|
7
|
+
export declare const agentMismatch: (hello: Pick<HelloEvent, "agentVersion">, cli?: string) => string | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
export const CLI_VERSION = createRequire(import.meta.url)('../../package.json').version;
|
|
3
|
+
/**
|
|
4
|
+
* An agent of another release can answer without an error and still do something else (type into
|
|
5
|
+
* nothing): what to tell the person running it, or nothing when the two match.
|
|
6
|
+
*/
|
|
7
|
+
export const agentMismatch = (hello, cli = CLI_VERSION) => {
|
|
8
|
+
if (hello.agentVersion === cli)
|
|
9
|
+
return undefined;
|
|
10
|
+
const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
|
|
11
|
+
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).`;
|
|
12
|
+
};
|
package/lib/device/devices.d.ts
CHANGED
|
@@ -7,5 +7,10 @@ export type DeviceInfo = {
|
|
|
7
7
|
connected: boolean;
|
|
8
8
|
};
|
|
9
9
|
export declare const bootedSimulators: () => Promise<DeviceInfo[]>;
|
|
10
|
+
/** The serials `adb devices` lists as ready, in its order; none when there is no adb to ask. */
|
|
11
|
+
export declare const parseAdbDevices: (stdout: string) => DeviceInfo[];
|
|
12
|
+
export declare const androidDevices: () => Promise<DeviceInfo[]>;
|
|
13
|
+
/** The Android device an app that said it runs on Android is on: the one asked for, or the first that is up. */
|
|
14
|
+
export declare const pickAndroidDevice: (wanted?: string) => Promise<DeviceInfo>;
|
|
10
15
|
export declare const HEADLESS_HOST: DeviceInfo;
|
|
11
16
|
export declare const pickAgentDevice: (wanted?: string) => Promise<DeviceInfo>;
|
package/lib/device/devices.js
CHANGED
|
@@ -18,6 +18,33 @@ export const bootedSimulators = async () => {
|
|
|
18
18
|
.filter((d) => d.state === 'Booted')
|
|
19
19
|
.map((d) => ({ id: d.udid, name: d.name, platform: 'ios', type: 'simulator', connected: true }));
|
|
20
20
|
};
|
|
21
|
+
/** The serials `adb devices` lists as ready, in its order; none when there is no adb to ask. */
|
|
22
|
+
export const parseAdbDevices = (stdout) => stdout
|
|
23
|
+
.split('\n')
|
|
24
|
+
.slice(1)
|
|
25
|
+
.map((line) => line.trim().split(/\s+/))
|
|
26
|
+
.filter(([serial, state]) => serial && state === 'device')
|
|
27
|
+
.map(([serial]) => ({ id: serial, name: serial, platform: 'android', type: serial.startsWith('emulator-') ? 'emulator' : 'physical', connected: true }));
|
|
28
|
+
export const androidDevices = async () => {
|
|
29
|
+
try {
|
|
30
|
+
return parseAdbDevices((await run('adb', ['devices'])).stdout);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT')
|
|
34
|
+
return [];
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** The Android device an app that said it runs on Android is on: the one asked for, or the first that is up. */
|
|
39
|
+
export const pickAndroidDevice = async (wanted) => {
|
|
40
|
+
const devices = await androidDevices();
|
|
41
|
+
const id = wanted ?? process.env.LYNKEUS_DEVICE;
|
|
42
|
+
// A name meant for a simulator (the config's `device` when both platforms are driven) is not a reason to fail here.
|
|
43
|
+
const device = (id ? devices.find((d) => d.id === id) : undefined) ?? devices[0];
|
|
44
|
+
if (!device)
|
|
45
|
+
throw new Error('No Android device or emulator is up (adb devices lists none).');
|
|
46
|
+
return device;
|
|
47
|
+
};
|
|
21
48
|
export const HEADLESS_HOST = { id: 'headless', name: 'headless host', platform: 'ios', type: 'simulator', connected: true };
|
|
22
49
|
export const pickAgentDevice = async (wanted) => {
|
|
23
50
|
const devices = await bootedSimulators();
|
package/lib/flow/runner.js
CHANGED
|
@@ -86,6 +86,34 @@ export const runFlow = async (flow, options) => {
|
|
|
86
86
|
}
|
|
87
87
|
return report;
|
|
88
88
|
};
|
|
89
|
+
/**
|
|
90
|
+
* A keypad the app raises slides in, and under load its keys are listed a while before they can take a
|
|
91
|
+
* press: disabled, under the screen it is leaving, or still moving. A press then lands on nothing, and the
|
|
92
|
+
* code comes up a digit short.
|
|
93
|
+
*/
|
|
94
|
+
const keyReady = async (device, key, timeoutMs) => {
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
let was;
|
|
97
|
+
for (;;) {
|
|
98
|
+
const found = (await device.server.call('find', key));
|
|
99
|
+
const at = found ? JSON.stringify(found.frame) : undefined;
|
|
100
|
+
const why = !found || (found.hidden && found.hidden !== 'inert')
|
|
101
|
+
? 'not on screen'
|
|
102
|
+
: !found.enabled
|
|
103
|
+
? 'disabled'
|
|
104
|
+
: found.covered
|
|
105
|
+
? 'covered'
|
|
106
|
+
: at !== was
|
|
107
|
+
? 'moving'
|
|
108
|
+
: undefined;
|
|
109
|
+
if (!why)
|
|
110
|
+
return;
|
|
111
|
+
if (Date.now() > deadline)
|
|
112
|
+
throw new Error(`keypad key #${key.testId} is ${why} after ${timeoutMs} ms`);
|
|
113
|
+
was = at;
|
|
114
|
+
await device.idle({ quietMs: 50, timeoutMs: 200 }).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
89
117
|
const execute = async (step, flow, device, vars) => {
|
|
90
118
|
if ('note' in step)
|
|
91
119
|
return;
|
|
@@ -118,7 +146,11 @@ const execute = async (step, flow, device, vars) => {
|
|
|
118
146
|
}
|
|
119
147
|
if ('keypad' in step) {
|
|
120
148
|
for (const digit of step.keypad.digits) {
|
|
121
|
-
|
|
149
|
+
const key = { testId: step.keypad.pattern.replace('{d}', digit) };
|
|
150
|
+
await keyReady(device, key, step.keypad.timeoutMs ?? 5000);
|
|
151
|
+
await device.press({ ...key, mode: step.keypad.mode });
|
|
152
|
+
// A code field fed by the app's state drops a digit pressed before the last one rendered.
|
|
153
|
+
await device.idle({ quietMs: 50, timeoutMs: 400 }).catch(() => undefined);
|
|
122
154
|
}
|
|
123
155
|
return;
|
|
124
156
|
}
|
package/lib/flow/types.d.ts
CHANGED
|
@@ -61,12 +61,13 @@ export type FlowStep =
|
|
|
61
61
|
gone?: boolean;
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
/** Presses one button per digit, e.g. a code on `digit-{d}-button
|
|
64
|
+
/** 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). */
|
|
65
65
|
| {
|
|
66
66
|
keypad: {
|
|
67
67
|
digits: string;
|
|
68
68
|
pattern: string;
|
|
69
69
|
mode?: 'native' | 'js';
|
|
70
|
+
timeoutMs?: number;
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
/** An app command registered with `qa.register`; the result is saved under `as`. */
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { Screen } from 'lynkeus-protocol';
|
|
6
|
+
import type { Finding } from './lint.js';
|
|
7
|
+
export type Pixels = {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
data: Uint8Array | Buffer;
|
|
11
|
+
};
|
|
12
|
+
export type Rgb = [number, number, number];
|
|
13
|
+
export type Contrast = {
|
|
14
|
+
ratio: number;
|
|
15
|
+
text: Rgb;
|
|
16
|
+
background: Rgb;
|
|
17
|
+
plain: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type ContrastOptions = {
|
|
20
|
+
/** The least ratio for ordinary text (WCAG 2 AA asks 4.5). */
|
|
21
|
+
normal?: number;
|
|
22
|
+
/** The least ratio for large text: 18 points, or 14 and bold (WCAG 2 AA asks 3). */
|
|
23
|
+
large?: number;
|
|
24
|
+
/** testIDs left out: a watermark, a brand color nobody may change. */
|
|
25
|
+
ignore?: RegExp[];
|
|
26
|
+
};
|
|
27
|
+
/** WCAG's relative luminance of an sRGB color. */
|
|
28
|
+
export declare const luminance: ([r, g, b]: Rgb) => number;
|
|
29
|
+
/** WCAG's contrast ratio, from 1 (the same color) to 21 (black on white). */
|
|
30
|
+
export declare const contrastRatio: (a: Rgb, b: Rgb) => number;
|
|
31
|
+
export declare const hex: (rgb: Rgb) => string;
|
|
32
|
+
/**
|
|
33
|
+
* The contrast of the text inside a rectangle of the picture, in pixels. The background is the color most
|
|
34
|
+
* of the rectangle has; the text is the color, among those with enough pixels to be glyphs, that stands
|
|
35
|
+
* furthest from it. Anything else in the box (an icon beside a label) can only raise the figure, so a low one
|
|
36
|
+
* is a finding and a high one is not a certificate.
|
|
37
|
+
*/
|
|
38
|
+
export declare const measureContrast: (picture: Pixels, box: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
w: number;
|
|
42
|
+
h: number;
|
|
43
|
+
}) => Contrast | undefined;
|
|
44
|
+
/** A finding for every text on the screen whose contrast falls short, most serious first. */
|
|
45
|
+
export declare const lintContrast: (screen: Screen, picture: Pixels, options?: ContrastOptions) => Finding[];
|