@sous-io/sous 0.1.0 → 0.2.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.
Files changed (206) hide show
  1. package/README.md +121 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +73 -9
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/bin/xcv +0 -5
  166. package/shared-prompts/_partials/resume-task.md +0 -51
  167. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  168. package/shared-prompts/_partials/update-task-file.md +0 -52
  169. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  189. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  190. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  191. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  192. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  193. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  194. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  195. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  196. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  197. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  198. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  199. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  200. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  201. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  202. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  203. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  204. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  206. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -1,148 +0,0 @@
1
- /**
2
- * Chrome State Extraction
3
- *
4
- * Reads cookies from Chrome's SQLite DB, decrypts them using the OS keyring,
5
- * and produces Playwright-compatible storageState JSON.
6
- *
7
- * v10 format: 'v10'(3) + ciphertext. AES-128-CBC, IV = 16 spaces.
8
- * v11 format: 'v11'(3) + IV(16) + ciphertext. AES-128-CBC. Plaintext has 16-byte random prefix.
9
- * Key: PBKDF2(keyring_password, 'saltysalt', 1 iteration, 16 bytes, SHA1)
10
- */
11
-
12
- import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
13
- import { join } from 'path';
14
- import { homedir } from 'os';
15
- import { pbkdf2Sync, createDecipheriv } from 'crypto';
16
- import Database from 'better-sqlite3';
17
- import { getChromeSafeStoragePassword } from './keyring.mjs';
18
-
19
- const DEFAULT_CHROME_BASE = join(homedir(), '.config', 'google-chrome');
20
- const STATE_CACHE_DIR = join(homedir(), '.cache', 'browser-automation-state');
21
-
22
- export function getChromeProfilePath(profileName = 'Default') {
23
- const profileDir = join(DEFAULT_CHROME_BASE, profileName);
24
- if (!existsSync(profileDir)) {
25
- throw new Error(
26
- `Chrome profile "${profileName}" not found at ${profileDir}. ` +
27
- `Available: ${listProfiles().join(', ')}`
28
- );
29
- }
30
- return profileDir;
31
- }
32
-
33
- export function listProfiles() {
34
- if (!existsSync(DEFAULT_CHROME_BASE)) return [];
35
- return readdirSync(DEFAULT_CHROME_BASE, { withFileTypes: true })
36
- .filter(d => d.isDirectory() && (d.name === 'Default' || d.name.startsWith('Profile ')))
37
- .map(d => d.name);
38
- }
39
-
40
- function deriveKey(password) {
41
- return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
42
- }
43
-
44
- function decryptCookieValue(encryptedValue, key) {
45
- if (!encryptedValue || encryptedValue.length === 0) return '';
46
-
47
- const prefix = encryptedValue.slice(0, 3).toString('utf-8');
48
-
49
- if (prefix === 'v10') {
50
- const iv = Buffer.alloc(16, ' ');
51
- const ciphertext = encryptedValue.slice(3);
52
- const decipher = createDecipheriv('aes-128-cbc', key, iv);
53
- const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
54
- return dec.toString('utf-8');
55
- }
56
-
57
- if (prefix === 'v11') {
58
- const iv = encryptedValue.slice(3, 19);
59
- const ciphertext = encryptedValue.slice(19);
60
- const decipher = createDecipheriv('aes-128-cbc', key, iv);
61
- const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
62
- // v11 prepends 16 random bytes to the plaintext before encrypting
63
- return dec.slice(16).toString('utf-8');
64
- }
65
-
66
- // Unencrypted or unknown format
67
- return encryptedValue.toString('utf-8');
68
- }
69
-
70
- function chromeTimeToUnix(chromeTime) {
71
- if (!chromeTime || chromeTime === 0) return -1;
72
- const epochOffset = 11644473600000000n;
73
- const unixMicro = BigInt(chromeTime) - epochOffset;
74
- return Number(unixMicro / 1000000n);
75
- }
76
-
77
- /**
78
- * Read and decrypt cookies from a Chrome profile.
79
- * @param {string} profileName - Chrome profile name (default: 'Default')
80
- * @param {string[]|null} domains - Filter by domain substrings. Null = all cookies.
81
- */
82
- export async function extractCookies(profileName = 'Default', domains = null) {
83
- const profileDir = getChromeProfilePath(profileName);
84
- const cookieDbPath = join(profileDir, 'Cookies');
85
-
86
- if (!existsSync(cookieDbPath)) {
87
- throw new Error(`Cookies database not found at ${cookieDbPath}`);
88
- }
89
-
90
- const password = await getChromeSafeStoragePassword();
91
- const key = deriveKey(password);
92
-
93
- const db = new Database(cookieDbPath, { readonly: true, fileMustExist: true });
94
-
95
- let query = 'SELECT host_key, name, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite FROM cookies';
96
- const params = [];
97
-
98
- if (domains && domains.length > 0) {
99
- const clauses = domains.map(() => 'host_key LIKE ?');
100
- query += ` WHERE ${clauses.join(' OR ')}`;
101
- for (const d of domains) {
102
- params.push(`%${d}%`);
103
- }
104
- }
105
-
106
- const rows = db.prepare(query).all(...params);
107
- db.close();
108
-
109
- const cookies = [];
110
- for (const row of rows) {
111
- try {
112
- const value = decryptCookieValue(row.encrypted_value, key);
113
- cookies.push({
114
- name: row.name,
115
- value,
116
- domain: row.host_key,
117
- path: row.path,
118
- expires: chromeTimeToUnix(row.expires_utc),
119
- httpOnly: Boolean(row.is_httponly),
120
- secure: Boolean(row.is_secure),
121
- sameSite: ['None', 'Lax', 'Strict'][row.samesite] || 'None',
122
- });
123
- } catch (e) {
124
- // Skip cookies that fail to decrypt (shouldn't happen but don't break the whole run)
125
- }
126
- }
127
-
128
- return cookies;
129
- }
130
-
131
- /**
132
- * Build a Playwright-compatible storageState object.
133
- */
134
- export async function buildStorageState(profileName = 'Default', domains = null) {
135
- const cookies = await extractCookies(profileName, domains);
136
- return { cookies, origins: [] };
137
- }
138
-
139
- /**
140
- * Write storageState to a JSON file and return the path.
141
- */
142
- export async function saveStorageState(profileName = 'Default', domains = null) {
143
- mkdirSync(STATE_CACHE_DIR, { recursive: true });
144
- const state = await buildStorageState(profileName, domains);
145
- const outPath = join(STATE_CACHE_DIR, `storage-state-${profileName.replace(/\s+/g, '-')}.json`);
146
- writeFileSync(outPath, JSON.stringify(state, null, 2));
147
- return outPath;
148
- }
@@ -1,383 +0,0 @@
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
- }