@sous-io/sous 0.1.1 → 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.
- package/README.md +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +408 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +619 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +413 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /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
|
-
}
|