@sdods/cli 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/LICENSE +17 -0
- package/bin/sdods.js +19 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +11 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +219 -0
- package/dist/commands/analyze.d.ts +20 -0
- package/dist/commands/analyze.js +216 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +97 -0
- package/dist/commands/browsers.d.ts +17 -0
- package/dist/commands/browsers.js +103 -0
- package/dist/commands/completion.d.ts +17 -0
- package/dist/commands/completion.js +143 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +79 -0
- package/dist/commands/coverage.d.ts +3 -0
- package/dist/commands/coverage.js +65 -0
- package/dist/commands/data.d.ts +7 -0
- package/dist/commands/data.js +188 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +241 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.js +323 -0
- package/dist/commands/env.d.ts +3 -0
- package/dist/commands/env.js +65 -0
- package/dist/commands/features.d.ts +3 -0
- package/dist/commands/features.js +57 -0
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/har.d.ts +3 -0
- package/dist/commands/har.js +124 -0
- package/dist/commands/heal.d.ts +4 -0
- package/dist/commands/heal.js +63 -0
- package/dist/commands/init.d.ts +38 -0
- package/dist/commands/init.js +363 -0
- package/dist/commands/insights.d.ts +7 -0
- package/dist/commands/insights.js +127 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.js +306 -0
- package/dist/commands/lint.d.ts +3 -0
- package/dist/commands/lint.js +74 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.js +190 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.js +133 -0
- package/dist/commands/proposals.d.ts +3 -0
- package/dist/commands/proposals.js +106 -0
- package/dist/commands/record.d.ts +3 -0
- package/dist/commands/record.js +174 -0
- package/dist/commands/report.d.ts +3 -0
- package/dist/commands/report.js +255 -0
- package/dist/commands/run.d.ts +41 -0
- package/dist/commands/run.js +369 -0
- package/dist/commands/schedule.d.ts +3 -0
- package/dist/commands/schedule.js +337 -0
- package/dist/commands/serve.d.ts +3 -0
- package/dist/commands/serve.js +38 -0
- package/dist/commands/steps.d.ts +8 -0
- package/dist/commands/steps.js +67 -0
- package/dist/commands/tokens.d.ts +3 -0
- package/dist/commands/tokens.js +110 -0
- package/dist/commands/trace.d.ts +10 -0
- package/dist/commands/trace.js +104 -0
- package/dist/commands/upgrade.d.ts +14 -0
- package/dist/commands/upgrade.js +115 -0
- package/dist/commands/users.d.ts +3 -0
- package/dist/commands/users.js +183 -0
- package/dist/commands/watch.d.ts +7 -0
- package/dist/commands/watch.js +116 -0
- package/dist/commands/workspace.d.ts +8 -0
- package/dist/commands/workspace.js +83 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.js +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/program.d.ts +7 -0
- package/dist/program.js +90 -0
- package/dist/templates/project.d.ts +10 -0
- package/dist/templates/project.js +115 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +67 -0
- package/package.json +61 -0
- package/templates/.claude/skills/sdods-record/SKILL.md +75 -0
- package/templates/.claude/skills/sdods-run/SKILL.md +54 -0
- package/templates/.env.example +62 -0
- package/templates/docker-compose.yml +40 -0
- package/templates/projects/demo-shop/.env.example +4 -0
- package/templates/projects/demo-shop/data/common/posts.yaml +11 -0
- package/templates/projects/demo-shop/data/common/products.json +8 -0
- package/templates/projects/demo-shop/data/common/users.csv +7 -0
- package/templates/projects/demo-shop/data/factories.ts +16 -0
- package/templates/projects/demo-shop/data/staging/users.csv +6 -0
- package/templates/projects/demo-shop/envs/local.yaml +17 -0
- package/templates/projects/demo-shop/envs/staging.yaml +23 -0
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/darwin/inventory.png +0 -0
- package/templates/projects/demo-shop/features/api/posts.feature +90 -0
- package/templates/projects/demo-shop/features/auth/data-driven-login.feature +17 -0
- package/templates/projects/demo-shop/features/auth/login.feature +32 -0
- package/templates/projects/demo-shop/features/auth/user-pool.feature +20 -0
- package/templates/projects/demo-shop/features/cart/cart.feature +33 -0
- package/templates/projects/demo-shop/features/hybrid/seed-then-verify.feature +28 -0
- package/templates/projects/demo-shop/features/inventory/inventory.feature +38 -0
- package/templates/projects/demo-shop/features/inventory/network-intercept.feature +29 -0
- package/templates/projects/demo-shop/features/inventory/visual.feature +10 -0
- package/templates/projects/demo-shop/har/staging/cart.har +1 -0
- package/templates/projects/demo-shop/har/staging/cart.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/login.har +1 -0
- package/templates/projects/demo-shop/har/staging/login.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/posts.api.har +2061 -0
- package/templates/projects/demo-shop/har/staging/posts.har.json +8 -0
- package/templates/projects/demo-shop/har/staging/products.har +1 -0
- package/templates/projects/demo-shop/har/staging/products.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/site.api.har +148 -0
- package/templates/projects/demo-shop/har/staging/site.har +1 -0
- package/templates/projects/demo-shop/har/staging/site.har.json +11 -0
- package/templates/projects/demo-shop/pages/CartPage.ts +20 -0
- package/templates/projects/demo-shop/pages/InventoryPage.ts +103 -0
- package/templates/projects/demo-shop/pages/LoginPage.ts +42 -0
- package/templates/projects/demo-shop/schemas/post.schema.json +13 -0
- package/templates/projects/demo-shop/sdods.project.yaml +185 -0
- package/templates/projects/demo-shop/steps/auth.ts +7 -0
- package/templates/projects/demo-shop/steps/fixtures.ts +39 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stringify as toYaml } from 'yaml';
|
|
4
|
+
import { BrowserSchema, LayerSchema, ProjectConfigSchema, SlugSchema } from '@sdods/contracts';
|
|
5
|
+
import { SdodsError, PROJECT_FILE } from '@sdods/core';
|
|
6
|
+
import { createContext } from '../context.js';
|
|
7
|
+
import { collect, json, ok, table } from '../ui.js';
|
|
8
|
+
import { projectTemplateFiles } from '../templates/project.js';
|
|
9
|
+
export function registerProjectCommands(program) {
|
|
10
|
+
const project = program.command('project').description('Create and list SDODS projects');
|
|
11
|
+
project
|
|
12
|
+
.command('list')
|
|
13
|
+
.description('List projects discovered under projects/')
|
|
14
|
+
.option('--matrix', 'emit a CI matrix (project × browser × shard)')
|
|
15
|
+
.option('--shards <n>', 'shards per browser for --matrix', '1')
|
|
16
|
+
.action((opts, cmd) => {
|
|
17
|
+
const ctx = createContext(cmd);
|
|
18
|
+
const entries = ctx.registry.entriesList();
|
|
19
|
+
if (opts.matrix) {
|
|
20
|
+
const shards = Math.max(1, Number(opts.shards));
|
|
21
|
+
// Projects with `ci.enabled: false` (e.g. ones that need their own server, like the
|
|
22
|
+
// dogfooding project) are left to dedicated jobs.
|
|
23
|
+
const matrix = entries
|
|
24
|
+
.filter((e) => e.config.ci.enabled)
|
|
25
|
+
.flatMap((e) => (e.config.ci.browsers ?? e.config.browsers).flatMap((browser) => Array.from({ length: shards }, (_, i) => ({
|
|
26
|
+
project: e.slug,
|
|
27
|
+
env: e.config.ci.env ?? e.config.envs.default,
|
|
28
|
+
tags: e.config.ci.tags ?? '',
|
|
29
|
+
browser,
|
|
30
|
+
shard: i + 1,
|
|
31
|
+
total: shards,
|
|
32
|
+
}))));
|
|
33
|
+
return json(matrix);
|
|
34
|
+
}
|
|
35
|
+
const rows = entries.map((e) => ({
|
|
36
|
+
slug: e.slug,
|
|
37
|
+
name: e.config.name,
|
|
38
|
+
layers: e.config.layers.join(','),
|
|
39
|
+
browsers: e.config.browsers.join(','),
|
|
40
|
+
envs: e.config.envs.available
|
|
41
|
+
.map((n) => (e.envsOnDisk.includes(n) ? n : `${n}(missing)`))
|
|
42
|
+
.join(','),
|
|
43
|
+
default: e.config.envs.default,
|
|
44
|
+
root: e.root,
|
|
45
|
+
}));
|
|
46
|
+
if (ctx.opts.json)
|
|
47
|
+
return json(rows);
|
|
48
|
+
table(rows, ['slug', 'name', 'layers', 'browsers', 'envs', 'default']);
|
|
49
|
+
});
|
|
50
|
+
project
|
|
51
|
+
.command('create <slug>')
|
|
52
|
+
.description('Scaffold a project: yaml, envs, fixtures, an auth stub, a page object, health features and data')
|
|
53
|
+
.option('--name <name>', 'display name')
|
|
54
|
+
.option('--layers <list>', 'comma list of ui,api,hybrid,recorded', collect, [])
|
|
55
|
+
.option('--browsers <list>', 'comma list of browsers', collect, [])
|
|
56
|
+
.option('--ui-url <url>', 'UI base URL for the first environment', 'http://localhost:3000')
|
|
57
|
+
.option('--api-url <url>', 'API base URL for the first environment', 'http://localhost:3000/api')
|
|
58
|
+
.option('--env <name>', 'first environment name', 'local')
|
|
59
|
+
.option('--test-id <attr>', 'test id attribute', 'data-testid')
|
|
60
|
+
.option('--force', 'overwrite an existing directory')
|
|
61
|
+
.action((slug, opts, cmd) => {
|
|
62
|
+
const ctx = createContext(cmd);
|
|
63
|
+
SlugSchema.parse(slug);
|
|
64
|
+
const root = join(ctx.registry.projectsDir, slug);
|
|
65
|
+
if (existsSync(join(root, PROJECT_FILE)) && !opts.force) {
|
|
66
|
+
throw new SdodsError('CONFIG_INVALID', `Project "${slug}" already exists at ${root}.`, {
|
|
67
|
+
hint: 'Pass --force to overwrite.',
|
|
68
|
+
exitCode: 2,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const layers = (opts.layers.length ? opts.layers : ['ui', 'api', 'hybrid']).map((l) => LayerSchema.parse(l));
|
|
72
|
+
const browsers = (opts.browsers.length ? opts.browsers : ['chromium']).map((b) => BrowserSchema.parse(b));
|
|
73
|
+
const config = ProjectConfigSchema.parse({
|
|
74
|
+
slug,
|
|
75
|
+
name: opts.name ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
76
|
+
layers,
|
|
77
|
+
browsers,
|
|
78
|
+
testIdAttribute: opts.testId,
|
|
79
|
+
routes: { home: '/' },
|
|
80
|
+
envs: { default: opts.env, available: [opts.env] },
|
|
81
|
+
data: {
|
|
82
|
+
sources: {
|
|
83
|
+
users: { type: 'csv', path: 'data/{env}/users.csv', fallback: 'data/common/users.csv' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const files = projectTemplateFiles({
|
|
88
|
+
config,
|
|
89
|
+
envName: opts.env,
|
|
90
|
+
uiUrl: opts.uiUrl,
|
|
91
|
+
apiUrl: opts.apiUrl,
|
|
92
|
+
});
|
|
93
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
94
|
+
const file = join(root, rel);
|
|
95
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
96
|
+
writeFileSync(file, content);
|
|
97
|
+
}
|
|
98
|
+
const yamlText = toYaml(minimalYaml(config), { lineWidth: 100 });
|
|
99
|
+
writeFileSync(join(root, PROJECT_FILE), `# SDODS project. Reference: ${'https://docs.sdods.com/docs/reference/project-yaml'}\n${yamlText}`);
|
|
100
|
+
if (ctx.opts.json)
|
|
101
|
+
return json({ slug, root, files: [PROJECT_FILE, ...Object.keys(files)] });
|
|
102
|
+
ok(`Created project ${slug} at ${root}`);
|
|
103
|
+
for (const f of [PROJECT_FILE, ...Object.keys(files)])
|
|
104
|
+
console.log(` ${f}`);
|
|
105
|
+
console.log(`\nNext: sdods run -p ${slug} -e ${opts.env} -l api`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Keep the generated yaml readable: drop defaults the schema fills in. */
|
|
109
|
+
function minimalYaml(config) {
|
|
110
|
+
return {
|
|
111
|
+
slug: config.slug,
|
|
112
|
+
name: config.name,
|
|
113
|
+
layers: config.layers,
|
|
114
|
+
browsers: config.browsers,
|
|
115
|
+
testIdAttribute: config.testIdAttribute,
|
|
116
|
+
routes: config.routes,
|
|
117
|
+
tags: { suites: config.tags.suites, extra: config.tags.extra, roles: config.tags.roles },
|
|
118
|
+
envs: config.envs,
|
|
119
|
+
data: {
|
|
120
|
+
sources: config.data.sources,
|
|
121
|
+
userPool: { dataset: 'users', roleColumn: 'role', leaseStore: 'file' },
|
|
122
|
+
},
|
|
123
|
+
auth: { strategy: 'none', storageState: true },
|
|
124
|
+
screenshots: {
|
|
125
|
+
policy: config.screenshots.policy,
|
|
126
|
+
mask: [],
|
|
127
|
+
viewport: config.screenshots.viewport,
|
|
128
|
+
},
|
|
129
|
+
heal: { enabled: true },
|
|
130
|
+
retries: { ci: 2, local: 0 },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=project.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { SdodsError } from '@sdods/core';
|
|
4
|
+
import { createContext } from '../context.js';
|
|
5
|
+
import { json, ok, out, table } from '../ui.js';
|
|
6
|
+
export function register(program) {
|
|
7
|
+
const proposals = program
|
|
8
|
+
.command('proposals')
|
|
9
|
+
.description('Review and apply proposals written by agents and MCP write tools');
|
|
10
|
+
proposals
|
|
11
|
+
.command('list')
|
|
12
|
+
.description('List proposals (newest first)')
|
|
13
|
+
.option('--status <s>', 'pending | accepted | rejected')
|
|
14
|
+
.option('-p, --project <slug>')
|
|
15
|
+
.action(async (opts, cmd) => {
|
|
16
|
+
const ctx = createContext(cmd);
|
|
17
|
+
const { ProposalStore } = await import('@sdods/mcp');
|
|
18
|
+
const rows = new ProposalStore(ctx.rootDir)
|
|
19
|
+
.list({ status: opts.status, project: opts.project })
|
|
20
|
+
.map((m) => ({
|
|
21
|
+
id: m.id,
|
|
22
|
+
role: m.role,
|
|
23
|
+
project: m.project ?? '',
|
|
24
|
+
status: m.status,
|
|
25
|
+
files: m.files.length,
|
|
26
|
+
summary: m.summary.slice(0, 70),
|
|
27
|
+
created: m.createdAt,
|
|
28
|
+
}));
|
|
29
|
+
if (ctx.opts.json)
|
|
30
|
+
return json(rows);
|
|
31
|
+
table(rows);
|
|
32
|
+
});
|
|
33
|
+
proposals
|
|
34
|
+
.command('show <id>')
|
|
35
|
+
.description('Show a proposal: manifest and diff against the working tree')
|
|
36
|
+
.action(async (id, _opts, cmd) => {
|
|
37
|
+
const ctx = createContext(cmd);
|
|
38
|
+
const { ProposalStore } = await import('@sdods/mcp');
|
|
39
|
+
const store = new ProposalStore(ctx.rootDir);
|
|
40
|
+
const m = store.get(id);
|
|
41
|
+
if (!m)
|
|
42
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Unknown proposal ${id}`, {
|
|
43
|
+
hint: 'Run `sdods proposals list`.',
|
|
44
|
+
exitCode: 2,
|
|
45
|
+
});
|
|
46
|
+
const diff = store.diff(id);
|
|
47
|
+
if (ctx.opts.json)
|
|
48
|
+
return json({ ...m, diff });
|
|
49
|
+
out(`${pc.bold(m.id)} ${pc.dim(`(${m.role}, ${m.status})`)}\n${m.summary}\n`);
|
|
50
|
+
for (const f of m.files)
|
|
51
|
+
out(` ${f.op === 'add' ? pc.green('+') : f.op === 'delete' ? pc.red('-') : pc.yellow('~')} ${f.path}`);
|
|
52
|
+
out('');
|
|
53
|
+
for (const line of diff.split('\n'))
|
|
54
|
+
out(line.startsWith('+')
|
|
55
|
+
? pc.green(line)
|
|
56
|
+
: line.startsWith('-')
|
|
57
|
+
? pc.red(line)
|
|
58
|
+
: pc.dim(line));
|
|
59
|
+
});
|
|
60
|
+
proposals
|
|
61
|
+
.command('accept <id>')
|
|
62
|
+
.description('Apply a proposal to the working tree (optionally on a new git branch)')
|
|
63
|
+
.option('--branch <name>', 'create/switch to this branch first')
|
|
64
|
+
.option('--reviewed-by <name>')
|
|
65
|
+
.option('--no-lint', 'skip sdods lint after applying')
|
|
66
|
+
.action(async (id, opts, cmd) => {
|
|
67
|
+
const ctx = createContext(cmd);
|
|
68
|
+
const { ProposalStore } = await import('@sdods/mcp');
|
|
69
|
+
const store = new ProposalStore(ctx.rootDir);
|
|
70
|
+
const m = store.get(id);
|
|
71
|
+
if (!m)
|
|
72
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Unknown proposal ${id}`, { exitCode: 2 });
|
|
73
|
+
if (opts.branch) {
|
|
74
|
+
try {
|
|
75
|
+
execFileSync('git', ['checkout', '-B', opts.branch], {
|
|
76
|
+
cwd: ctx.rootDir,
|
|
77
|
+
stdio: 'ignore',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
throw new SdodsError('RUN_FAILED', `Could not switch to branch ${opts.branch}: ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const written = store.accept(id, { reviewedBy: opts.reviewedBy ?? process.env.USER });
|
|
85
|
+
if (ctx.opts.json)
|
|
86
|
+
return json({ id, written, branch: opts.branch });
|
|
87
|
+
for (const w of written)
|
|
88
|
+
ok(w);
|
|
89
|
+
if (opts.lint !== false && m.project) {
|
|
90
|
+
out(pc.dim(`Next: sdods lint -p ${m.project}`));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
proposals
|
|
94
|
+
.command('reject <id>')
|
|
95
|
+
.description('Reject a proposal')
|
|
96
|
+
.option('--reason <text>')
|
|
97
|
+
.action(async (id, opts, cmd) => {
|
|
98
|
+
const ctx = createContext(cmd);
|
|
99
|
+
const { ProposalStore } = await import('@sdods/mcp');
|
|
100
|
+
const m = new ProposalStore(ctx.rootDir).reject(id, opts.reason, process.env.USER);
|
|
101
|
+
if (ctx.opts.json)
|
|
102
|
+
return json(m);
|
|
103
|
+
ok(`Rejected ${m.id}`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=proposals.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { SdodsError, VERSION } from '@sdods/core';
|
|
6
|
+
import { createContext } from '../context.js';
|
|
7
|
+
import { json, ok, out, warn } from '../ui.js';
|
|
8
|
+
function addRecordOptions(cmd) {
|
|
9
|
+
return cmd
|
|
10
|
+
.option('-p, --project <slug>', 'project slug')
|
|
11
|
+
.option('-e, --env <name>', 'environment name')
|
|
12
|
+
.option('--name <name>', 'recording name → recorded/<name>.spec.ts (default: rec-<timestamp>)')
|
|
13
|
+
.option('--url <route|path|url>', 'route name from the project yaml, a path, or an absolute URL', '/')
|
|
14
|
+
.option('--device <name>', 'device name, e.g. "iPhone 15"')
|
|
15
|
+
.option('--user <role>', 'use a pool user of this role and start logged in (storageState)')
|
|
16
|
+
.option('-b, --browser <name>', 'chromium | firefox | webkit', 'chromium')
|
|
17
|
+
.option('--save-har', 'also capture network into har/<env>/<name>.har')
|
|
18
|
+
.option('--har-glob <glob>', 'URL glob for --save-har', '**/*')
|
|
19
|
+
.option('--viewport <WxH>', 'viewport, e.g. 1280x720 (ignored with --device)')
|
|
20
|
+
.option('--tag <tag>', 'extra tag for the recorded spec (repeatable)', (v, p = []) => p.concat(v), [])
|
|
21
|
+
.option('--no-post-process', 'keep the raw codegen output');
|
|
22
|
+
}
|
|
23
|
+
async function recordAction(flags, cmd) {
|
|
24
|
+
const ctx = createContext(cmd);
|
|
25
|
+
const entry = ctx.registry.pick(flags.project);
|
|
26
|
+
const config = ctx.registry.resolve(entry.slug, flags.env);
|
|
27
|
+
const { runCodegen, postProcessRecording } = await import('@sdods/core/recorder');
|
|
28
|
+
const { writeSidecar } = await import('@sdods/core/har');
|
|
29
|
+
const { captureAuth, poolUsers } = await import('@sdods/core/auth/capture');
|
|
30
|
+
const { AuthStateCache } = await import('@sdods/core');
|
|
31
|
+
const name = flags.name ?? `rec-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
|
|
32
|
+
const browser = (flags.browser ?? 'chromium');
|
|
33
|
+
// logged-in start: make sure the first user of the role has a fresh storage state
|
|
34
|
+
let storageStatePath;
|
|
35
|
+
let userName;
|
|
36
|
+
if (flags.user) {
|
|
37
|
+
const users = await poolUsers(config, flags.user);
|
|
38
|
+
const user = users[0];
|
|
39
|
+
userName = user.username;
|
|
40
|
+
const cache = new AuthStateCache(config);
|
|
41
|
+
if (!cache.isFresh(user)) {
|
|
42
|
+
out(pc.dim(`no fresh login state for ${user.username}; capturing…`));
|
|
43
|
+
const res = await captureAuth({ config, role: flags.user, index: 0, browserName: browser });
|
|
44
|
+
if (!res[0]?.file)
|
|
45
|
+
warn(`could not capture login state (${res[0]?.skipped ?? 'unknown'}); recording without it`);
|
|
46
|
+
}
|
|
47
|
+
if (cache.isFresh(user))
|
|
48
|
+
storageStatePath = cache.fileFor(user);
|
|
49
|
+
}
|
|
50
|
+
out(pc.dim(`recording ${name} · project ${entry.slug} · env ${config.env.name}${flags.user ? ` · user ${userName} (${flags.user})` : ''}${flags.device ? ` · device ${flags.device}` : ''}`));
|
|
51
|
+
const result = await runCodegen({
|
|
52
|
+
config,
|
|
53
|
+
name,
|
|
54
|
+
url: flags.url,
|
|
55
|
+
device: flags.device,
|
|
56
|
+
browser,
|
|
57
|
+
storageStatePath,
|
|
58
|
+
saveHar: flags.saveHar,
|
|
59
|
+
harUrlGlob: flags.harGlob,
|
|
60
|
+
viewport: flags.viewport,
|
|
61
|
+
});
|
|
62
|
+
if (!result.produced) {
|
|
63
|
+
throw new SdodsError('RUN_FAILED', 'Codegen closed without writing a spec (nothing was recorded).', {
|
|
64
|
+
exitCode: 1,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
let summary = {};
|
|
68
|
+
if (flags.postProcess !== false) {
|
|
69
|
+
const source = readFileSync(result.outputFile, 'utf8');
|
|
70
|
+
const processed = postProcessRecording(source, {
|
|
71
|
+
meta: {
|
|
72
|
+
project: entry.slug,
|
|
73
|
+
env: config.env.name,
|
|
74
|
+
name,
|
|
75
|
+
recordedAt: new Date().toISOString(),
|
|
76
|
+
url: flags.url,
|
|
77
|
+
user: userName,
|
|
78
|
+
role: flags.user,
|
|
79
|
+
device: flags.device,
|
|
80
|
+
browser,
|
|
81
|
+
har: result.harFile ? name : undefined,
|
|
82
|
+
sdodsVersion: VERSION,
|
|
83
|
+
playwright: await playwrightVersion(ctx.rootDir),
|
|
84
|
+
},
|
|
85
|
+
baseUrls: [config.env.ui.baseUrl, ...config.env.aliases],
|
|
86
|
+
tags: ['@recorded', '@ui', '@regression', ...flags.tag],
|
|
87
|
+
});
|
|
88
|
+
writeFileSync(result.outputFile, processed.code);
|
|
89
|
+
for (const w of processed.warnings)
|
|
90
|
+
warn(w);
|
|
91
|
+
summary = {
|
|
92
|
+
fragileLocators: processed.fragileLocators,
|
|
93
|
+
rewrittenUrls: processed.rewrittenUrls,
|
|
94
|
+
wrapped: processed.wrapped,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (result.harFile) {
|
|
98
|
+
writeSidecar(config, {
|
|
99
|
+
name,
|
|
100
|
+
project: entry.slug,
|
|
101
|
+
env: config.env.name,
|
|
102
|
+
urlGlob: flags.harGlob ?? '**/*',
|
|
103
|
+
recordedAt: new Date().toISOString(),
|
|
104
|
+
source: 'codegen',
|
|
105
|
+
scenarios: [name],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const spec = relative(ctx.rootDir, result.outputFile);
|
|
109
|
+
if (ctx.opts.json)
|
|
110
|
+
return json({ name, spec, har: result.harFile, user: userName, role: flags.user, ...summary });
|
|
111
|
+
ok(`Recorded ${spec}${result.harFile ? ` (+ HAR ${relative(ctx.rootDir, result.harFile)})` : ''}`);
|
|
112
|
+
if (summary.fragileLocators)
|
|
113
|
+
warn(`${summary.fragileLocators} CSS/XPath locator(s) marked // sdods:fragile — prefer role/label/test-id locators.`);
|
|
114
|
+
out(pc.dim(`play back: sdods run -p ${entry.slug} -e ${config.env.name} -l recorded --grep "${name}"`));
|
|
115
|
+
out(pc.dim(`convert: sdods record convert ${spec}`));
|
|
116
|
+
}
|
|
117
|
+
export function register(program) {
|
|
118
|
+
const record = addRecordOptions(program
|
|
119
|
+
.command('record')
|
|
120
|
+
.description('Record a browser session into a runnable spec under recorded/ (alias: codegen)'));
|
|
121
|
+
record.action(recordAction);
|
|
122
|
+
record
|
|
123
|
+
.command('convert <spec>')
|
|
124
|
+
.description('Ask the generator agent to turn a recorded spec into a feature + steps proposal (reviewed, never auto-applied)')
|
|
125
|
+
.option('-p, --project <slug>', 'project slug (default: inferred from the spec path)')
|
|
126
|
+
.option('--dry-run', 'print the prompt and plan without calling a model')
|
|
127
|
+
.option('--adapter <name>', 'claude | claude-code | codex | openai | ollama | fake; default: auto-detect')
|
|
128
|
+
.option('--model <id>', 'model override')
|
|
129
|
+
.option('--profile <name>', 'auto | full | small — how much of the platform the model is shown')
|
|
130
|
+
.action(async (spec, opts, cmd) => {
|
|
131
|
+
const ctx = createContext(cmd);
|
|
132
|
+
if (!existsSync(spec))
|
|
133
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Spec not found: ${spec}`, { exitCode: 2 });
|
|
134
|
+
// The generator agent works on a project; a spec under projects/<slug>/recorded names it.
|
|
135
|
+
const project = opts.project ?? /(?:^|\/)projects\/([^/]+)\//.exec(spec)?.[1];
|
|
136
|
+
if (!project)
|
|
137
|
+
throw new SdodsError('CONFIG_INVALID', `Cannot tell which project ${spec} belongs to.`, {
|
|
138
|
+
hint: 'Pass -p <slug>, or keep recordings under projects/<slug>/recorded/.',
|
|
139
|
+
exitCode: 2,
|
|
140
|
+
});
|
|
141
|
+
const args = [process.argv[1], 'agent', 'generate', '-p', project, '--spec', spec];
|
|
142
|
+
if (opts.dryRun)
|
|
143
|
+
args.push('--dry-run');
|
|
144
|
+
if (opts.adapter)
|
|
145
|
+
args.push('--adapter', opts.adapter);
|
|
146
|
+
if (opts.model)
|
|
147
|
+
args.push('--model', opts.model);
|
|
148
|
+
if (opts.profile)
|
|
149
|
+
args.push('--profile', opts.profile);
|
|
150
|
+
if (ctx.opts.json)
|
|
151
|
+
args.push('--json');
|
|
152
|
+
const res = await execa(process.execPath, ['--import', 'tsx', ...args], {
|
|
153
|
+
cwd: ctx.rootDir,
|
|
154
|
+
stdio: 'inherit',
|
|
155
|
+
reject: false,
|
|
156
|
+
});
|
|
157
|
+
if (res.exitCode === 2) {
|
|
158
|
+
warn('The agent layer is not available in this build yet; the recording stays runnable under recorded/.');
|
|
159
|
+
}
|
|
160
|
+
process.exitCode = res.exitCode ?? 1;
|
|
161
|
+
});
|
|
162
|
+
const codegen = addRecordOptions(program.command('codegen').description('Alias of record'));
|
|
163
|
+
codegen.action(recordAction);
|
|
164
|
+
}
|
|
165
|
+
async function playwrightVersion(cwd) {
|
|
166
|
+
try {
|
|
167
|
+
const { stdout } = await execa('npx', ['playwright', '--version'], { cwd });
|
|
168
|
+
return stdout.trim().replace(/^Version\s+/i, '');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=record.js.map
|