stillhunter 0.0.1 → 0.2.1
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/.claude-plugin/plugin.json +10 -0
- package/README.md +102 -2
- package/agents/hunter.md +105 -0
- package/dist/accounting.mjs +99 -0
- package/dist/browser.mjs +76 -0
- package/dist/change.mjs +217 -0
- package/dist/common.mjs +110 -0
- package/dist/coverage.mjs +119 -0
- package/dist/finish.mjs +357 -0
- package/dist/flags.mjs +49 -0
- package/dist/hunt.mjs +193 -0
- package/dist/keep.mjs +195 -0
- package/dist/layout.mjs +143 -0
- package/dist/probe-child.mjs +456 -0
- package/dist/probe-host.mjs +206 -0
- package/dist/relay.mjs +235 -0
- package/dist/reset.mjs +87 -0
- package/dist/results.mjs +87 -0
- package/dist/target.mjs +121 -0
- package/hooks/guard.mjs +255 -0
- package/hooks/hooks.json +28 -0
- package/npm-shrinkwrap.json +447 -0
- package/package.json +41 -3
- package/skills/hunt/SKILL.md +25 -0
- package/skills/keep/SKILL.md +19 -0
- package/skills/replay/SKILL.md +14 -0
package/dist/keep.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// keep and replay: a confirmed finding becomes a test under .stillhunter/tests/
|
|
2
|
+
// that runs with no model. A kept test passes when its check holds, so it
|
|
3
|
+
// fails while the bug is there and guards against its return once fixed.
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { chooseBrowser, installedOnThisMachine } from './browser.mjs';
|
|
8
|
+
import { ensureRunsIgnored } from './change.mjs';
|
|
9
|
+
import { block, fail, findTargetUrl, FOLDER, flag, loadRun, PROBE_NAME, readConfig, repoRoot, runsDir, signInVariableNames, stamp, testsDir } from './common.mjs';
|
|
10
|
+
import { runProbe } from './probe-host.mjs';
|
|
11
|
+
import { resetApp } from './reset.mjs';
|
|
12
|
+
import { outcome, probesDir, signInProbe } from './results.mjs';
|
|
13
|
+
import { answers } from './target.mjs';
|
|
14
|
+
export const SIGNIN = '_signin';
|
|
15
|
+
const WATCH_SLOW_MO_MS = 250;
|
|
16
|
+
const WATCH_KEEP_OPEN_MS = 10_000;
|
|
17
|
+
// A kept test is committed, so it must not carry a credential: a probe that
|
|
18
|
+
// typed a sign-in value literally instead of reading process.env is refused.
|
|
19
|
+
export function credentialIn(script, names, env = process.env) {
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
const value = env[name];
|
|
22
|
+
if (value && value.length >= 4 && script.includes(value))
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function keep(args) {
|
|
28
|
+
const { ctx, dir, root } = loadRun(args);
|
|
29
|
+
const probe = flag(args, '--probe');
|
|
30
|
+
const check = flag(args, '--check');
|
|
31
|
+
const as = flag(args, '--as') ?? probe;
|
|
32
|
+
if (!probe || !PROBE_NAME.test(probe))
|
|
33
|
+
fail('Give the probe of the finding with --probe.');
|
|
34
|
+
if (!check)
|
|
35
|
+
fail('Give the expectation of the finding with --check.');
|
|
36
|
+
if (!as || !PROBE_NAME.test(as))
|
|
37
|
+
fail('--as is a name of lowercase letters, digits and dashes.');
|
|
38
|
+
let confirmed = [];
|
|
39
|
+
try {
|
|
40
|
+
confirmed = JSON.parse(readFileSync(path.join(dir, 'findings.json'), 'utf8')).confirmed ?? [];
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
fail(`Run ${ctx.runId} has not finished, so it has no confirmed findings to keep.`);
|
|
44
|
+
}
|
|
45
|
+
const finding = confirmed.find((f) => f.probe === probe && f.check === check);
|
|
46
|
+
if (!finding) {
|
|
47
|
+
const list = confirmed.map((f) => `--probe ${JSON.stringify(f.probe)} --check ${JSON.stringify(f.check)}`).join('; ');
|
|
48
|
+
fail(`Run ${ctx.runId} confirmed no finding with probe "${probe}" and expectation "${check}". Confirmed: ${list || 'none'}.`);
|
|
49
|
+
}
|
|
50
|
+
// Prefer the snapshot finish took of the script it proved: the live probe
|
|
51
|
+
// file may have been re-run and overwritten since the finding was confirmed.
|
|
52
|
+
const proofScript = path.join(dir, 'proof', `${probe}.mjs`);
|
|
53
|
+
const scriptFile = existsSync(proofScript) ? proofScript : path.join(probesDir(dir), `${probe}.mjs`);
|
|
54
|
+
const script = readFileSync(scriptFile, 'utf8');
|
|
55
|
+
const leaked = credentialIn(script, ctx.signInVariables);
|
|
56
|
+
if (leaked)
|
|
57
|
+
fail(`The probe holds the value of ${leaked}. Kept tests never hold a credential; read it from process.env.${leaked} instead.`);
|
|
58
|
+
const dest = path.join(testsDir(root), as);
|
|
59
|
+
if (existsSync(dest))
|
|
60
|
+
fail(`A kept test named ${as} exists. Choose another name with --as.`);
|
|
61
|
+
const lines = [`stillhunter | kept | ${as}`];
|
|
62
|
+
const signIn = signInProbe(dir);
|
|
63
|
+
const signInDest = path.join(testsDir(root), `${SIGNIN}.mjs`);
|
|
64
|
+
let signInScript = null;
|
|
65
|
+
if (signIn && !existsSync(signInDest)) {
|
|
66
|
+
signInScript = readFileSync(path.join(probesDir(dir), `${signIn}.mjs`), 'utf8');
|
|
67
|
+
const leakedSignIn = credentialIn(signInScript, ctx.signInVariables);
|
|
68
|
+
if (leakedSignIn)
|
|
69
|
+
fail(`The sign-in probe "${signIn}" holds the value of ${leakedSignIn}. Kept tests never hold a credential; read it from process.env.${leakedSignIn} instead.`);
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dest, { recursive: true });
|
|
72
|
+
writeFileSync(path.join(dest, 'test.mjs'), script);
|
|
73
|
+
const meta = { check, title: finding.title, severity: finding.severity, keptFrom: { run: ctx.runId, probe } };
|
|
74
|
+
writeFileSync(path.join(dest, 'test.json'), JSON.stringify(meta, null, 2) + '\n');
|
|
75
|
+
lines.push(`${FOLDER}/tests/${as}/test.mjs: "${finding.title}" (expectation "${check}")`);
|
|
76
|
+
if (signInScript !== null) {
|
|
77
|
+
writeFileSync(signInDest, signInScript);
|
|
78
|
+
lines.push(`${FOLDER}/tests/${SIGNIN}.mjs: the sign-in probe "${signIn}", run before each replay`);
|
|
79
|
+
}
|
|
80
|
+
else if (existsSync(signInDest)) {
|
|
81
|
+
lines.push(`${FOLDER}/tests/${SIGNIN}.mjs: kept already, left as it is`);
|
|
82
|
+
}
|
|
83
|
+
lines.push('While the bug is there the test fails; once it is fixed the test passes.', `Run it with: npx stillhunter replay ${as} (or /stillhunter:replay ${as})`);
|
|
84
|
+
process.stdout.write(block(lines));
|
|
85
|
+
}
|
|
86
|
+
function keptTests(root) {
|
|
87
|
+
const dir = testsDir(root);
|
|
88
|
+
if (!existsSync(dir))
|
|
89
|
+
return [];
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const name of readdirSync(dir).sort()) {
|
|
92
|
+
if (!PROBE_NAME.test(name) || !existsSync(path.join(dir, name, 'test.mjs')))
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
out.push({ name, meta: JSON.parse(readFileSync(path.join(dir, name, 'test.json'), 'utf8')) });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A test without readable metadata cannot say which check it guards.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
export async function replay(args) {
|
|
104
|
+
const root = repoRoot();
|
|
105
|
+
const watch = args.includes('--watch');
|
|
106
|
+
const names = args.filter((a) => a !== '--watch');
|
|
107
|
+
const unknownFlag = names.find((n) => n.startsWith('-'));
|
|
108
|
+
if (unknownFlag)
|
|
109
|
+
fail(`Unknown replay option ${unknownFlag}. replay takes test names and --watch.`);
|
|
110
|
+
const all = keptTests(root);
|
|
111
|
+
if (all.length === 0)
|
|
112
|
+
fail(`No kept tests in ${FOLDER}/tests/. Keep a confirmed finding with /stillhunter:keep.`);
|
|
113
|
+
const missing = names.filter((n) => !all.some((t) => t.name === n));
|
|
114
|
+
if (missing.length)
|
|
115
|
+
fail(`No kept test named ${missing.join(', ')}. Kept tests: ${all.map((t) => t.name).join(', ')}.`);
|
|
116
|
+
const selected = names.length ? all.filter((t) => names.includes(t.name)) : all;
|
|
117
|
+
if (watch && selected.length !== 1)
|
|
118
|
+
fail(`--watch shows one test in a visible browser. Name one: ${selected.map((t) => t.name).join(', ')}.`);
|
|
119
|
+
const config = readConfig(root);
|
|
120
|
+
const target = await findTargetUrl(root, config);
|
|
121
|
+
if (!target)
|
|
122
|
+
fail(`No running app found. Start your dev server, or set "target": { "url": ... } in ${FOLDER}/config.json.`);
|
|
123
|
+
if ((await answers(target)) === null)
|
|
124
|
+
fail(`Nothing answers at ${target}. Start your dev server first.`);
|
|
125
|
+
const choice = chooseBrowser(config.browser ?? null, installedOnThisMachine());
|
|
126
|
+
if (!choice.ok)
|
|
127
|
+
fail(choice.message);
|
|
128
|
+
const runId = `${stamp()}-replay-${randomBytes(3).toString('hex').slice(0, 4)}`;
|
|
129
|
+
const dir = path.join(runsDir(root), runId);
|
|
130
|
+
// A replay run folder holds a signed-in session too, so keep it out of git
|
|
131
|
+
// even when replay is the first command run in a fresh clone.
|
|
132
|
+
ensureRunsIgnored(path.join(root, FOLDER, '.gitignore'));
|
|
133
|
+
mkdirSync(probesDir(dir), { recursive: true });
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const ctx = {
|
|
136
|
+
runId, root, target, browser: choice.name, base: '', startedAt: now, deadline: now, depth: 'replay', budgetMinutes: 0,
|
|
137
|
+
minSeverity: 'low', focus: null, mode: 'replay', signInVariables: signInVariableNames(),
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(path.join(dir, 'context.json'), JSON.stringify(ctx, null, 2) + '\n');
|
|
140
|
+
// The child may read only the run folder, so each test is copied into it.
|
|
141
|
+
const stage = (name, from) => {
|
|
142
|
+
const file = path.join(probesDir(dir), `${name}.mjs`);
|
|
143
|
+
copyFileSync(from, file);
|
|
144
|
+
return file;
|
|
145
|
+
};
|
|
146
|
+
const host = (name, file, watching = false) => runProbe({
|
|
147
|
+
runDir: dir, probe: name, probeFile: file, target, browser: choice.name, signInVariables: ctx.signInVariables, shotsDir: path.join(dir, 'shots'),
|
|
148
|
+
...(watching ? { headed: true, slowMo: WATCH_SLOW_MO_MS, keepOpenMs: WATCH_KEEP_OPEN_MS } : {}),
|
|
149
|
+
}).then((r) => { writeFileSync(path.join(probesDir(dir), `${name}.result.json`), JSON.stringify(r, null, 2) + '\n'); return r; });
|
|
150
|
+
const signInSource = path.join(testsDir(root), `${SIGNIN}.mjs`);
|
|
151
|
+
const signInFile = existsSync(signInSource) ? stage(SIGNIN, signInSource) : null;
|
|
152
|
+
const signInLines = [];
|
|
153
|
+
const signIn = async () => {
|
|
154
|
+
if (!signInFile)
|
|
155
|
+
return;
|
|
156
|
+
const r = await host(SIGNIN, signInFile);
|
|
157
|
+
const problem = r.error ?? (r.savedSession ? null : 'it saved no session');
|
|
158
|
+
if (problem)
|
|
159
|
+
signInLines.push(`sign-in failed: ${problem}`);
|
|
160
|
+
};
|
|
161
|
+
const notes = [];
|
|
162
|
+
if (!config.reset)
|
|
163
|
+
await signIn();
|
|
164
|
+
const lines = [];
|
|
165
|
+
let failed = 0;
|
|
166
|
+
for (const t of selected) {
|
|
167
|
+
if (config.reset) {
|
|
168
|
+
const r = await resetApp(root, config);
|
|
169
|
+
if (r)
|
|
170
|
+
notes.push(`before ${t.name}: ${r}`);
|
|
171
|
+
await signIn();
|
|
172
|
+
}
|
|
173
|
+
const result = await host(t.name, stage(t.name, path.join(testsDir(root), t.name, 'test.mjs')), watch);
|
|
174
|
+
const o = outcome(result, t.meta.check);
|
|
175
|
+
if (o.state === 'held') {
|
|
176
|
+
lines.push(`PASS ${t.name}: ${t.meta.title} (expectation "${t.meta.check}" holds)`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
failed += 1;
|
|
180
|
+
lines.push(o.state === 'failed'
|
|
181
|
+
? `FAIL ${t.name}: ${t.meta.title} (expectation "${t.meta.check}" fails: ${o.detail})`
|
|
182
|
+
: `FAIL ${t.name}: ${t.meta.title} (expectation "${t.meta.check}" not reached${result.error ? `: ${result.error}` : ''})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(block([
|
|
186
|
+
`stillhunter | replay | ${selected.length} test${selected.length === 1 ? '' : 's'} | ${selected.length - failed} passed, ${failed} failed`,
|
|
187
|
+
`target: ${target}${signInFile ? '' : ` | no ${SIGNIN}.mjs kept, so tests start signed out`}`,
|
|
188
|
+
...[...new Set(signInLines)],
|
|
189
|
+
...notes,
|
|
190
|
+
...lines,
|
|
191
|
+
`results: ${FOLDER}/runs/${runId}/`,
|
|
192
|
+
]));
|
|
193
|
+
if (failed > 0)
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
}
|
package/dist/layout.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Layout candidates for observe(): places where a person cannot see what the
|
|
2
|
+
// page holds. It runs inside the page, so it must not use anything from the
|
|
3
|
+
// module around it. Only a 1 px rounding allowance is used: no threshold is
|
|
4
|
+
// tuned to a particular app.
|
|
5
|
+
export function layoutCandidates(limit) {
|
|
6
|
+
const SKIP_INPUT = new Set(['hidden', 'checkbox', 'radio', 'range', 'color', 'file', 'submit', 'button', 'reset', 'image']);
|
|
7
|
+
const SKIP_TAG = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'BR', 'OPTION']);
|
|
8
|
+
const short = (s, n = 80) => {
|
|
9
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
10
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
11
|
+
};
|
|
12
|
+
const shown = (el) => {
|
|
13
|
+
const r = el.getBoundingClientRect();
|
|
14
|
+
if (r.width <= 0 || r.height <= 0)
|
|
15
|
+
return false;
|
|
16
|
+
const check = el.checkVisibility;
|
|
17
|
+
if (check)
|
|
18
|
+
return check.call(el, { checkOpacity: true, visibilityProperty: true });
|
|
19
|
+
const s = getComputedStyle(el);
|
|
20
|
+
return s.visibility !== 'hidden' && s.display !== 'none';
|
|
21
|
+
};
|
|
22
|
+
const selectorOf = (el) => {
|
|
23
|
+
const parts = [];
|
|
24
|
+
let e = el;
|
|
25
|
+
for (let depth = 0; e && e !== document.body && depth < 4; depth++) {
|
|
26
|
+
if (e.id) {
|
|
27
|
+
parts.unshift(`#${CSS.escape(e.id)}`);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
let s = e.tagName.toLowerCase();
|
|
31
|
+
const classes = [...e.classList].slice(0, 2);
|
|
32
|
+
if (classes.length)
|
|
33
|
+
s += `.${classes.map((c) => CSS.escape(c)).join('.')}`;
|
|
34
|
+
const parent = e.parentElement;
|
|
35
|
+
if (parent) {
|
|
36
|
+
const same = [...parent.children].filter((x) => x.tagName === e.tagName);
|
|
37
|
+
if (same.length > 1)
|
|
38
|
+
s += `:nth-of-type(${same.indexOf(e) + 1})`;
|
|
39
|
+
}
|
|
40
|
+
parts.unshift(s);
|
|
41
|
+
e = parent;
|
|
42
|
+
}
|
|
43
|
+
return parts.join(' > ');
|
|
44
|
+
};
|
|
45
|
+
const fieldName = (el) => short(el.labels?.[0]?.innerText || el.getAttribute('aria-label') || el.getAttribute('name') || el.id || el.tagName.toLowerCase(), 40);
|
|
46
|
+
const canvas = document.createElement('canvas').getContext('2d');
|
|
47
|
+
const textWidth = (text, s) => {
|
|
48
|
+
if (!canvas)
|
|
49
|
+
return 0;
|
|
50
|
+
canvas.font = s.font || `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
51
|
+
const spacing = Number.parseFloat(s.letterSpacing) || 0;
|
|
52
|
+
return canvas.measureText(text).width + spacing * text.length;
|
|
53
|
+
};
|
|
54
|
+
const valueClipped = [];
|
|
55
|
+
for (const el of document.querySelectorAll('input, select, textarea')) {
|
|
56
|
+
if (el instanceof HTMLInputElement && SKIP_INPUT.has(el.type))
|
|
57
|
+
continue;
|
|
58
|
+
if (!shown(el))
|
|
59
|
+
continue;
|
|
60
|
+
const s = getComputedStyle(el);
|
|
61
|
+
const content = el.clientWidth - (Number.parseFloat(s.paddingLeft) || 0) - (Number.parseFloat(s.paddingRight) || 0);
|
|
62
|
+
const text = el instanceof HTMLSelectElement ? (el.selectedOptions[0]?.text ?? '') : (el.value || el.getAttribute('placeholder') || '');
|
|
63
|
+
const scrolls = el.scrollWidth > el.clientWidth + 1;
|
|
64
|
+
// A textarea wraps its text, so only a sideways scroll means it is cut.
|
|
65
|
+
const needed = el instanceof HTMLTextAreaElement || text === '' ? 0 : textWidth(text, s);
|
|
66
|
+
if (scrolls || needed > content + 1) {
|
|
67
|
+
valueClipped.push({
|
|
68
|
+
kind: 'value-clipped',
|
|
69
|
+
selector: selectorOf(el),
|
|
70
|
+
text: `${fieldName(el)}: "${short(text, 60)}"`,
|
|
71
|
+
detail: needed > content + 1 ? `text needs ${Math.ceil(needed)} px, the box shows ${Math.floor(content)} px` : `scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const all = [...document.body.querySelectorAll('*')].filter((el) => !SKIP_TAG.has(el.tagName));
|
|
76
|
+
const textClipped = [];
|
|
77
|
+
for (const el of all) {
|
|
78
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)
|
|
79
|
+
continue;
|
|
80
|
+
const s = getComputedStyle(el);
|
|
81
|
+
const clipX = s.overflowX === 'hidden' || s.overflowX === 'clip' || s.textOverflow === 'ellipsis';
|
|
82
|
+
const clipY = s.overflowY === 'hidden' || s.overflowY === 'clip';
|
|
83
|
+
if (!clipX && !clipY)
|
|
84
|
+
continue;
|
|
85
|
+
const r = el.getBoundingClientRect();
|
|
86
|
+
// 1 px boxes are the usual visually-hidden pattern for screen readers.
|
|
87
|
+
if (r.width <= 1 || r.height <= 1 || !shown(el))
|
|
88
|
+
continue;
|
|
89
|
+
const text = el.innerText ?? '';
|
|
90
|
+
if (!text.trim())
|
|
91
|
+
continue;
|
|
92
|
+
const wide = clipX && el.scrollWidth > el.clientWidth + 1;
|
|
93
|
+
const tall = clipY && el.scrollHeight > el.clientHeight + 1;
|
|
94
|
+
if (!wide && !tall)
|
|
95
|
+
continue;
|
|
96
|
+
textClipped.push({
|
|
97
|
+
kind: 'text-clipped',
|
|
98
|
+
selector: selectorOf(el),
|
|
99
|
+
text: short(text),
|
|
100
|
+
detail: [wide ? `content ${el.scrollWidth} px wide in a ${el.clientWidth} px box` : '', tall ? `content ${el.scrollHeight} px tall in a ${el.clientHeight} px box` : ''].filter(Boolean).join('; '),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
104
|
+
const offScreen = [];
|
|
105
|
+
const reported = new Set();
|
|
106
|
+
const scrollsSideways = (el) => {
|
|
107
|
+
for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
|
|
108
|
+
if (a === document.body)
|
|
109
|
+
break;
|
|
110
|
+
const ox = getComputedStyle(a).overflowX;
|
|
111
|
+
if (ox === 'auto' || ox === 'scroll')
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
};
|
|
116
|
+
for (const el of all) {
|
|
117
|
+
const r = el.getBoundingClientRect();
|
|
118
|
+
// Off-screen is any visible element whose right edge is past the viewport,
|
|
119
|
+
// including one pushed entirely past it, unless it scrolls sideways on
|
|
120
|
+
// purpose. An element wholly off the left (right edge at or before 0) is
|
|
121
|
+
// left out by the first test.
|
|
122
|
+
if (r.right <= viewport + 1 || r.width <= 0 || r.height <= 0)
|
|
123
|
+
continue;
|
|
124
|
+
if (!shown(el) || scrollsSideways(el))
|
|
125
|
+
continue;
|
|
126
|
+
let inner = false;
|
|
127
|
+
for (let a = el.parentElement; a; a = a.parentElement)
|
|
128
|
+
if (reported.has(a)) {
|
|
129
|
+
inner = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (inner)
|
|
133
|
+
continue;
|
|
134
|
+
reported.add(el);
|
|
135
|
+
offScreen.push({
|
|
136
|
+
kind: 'off-screen',
|
|
137
|
+
selector: selectorOf(el),
|
|
138
|
+
text: short(el.innerText ?? ''),
|
|
139
|
+
detail: `right edge at ${Math.round(r.right)} px, the viewport is ${viewport} px wide`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return [...valueClipped, ...textClipped, ...offScreen].slice(0, limit);
|
|
143
|
+
}
|