@sous-io/sous 0.1.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/LICENSE +201 -0
- package/README.md +154 -0
- package/bin/run.js +17 -0
- package/bin/xcv +5 -0
- package/package.json +81 -0
- package/shared-prompts/_partials/resume-task.md +51 -0
- package/shared-prompts/_partials/sub-agent-delegation.md +32 -0
- package/shared-prompts/_partials/update-task-file.md +52 -0
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +52 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +102 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +81 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +126 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +92 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +61 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +65 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +96 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +104 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +243 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +148 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +383 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +267 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +56 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +169 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +59 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +25 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +140 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +140 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +1 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +185 -0
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +52 -0
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +59 -0
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +47 -0
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +26 -0
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +58 -0
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +27 -0
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +34 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/SKILL.tpl.md +177 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/examples/about-something.md +45 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/examples/do-something.md +33 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/advanced-patterns.md +87 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/commands.md +46 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/frontmatter.md +25 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/substitutions.md +50 -0
- package/shared-prompts/skills/sous-skills/about-liquid-templates/SKILL.tpl.md +268 -0
- package/shared-prompts/skills/sous-skills/about-liquid-templates/references/liquid-filters.md +82 -0
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +51 -0
- package/shared-prompts/skills/sous-skills/create-skill/SKILL.tpl.md +114 -0
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +122 -0
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +80 -0
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +14 -0
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +13 -0
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +93 -0
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +14 -0
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +13 -0
- package/src/base-command.ts +163 -0
- package/src/commands/build.ts +196 -0
- package/src/commands/clear.ts +71 -0
- package/src/commands/compile.ts +95 -0
- package/src/commands/launch.ts +111 -0
- package/src/commands/prune.ts +48 -0
- package/src/lib/build-service.ts +258 -0
- package/src/lib/config-discovery.ts +199 -0
- package/src/lib/env-local.ts +195 -0
- package/src/lib/include-resolver.ts +146 -0
- package/src/lib/markdown-compiler.ts +580 -0
- package/src/lib/pid-service.ts +88 -0
- package/src/lib/settings.ts +695 -0
- package/src/lib/state.ts +135 -0
- package/src/lib/watch-service.ts +115 -0
- package/src/templating/filters/bullet-list.ts +9 -0
- package/src/templating/filters/index.ts +8 -0
- package/src/templating/init-liquid-engine.ts +82 -0
- package/src/templating/lib/glob-files.ts +74 -0
- package/src/templating/lib/import-export.ts +32 -0
- package/src/templating/lib/tag-args.ts +19 -0
- package/src/templating/tags/exportScalarVarsJs.ts +43 -0
- package/src/templating/tags/getFiles.ts +89 -0
- package/src/templating/tags/index.ts +14 -0
- package/src/templating/tags/listFiles.ts +54 -0
- package/src/templating/tags/showVars.ts +22 -0
- package/src/utils/formatting.ts +338 -0
- package/src/utils/prompts.ts +19 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ctx.debug — exploration & debugging tools for automation scripts.
|
|
3
|
+
*
|
|
4
|
+
* Browser automation fails because authors GUESS about a page (its DOM, its
|
|
5
|
+
* timing) instead of observing it. These tools make observation cheap, so you
|
|
6
|
+
* can build condition-based waits and correct selectors from what is actually
|
|
7
|
+
* on the page. See references/script-conventions.md ("observe the real page").
|
|
8
|
+
*
|
|
9
|
+
* Built per-run and bound to the live `page`/`logger`. Artifacts (screenshots,
|
|
10
|
+
* HTML, snapshots) are written under a per-run debug directory so repeated runs
|
|
11
|
+
* don't clobber each other.
|
|
12
|
+
*
|
|
13
|
+
* Pure formatting helpers (no page I/O) are exported separately so they can be
|
|
14
|
+
* unit-tested without a browser.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { tmpdir } from 'os';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the debug toolkit for a run.
|
|
23
|
+
*
|
|
24
|
+
* @param {import('playwright').Page} page
|
|
25
|
+
* @param {object} logger - The script's logger (a child is created internally).
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {string} [opts.dir] - Directory for artifacts. Defaults to a unique
|
|
28
|
+
* subdir of the OS temp dir.
|
|
29
|
+
* @param {string} [opts.runId] - Identifier mixed into the default dir name.
|
|
30
|
+
* @returns {object} the ctx.debug surface
|
|
31
|
+
*/
|
|
32
|
+
export function createDebug(page, logger, opts = {}) {
|
|
33
|
+
const log = logger.child('debug');
|
|
34
|
+
const dir = opts.dir || join(tmpdir(), 'sous-browser-debug', opts.runId || defaultRunId());
|
|
35
|
+
let dirReady = false;
|
|
36
|
+
|
|
37
|
+
/** Lazily create the artifact dir only when something is actually written. */
|
|
38
|
+
function ensureDir() {
|
|
39
|
+
if (!dirReady) {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
dirReady = true;
|
|
42
|
+
}
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve an artifact path, creating the dir on demand. */
|
|
47
|
+
function artifact(name, ext) {
|
|
48
|
+
return join(ensureDir(), `${slug(name)}.${ext}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** Absolute path to this run's debug artifact directory. */
|
|
53
|
+
get dir() {
|
|
54
|
+
return dir;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save a screenshot. Never throws (a debug aid must not break a run).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} [name='screenshot']
|
|
61
|
+
* @param {object} [o] - { fullPage }
|
|
62
|
+
* @returns {Promise<string|null>} path written, or null on failure
|
|
63
|
+
*/
|
|
64
|
+
async screenshot(name = 'screenshot', o = {}) {
|
|
65
|
+
const { fullPage = true } = o;
|
|
66
|
+
const path = artifact(name, 'png');
|
|
67
|
+
try {
|
|
68
|
+
await page.screenshot({ path, fullPage });
|
|
69
|
+
log.info(`screenshot → ${path}`);
|
|
70
|
+
return path;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
log.warn(`screenshot failed: ${err.message}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Save the page's current HTML. Never throws.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [name='page']
|
|
81
|
+
* @returns {Promise<string|null>} path written, or null on failure
|
|
82
|
+
*/
|
|
83
|
+
async html(name = 'page') {
|
|
84
|
+
const path = artifact(name, 'html');
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(path, await page.content());
|
|
87
|
+
log.info(`html → ${path}`);
|
|
88
|
+
return path;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log.warn(`html failed: ${err.message}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Capture a full snapshot — URL, title, visible text, screenshot, and HTML —
|
|
97
|
+
* to the debug dir, and log a concise summary. The go-to "what does this page
|
|
98
|
+
* look like right now?" tool and the basis of failure auto-capture.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} [name='dump']
|
|
101
|
+
* @returns {Promise<object>} { dir, url, title, files, textPreview }
|
|
102
|
+
*/
|
|
103
|
+
async dump(name = 'dump') {
|
|
104
|
+
ensureDir();
|
|
105
|
+
const url = safeCall(() => page.url(), '(unknown url)');
|
|
106
|
+
const title = await safeAsync(() => page.title(), '(unknown title)');
|
|
107
|
+
const text = (await safeAsync(() => page.evaluate(() => document.body?.innerText || ''), '')).trim();
|
|
108
|
+
const textPath = artifact(`${name}-text`, 'txt');
|
|
109
|
+
writeFileSync(textPath, `URL: ${url}\nTITLE: ${title}\n\n${text}`);
|
|
110
|
+
const shot = await this.screenshot(`${name}-screenshot`);
|
|
111
|
+
const htmlPath = await this.html(`${name}-page`);
|
|
112
|
+
log.info(`dump "${name}": ${url} — ${title}`);
|
|
113
|
+
log.info(` text(${text.length}c) → ${textPath}`);
|
|
114
|
+
return {
|
|
115
|
+
dir,
|
|
116
|
+
url,
|
|
117
|
+
title,
|
|
118
|
+
files: { text: textPath, screenshot: shot, html: htmlPath },
|
|
119
|
+
textPreview: truncate(text, 500),
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Describe what a selector matches: how many, and for the first few, their
|
|
125
|
+
* tag/role/text/visibility/box. The fastest way to learn whether a selector
|
|
126
|
+
* is right and what it actually targets.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} selector - A CSS selector.
|
|
129
|
+
* @param {object} [o] - { limit }
|
|
130
|
+
* @returns {Promise<{count:number, elements:object[]}>}
|
|
131
|
+
*/
|
|
132
|
+
async describe(selector, o = {}) {
|
|
133
|
+
const { limit = 5 } = o;
|
|
134
|
+
const data = await safeAsync(
|
|
135
|
+
() => page.$$eval(selector, (els, lim) => els.slice(0, lim).map((el) => ({
|
|
136
|
+
tag: el.tagName,
|
|
137
|
+
role: el.getAttribute('role'),
|
|
138
|
+
id: el.id || null,
|
|
139
|
+
classes: (el.className || '').toString().slice(0, 80),
|
|
140
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
141
|
+
visible: !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length),
|
|
142
|
+
href: el.getAttribute('href'),
|
|
143
|
+
})), limit),
|
|
144
|
+
[]
|
|
145
|
+
);
|
|
146
|
+
const count = await safeAsync(() => page.$$eval(selector, (els) => els.length), 0);
|
|
147
|
+
log.info(`describe "${selector}": ${count} match(es)`);
|
|
148
|
+
for (const el of data) log.info(` ${formatElement(el)}`);
|
|
149
|
+
return { count, elements: data };
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* List interactive/clickable elements on the page (anchors, buttons, role
|
|
154
|
+
* buttons/tabs/links, elements with cursor:pointer). Invaluable when the
|
|
155
|
+
* obvious selector (e.g. an `<a href>`) doesn't exist and the real control is
|
|
156
|
+
* a click-handled div.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} [o] - { limit }
|
|
159
|
+
* @returns {Promise<object[]>}
|
|
160
|
+
*/
|
|
161
|
+
async clickables(o = {}) {
|
|
162
|
+
const { limit = 40 } = o;
|
|
163
|
+
const els = await safeAsync(
|
|
164
|
+
() => page.evaluate((lim) => {
|
|
165
|
+
const sel = 'a, button, [role="button"], [role="tab"], [role="link"], [role="menuitem"], [onclick]';
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
168
|
+
const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
169
|
+
if (!visible) continue;
|
|
170
|
+
const text = (el.textContent || '').trim();
|
|
171
|
+
out.push({
|
|
172
|
+
tag: el.tagName,
|
|
173
|
+
role: el.getAttribute('role'),
|
|
174
|
+
href: el.getAttribute('href'),
|
|
175
|
+
text: text.slice(0, 60),
|
|
176
|
+
});
|
|
177
|
+
if (out.length >= lim) break;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}, limit),
|
|
181
|
+
[]
|
|
182
|
+
);
|
|
183
|
+
log.info(`clickables: ${els.length} visible interactive element(s)`);
|
|
184
|
+
for (const el of els) log.info(` ${formatElement(el)}`);
|
|
185
|
+
return els;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Find where some text lives. Returns, for each text match, the leaf element
|
|
190
|
+
* and its clickable ancestor (the thing you probably want to click). Solves
|
|
191
|
+
* "I can see the text but what do I target?".
|
|
192
|
+
*
|
|
193
|
+
* @param {string|RegExp} pattern
|
|
194
|
+
* @param {object} [o] - { limit }
|
|
195
|
+
* @returns {Promise<object[]>}
|
|
196
|
+
*/
|
|
197
|
+
async findText(pattern, o = {}) {
|
|
198
|
+
const { limit = 10 } = o;
|
|
199
|
+
const { source, flags } = regexParts(pattern);
|
|
200
|
+
const hits = await safeAsync(
|
|
201
|
+
() => page.evaluate((args) => {
|
|
202
|
+
const re = new RegExp(args.source, args.flags);
|
|
203
|
+
const clickableSel = 'a,button,[role="button"],[role="tab"],[role="link"],[onclick]';
|
|
204
|
+
const isClickable = (el) =>
|
|
205
|
+
el.matches(clickableSel) || getComputedStyle(el).cursor === 'pointer';
|
|
206
|
+
// Match the element that DIRECTLY owns the text (in its own text nodes),
|
|
207
|
+
// not childless leaves only — text can sit on an element that also has
|
|
208
|
+
// element children (e.g. a tab label beside an icon). Matching own-text
|
|
209
|
+
// also avoids reporting every ancestor up the tree for one string.
|
|
210
|
+
const ownText = (el) =>
|
|
211
|
+
[...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('');
|
|
212
|
+
const out = [];
|
|
213
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
214
|
+
while (walker.nextNode()) {
|
|
215
|
+
const el = walker.currentNode;
|
|
216
|
+
if (re.test(ownText(el))) {
|
|
217
|
+
let anc = el, depth = 0;
|
|
218
|
+
while (anc && depth < 8 && !isClickable(anc)) { anc = anc.parentElement; depth += 1; }
|
|
219
|
+
out.push({
|
|
220
|
+
text: (el.textContent || '').trim().slice(0, 60),
|
|
221
|
+
leafTag: el.tagName,
|
|
222
|
+
clickableAncestor: anc
|
|
223
|
+
? { tag: anc.tagName, role: anc.getAttribute('role'), classes: (anc.className || '').toString().slice(0, 60) }
|
|
224
|
+
: null,
|
|
225
|
+
});
|
|
226
|
+
if (out.length >= args.limit) break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}, { source, flags, limit }),
|
|
231
|
+
[]
|
|
232
|
+
);
|
|
233
|
+
log.info(`findText ${pattern}: ${hits.length} match(es)`);
|
|
234
|
+
for (const h of hits) {
|
|
235
|
+
const anc = h.clickableAncestor ? ` → clickable<${h.clickableAncestor.tag}${h.clickableAncestor.role ? ` role=${h.clickableAncestor.role}` : ''}>` : ' (no clickable ancestor)';
|
|
236
|
+
log.info(` "${h.text}" [${h.leafTag}]${anc}`);
|
|
237
|
+
}
|
|
238
|
+
return hits;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sample a page metric repeatedly over time and log how it evolves. Built to
|
|
243
|
+
* answer "WHEN is this actually ready?" — the question that exposes why
|
|
244
|
+
* `networkidle` lies and where a real readiness signal lives.
|
|
245
|
+
*
|
|
246
|
+
* @param {() => any} fn - Runs in the BROWSER; returns a JSON-serializable metric.
|
|
247
|
+
* e.g. `() => document.body.innerText.length`
|
|
248
|
+
* @param {object} [o] - { samples=10, intervalMs=500, label='metric' }
|
|
249
|
+
* @returns {Promise<Array<{t:number, value:any}>>}
|
|
250
|
+
*/
|
|
251
|
+
async watch(fn, o = {}) {
|
|
252
|
+
const { samples = 10, intervalMs = 500, label = 'metric' } = o;
|
|
253
|
+
const series = [];
|
|
254
|
+
for (let i = 0; i < samples; i++) {
|
|
255
|
+
const value = await safeAsync(() => page.evaluate(fn), null);
|
|
256
|
+
const t = i * intervalMs;
|
|
257
|
+
series.push({ t, value });
|
|
258
|
+
log.info(`watch[${label}] +${t}ms: ${formatValue(value)}`);
|
|
259
|
+
if (i < samples - 1) await page.waitForTimeout(intervalMs);
|
|
260
|
+
}
|
|
261
|
+
return series;
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Quick count of elements matching a selector.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} selector
|
|
268
|
+
* @returns {Promise<number>}
|
|
269
|
+
*/
|
|
270
|
+
async count(selector) {
|
|
271
|
+
const n = await safeAsync(() => page.$$eval(selector, (els) => els.length), 0);
|
|
272
|
+
log.info(`count "${selector}": ${n}`);
|
|
273
|
+
return n;
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Pure helpers (no page I/O; unit-tested) -------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Filesystem-safe slug for artifact filenames.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} name
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
export function slug(name) {
|
|
287
|
+
return String(name)
|
|
288
|
+
.trim()
|
|
289
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
290
|
+
.replace(/^-+|-+$/g, '')
|
|
291
|
+
.slice(0, 80) || 'debug';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Truncate a string, appending an ellipsis + original length when cut.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} s
|
|
298
|
+
* @param {number} max
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
export function truncate(s, max) {
|
|
302
|
+
const str = String(s ?? '');
|
|
303
|
+
if (str.length <= max) return str;
|
|
304
|
+
return `${str.slice(0, max)}… (${str.length} chars total)`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Format a single element-summary object into a one-line string.
|
|
309
|
+
*
|
|
310
|
+
* @param {object} el - { tag, role, id, classes, text, visible, href }
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
export function formatElement(el) {
|
|
314
|
+
const parts = [String(el.tag || '?').toLowerCase()];
|
|
315
|
+
if (el.role) parts.push(`role=${el.role}`);
|
|
316
|
+
if (el.id) parts.push(`#${el.id}`);
|
|
317
|
+
if (el.href) parts.push(`href=${truncate(el.href, 40)}`);
|
|
318
|
+
if (el.visible === false) parts.push('(hidden)');
|
|
319
|
+
const tag = parts.join(' ');
|
|
320
|
+
return el.text ? `${tag} — "${truncate(el.text, 60)}"` : tag;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Format a watch() sample value compactly for logging.
|
|
325
|
+
*
|
|
326
|
+
* @param {any} v
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
export function formatValue(v) {
|
|
330
|
+
if (v === null || v === undefined) return String(v);
|
|
331
|
+
if (typeof v === 'object') return truncate(JSON.stringify(v), 120);
|
|
332
|
+
return String(v);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Decompose a string|RegExp pattern into source + flags for cross-context
|
|
337
|
+
* (browser) reconstruction.
|
|
338
|
+
*
|
|
339
|
+
* @param {string|RegExp} pattern
|
|
340
|
+
* @returns {{source:string, flags:string}}
|
|
341
|
+
*/
|
|
342
|
+
export function regexParts(pattern) {
|
|
343
|
+
if (pattern instanceof RegExp) return { source: pattern.source, flags: pattern.flags };
|
|
344
|
+
return { source: escapeRegExp(String(pattern)), flags: 'i' };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Escape a string for literal use inside a RegExp.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} s
|
|
351
|
+
* @returns {string}
|
|
352
|
+
*/
|
|
353
|
+
export function escapeRegExp(s) {
|
|
354
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A short, sortable-ish run id derived from high-res time (no Date dependency at
|
|
359
|
+
* module load; called only when a debug dir is actually needed).
|
|
360
|
+
*
|
|
361
|
+
* @returns {string}
|
|
362
|
+
*/
|
|
363
|
+
function defaultRunId() {
|
|
364
|
+
return `run-${process.pid}-${Math.floor(performance.now())}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Run a sync fn, returning a fallback if it throws. */
|
|
368
|
+
function safeCall(fn, fallback) {
|
|
369
|
+
try {
|
|
370
|
+
return fn();
|
|
371
|
+
} catch {
|
|
372
|
+
return fallback;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Await an async fn, returning a fallback if it throws. */
|
|
377
|
+
async function safeAsync(fn, fallback) {
|
|
378
|
+
try {
|
|
379
|
+
return await fn();
|
|
380
|
+
} catch {
|
|
381
|
+
return fallback;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, mkdtempSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import {
|
|
6
|
+
createDebug,
|
|
7
|
+
slug,
|
|
8
|
+
truncate,
|
|
9
|
+
formatElement,
|
|
10
|
+
formatValue,
|
|
11
|
+
regexParts,
|
|
12
|
+
escapeRegExp,
|
|
13
|
+
} from './debug.mjs';
|
|
14
|
+
|
|
15
|
+
// --- Pure helpers ----------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('slug()', () => {
|
|
18
|
+
it('replaces unsafe characters with dashes', () => {
|
|
19
|
+
expect(slug('Foo Bar/baz!!')).toBe('Foo-Bar-baz');
|
|
20
|
+
});
|
|
21
|
+
it('trims leading/trailing dashes', () => {
|
|
22
|
+
expect(slug(' !!hi!! ')).toBe('hi');
|
|
23
|
+
});
|
|
24
|
+
it('falls back to "debug" for empty/blank input', () => {
|
|
25
|
+
expect(slug(' ')).toBe('debug');
|
|
26
|
+
expect(slug('')).toBe('debug');
|
|
27
|
+
});
|
|
28
|
+
it('caps length at 80 chars', () => {
|
|
29
|
+
expect(slug('a'.repeat(200)).length).toBe(80);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('truncate()', () => {
|
|
34
|
+
it('returns the string unchanged when within max', () => {
|
|
35
|
+
expect(truncate('hello', 10)).toBe('hello');
|
|
36
|
+
});
|
|
37
|
+
it('cuts and annotates with total length when over max', () => {
|
|
38
|
+
expect(truncate('abcdefghij', 5)).toBe('abcde… (10 chars total)');
|
|
39
|
+
});
|
|
40
|
+
it('handles null/undefined as empty', () => {
|
|
41
|
+
expect(truncate(null, 5)).toBe('');
|
|
42
|
+
expect(truncate(undefined, 5)).toBe('');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('formatElement()', () => {
|
|
47
|
+
it('formats tag, role, href, and text', () => {
|
|
48
|
+
expect(formatElement({ tag: 'A', role: 'tab', href: '/x', text: 'Checks', visible: true }))
|
|
49
|
+
.toBe('a role=tab href=/x — "Checks"');
|
|
50
|
+
});
|
|
51
|
+
it('marks hidden elements', () => {
|
|
52
|
+
expect(formatElement({ tag: 'DIV', visible: false, text: '' })).toBe('div (hidden)');
|
|
53
|
+
});
|
|
54
|
+
it('omits text segment when there is no text', () => {
|
|
55
|
+
expect(formatElement({ tag: 'BUTTON', visible: true })).toBe('button');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('formatValue()', () => {
|
|
60
|
+
it('stringifies objects compactly', () => {
|
|
61
|
+
expect(formatValue({ a: 1 })).toBe('{"a":1}');
|
|
62
|
+
});
|
|
63
|
+
it('passes through scalars', () => {
|
|
64
|
+
expect(formatValue(42)).toBe('42');
|
|
65
|
+
expect(formatValue(null)).toBe('null');
|
|
66
|
+
expect(formatValue(undefined)).toBe('undefined');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('regexParts() / escapeRegExp()', () => {
|
|
71
|
+
it('decomposes a RegExp into source + flags', () => {
|
|
72
|
+
expect(regexParts(/SUCCEEDED|FAILED/i)).toEqual({ source: 'SUCCEEDED|FAILED', flags: 'i' });
|
|
73
|
+
});
|
|
74
|
+
it('escapes a string pattern and defaults to case-insensitive', () => {
|
|
75
|
+
expect(regexParts('a.b')).toEqual({ source: 'a\\.b', flags: 'i' });
|
|
76
|
+
});
|
|
77
|
+
it('escapeRegExp escapes regex metacharacters', () => {
|
|
78
|
+
expect(escapeRegExp('a.b*c')).toBe('a\\.b\\*c');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Page-driven tools (fake page) -----------------------------------------
|
|
83
|
+
|
|
84
|
+
/** A logger that records messages instead of printing. */
|
|
85
|
+
function fakeLogger() {
|
|
86
|
+
const messages = [];
|
|
87
|
+
const mk = () => ({
|
|
88
|
+
info: (m) => messages.push(`info:${m}`),
|
|
89
|
+
warn: (m) => messages.push(`warn:${m}`),
|
|
90
|
+
error: (m) => messages.push(`error:${m}`),
|
|
91
|
+
child: () => mk(),
|
|
92
|
+
});
|
|
93
|
+
const l = mk();
|
|
94
|
+
l.messages = messages;
|
|
95
|
+
return l;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A configurable fake Playwright Page recording what the tools ask of it. */
|
|
99
|
+
function fakePage(overrides = {}) {
|
|
100
|
+
const calls = { screenshot: [], evaluate: 0, waitForTimeout: [] };
|
|
101
|
+
return {
|
|
102
|
+
calls,
|
|
103
|
+
url: () => overrides.url ?? 'https://example.com/page',
|
|
104
|
+
title: async () => overrides.title ?? 'Example Title',
|
|
105
|
+
content: async () => overrides.content ?? '<html><body>hi</body></html>',
|
|
106
|
+
async screenshot(o) {
|
|
107
|
+
calls.screenshot.push(o);
|
|
108
|
+
if (overrides.screenshotThrows) throw new Error('boom');
|
|
109
|
+
// emulate playwright writing the file
|
|
110
|
+
const { writeFileSync } = await import('fs');
|
|
111
|
+
writeFileSync(o.path, 'PNG');
|
|
112
|
+
},
|
|
113
|
+
async evaluate(fn, arg) {
|
|
114
|
+
calls.evaluate += 1;
|
|
115
|
+
if (typeof overrides.evaluate === 'function') return overrides.evaluate(fn, arg);
|
|
116
|
+
return overrides.evaluateResult ?? null;
|
|
117
|
+
},
|
|
118
|
+
async $$eval(sel, fn, arg) {
|
|
119
|
+
if (typeof overrides.$$eval === 'function') return overrides.$$eval(sel, fn, arg);
|
|
120
|
+
return overrides.$$evalResult ?? [];
|
|
121
|
+
},
|
|
122
|
+
async waitForTimeout(ms) {
|
|
123
|
+
calls.waitForTimeout.push(ms);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('createDebug() — artifacts', () => {
|
|
129
|
+
let dir;
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
dir = mkdtempSync(join(tmpdir(), 'debug-spec-'));
|
|
132
|
+
});
|
|
133
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
134
|
+
|
|
135
|
+
it('does not create the dir until something is written', () => {
|
|
136
|
+
const sub = join(dir, 'lazy');
|
|
137
|
+
createDebug(fakePage(), fakeLogger(), { dir: sub });
|
|
138
|
+
expect(existsSync(sub)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('screenshot writes a .png and returns its path', async () => {
|
|
142
|
+
const debug = createDebug(fakePage(), fakeLogger(), { dir });
|
|
143
|
+
const path = await debug.screenshot('shot one');
|
|
144
|
+
expect(path).toBe(join(dir, 'shot-one.png'));
|
|
145
|
+
expect(existsSync(path)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('screenshot returns null (never throws) on failure', async () => {
|
|
149
|
+
const debug = createDebug(fakePage({ screenshotThrows: true }), fakeLogger(), { dir });
|
|
150
|
+
expect(await debug.screenshot('x')).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('html writes page content to a .html file', async () => {
|
|
154
|
+
const debug = createDebug(fakePage({ content: '<p>real</p>' }), fakeLogger(), { dir });
|
|
155
|
+
const path = await debug.html('snap');
|
|
156
|
+
expect(readFileSync(path, 'utf8')).toBe('<p>real</p>');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('dump captures url/title/text and writes a text artifact', async () => {
|
|
160
|
+
const page = fakePage({ url: 'https://x/y', title: 'T', evaluateResult: 'BODY TEXT' });
|
|
161
|
+
const debug = createDebug(page, fakeLogger(), { dir });
|
|
162
|
+
const snap = await debug.dump('d');
|
|
163
|
+
expect(snap.url).toBe('https://x/y');
|
|
164
|
+
expect(snap.title).toBe('T');
|
|
165
|
+
expect(snap.textPreview).toContain('BODY TEXT');
|
|
166
|
+
expect(readFileSync(snap.files.text, 'utf8')).toContain('BODY TEXT');
|
|
167
|
+
expect(existsSync(snap.files.screenshot)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('createDebug() — inspection tools', () => {
|
|
172
|
+
let dir;
|
|
173
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'debug-spec-')); });
|
|
174
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
175
|
+
|
|
176
|
+
it('describe reports count and per-element summaries', async () => {
|
|
177
|
+
const els = [{ tag: 'A', role: 'tab', text: 'Checks', visible: true }];
|
|
178
|
+
const page = fakePage({
|
|
179
|
+
$$eval: (_sel, _fn, arg) => (typeof arg === 'number' ? els : els.length),
|
|
180
|
+
});
|
|
181
|
+
const debug = createDebug(page, fakeLogger(), { dir });
|
|
182
|
+
const out = await debug.describe('a', { limit: 5 });
|
|
183
|
+
expect(out.count).toBe(1);
|
|
184
|
+
expect(out.elements[0].text).toBe('Checks');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('count returns the number of matches', async () => {
|
|
188
|
+
const debug = createDebug(fakePage({ $$evalResult: 7 }), fakeLogger(), { dir });
|
|
189
|
+
expect(await debug.count('div')).toBe(7);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('clickables returns the evaluated list', async () => {
|
|
193
|
+
const list = [{ tag: 'BUTTON', text: 'Go' }];
|
|
194
|
+
const debug = createDebug(fakePage({ evaluateResult: list }), fakeLogger(), { dir });
|
|
195
|
+
expect(await debug.clickables()).toEqual(list);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('watch samples the metric N times and spaces them by interval', async () => {
|
|
199
|
+
let n = 0;
|
|
200
|
+
const page = fakePage({ evaluate: () => ++n });
|
|
201
|
+
const debug = createDebug(page, fakeLogger(), { dir });
|
|
202
|
+
const series = await debug.watch(() => 0, { samples: 3, intervalMs: 250, label: 'len' });
|
|
203
|
+
expect(series.map((s) => s.value)).toEqual([1, 2, 3]);
|
|
204
|
+
expect(series.map((s) => s.t)).toEqual([0, 250, 500]);
|
|
205
|
+
// waits between samples only (N-1 times)
|
|
206
|
+
expect(page.calls.waitForTimeout).toEqual([250, 250]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('tools degrade gracefully when the page throws', async () => {
|
|
210
|
+
const page = fakePage({ evaluate: () => { throw new Error('nope'); }, $$eval: () => { throw new Error('nope'); } });
|
|
211
|
+
const debug = createDebug(page, fakeLogger(), { dir });
|
|
212
|
+
expect(await debug.count('x')).toBe(0);
|
|
213
|
+
expect(await debug.clickables()).toEqual([]);
|
|
214
|
+
expect((await debug.describe('x')).count).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('findText passes the pattern source/flags through to the page', async () => {
|
|
218
|
+
let received;
|
|
219
|
+
const page = fakePage({ evaluate: (_fn, arg) => { received = arg; return []; } });
|
|
220
|
+
const debug = createDebug(page, fakeLogger(), { dir });
|
|
221
|
+
await debug.findText(/SUCCEEDED|FAILED/i, { limit: 3 });
|
|
222
|
+
expect(received).toMatchObject({ source: 'SUCCEEDED|FAILED', flags: 'i', limit: 3 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('findText returns whatever the page evaluation yields', async () => {
|
|
226
|
+
const hits = [{ text: 'Checks', leafTag: 'DIV', clickableAncestor: { tag: 'DIV', role: 'tab' } }];
|
|
227
|
+
const debug = createDebug(fakePage({ evaluateResult: hits }), fakeLogger(), { dir });
|
|
228
|
+
expect(await debug.findText('Checks')).toEqual(hits);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The own-text matching rule that findText runs in-browser, replicated here as a
|
|
234
|
+
* pure check. This is the logic that the leaf-only version got wrong: an element
|
|
235
|
+
* may own a matching text node AND have element children (e.g. a tab label beside
|
|
236
|
+
* an icon). The in-browser behavior itself is covered by live tests.
|
|
237
|
+
*/
|
|
238
|
+
describe('findText own-text matching rule', () => {
|
|
239
|
+
// Mirror of the in-browser predicate: match on an element's OWN text nodes.
|
|
240
|
+
const ownText = (node) => (node.childNodes || [])
|
|
241
|
+
.filter((n) => n.nodeType === 3)
|
|
242
|
+
.map((n) => n.textContent)
|
|
243
|
+
.join('');
|
|
244
|
+
const matches = (node, re) => re.test(ownText(node));
|
|
245
|
+
|
|
246
|
+
it('matches an element whose own text node holds the pattern, even with element children', () => {
|
|
247
|
+
// <div role=tab>"Checks"<span(icon)/></div> — a common real-world tab shape.
|
|
248
|
+
const tab = {
|
|
249
|
+
childNodes: [
|
|
250
|
+
{ nodeType: 3, textContent: 'Checks' },
|
|
251
|
+
{ nodeType: 1, textContent: '' }, // icon element child
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
expect(matches(tab, /Checks/i)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('does NOT match an ancestor whose text comes only from descendants', () => {
|
|
258
|
+
// A wrapper with no own text nodes — only an element child that contains text.
|
|
259
|
+
const wrapper = { childNodes: [{ nodeType: 1, textContent: 'Checks' }] };
|
|
260
|
+
expect(matches(wrapper, /Checks/i)).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('matches a plain leaf', () => {
|
|
264
|
+
const leaf = { childNodes: [{ nodeType: 3, textContent: 'SUCCEEDED' }] };
|
|
265
|
+
expect(matches(leaf, /SUCCEEDED/)).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
});
|