@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,363 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { OrganizationSchema, SlugSchema, WorkspaceSchema } from '@sdods/contracts';
|
|
7
|
+
import { SdodsError, VERSION, WORKSPACE_FILE } from '@sdods/core';
|
|
8
|
+
import { globalOptions } from '../context.js';
|
|
9
|
+
import { json, ok, out, warn } from '../ui.js';
|
|
10
|
+
/** Root of the SDODS monorepo this CLI runs from (used by --link, which needs a real checkout). */
|
|
11
|
+
export function sourceRepoRoot() {
|
|
12
|
+
// packages/cli/{src,dist}/commands/init.{ts,js} → repo root (same depth either way)
|
|
13
|
+
return resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Where `init` reads the assets it copies into a new workspace. A real checkout wins, so working
|
|
17
|
+
* on SDODS scaffolds from the live `projects/demo-shop` rather than a staged copy. A published
|
|
18
|
+
* install has no checkout above it and falls back to `templates/`, put into the tarball at pack
|
|
19
|
+
* time by scripts/build-publish-assets.ts. The staged layout mirrors the repo root, so the callers
|
|
20
|
+
* below resolve the same relative paths either way.
|
|
21
|
+
*/
|
|
22
|
+
export function templateRoot() {
|
|
23
|
+
const repo = sourceRepoRoot();
|
|
24
|
+
if (existsSync(join(repo, 'projects', 'demo-shop')))
|
|
25
|
+
return repo;
|
|
26
|
+
// packages/cli/{src,dist}/commands → packages/cli
|
|
27
|
+
const packaged = resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
|
|
28
|
+
return existsSync(packaged) ? packaged : repo;
|
|
29
|
+
}
|
|
30
|
+
export function register(program) {
|
|
31
|
+
program
|
|
32
|
+
.command('init [dir]')
|
|
33
|
+
.description('Scaffold a new SDODS workspace: workspace yaml, runner config, demo project, skills')
|
|
34
|
+
.option('--db <driver>', 'sqlite | postgres (written to .env.example)', 'sqlite')
|
|
35
|
+
.option('--pm <manager>', 'bun | pnpm', 'bun')
|
|
36
|
+
.option('--no-demo', 'skip projects/demo-shop')
|
|
37
|
+
.option('--claude', 'also write .claude/agents and .mcp.json via `sdods agent install-claude`')
|
|
38
|
+
.option('--from <app>', 'analyze an application repo and create a project from it')
|
|
39
|
+
.option('--git', 'git init and make an initial commit')
|
|
40
|
+
.option('--link', 'depend on the local SDODS packages (development) instead of the npm registry')
|
|
41
|
+
.option('--no-install', 'skip the package manager install step')
|
|
42
|
+
.option('--no-browsers', 'skip downloading the chromium engine')
|
|
43
|
+
.option('--org <slug>', 'organization slug', 'default')
|
|
44
|
+
.option('--org-name <name>', 'organization display name')
|
|
45
|
+
.option('--workspace <slug>', 'workspace slug', 'default')
|
|
46
|
+
.option('--workspace-name <name>', 'workspace display name')
|
|
47
|
+
.option('--force', 'write into a non-empty directory')
|
|
48
|
+
.action(async (dir, flags, cmd) => {
|
|
49
|
+
const opts = globalOptions(cmd);
|
|
50
|
+
const target = resolvePath(opts.cwd ?? process.cwd(), dir ?? '.');
|
|
51
|
+
const result = await initWorkspace(target, flags);
|
|
52
|
+
if (opts.json)
|
|
53
|
+
return json(result);
|
|
54
|
+
const run = flags.pm === 'bun' ? 'bun run' : 'pnpm';
|
|
55
|
+
ok(`SDODS workspace ready at ${target}`);
|
|
56
|
+
for (const f of result.files)
|
|
57
|
+
out(` ${f}`);
|
|
58
|
+
out('');
|
|
59
|
+
out(pc.bold('Next steps'));
|
|
60
|
+
if (dir)
|
|
61
|
+
out(` cd ${dir}`);
|
|
62
|
+
if (!flags.install)
|
|
63
|
+
out(` ${flags.pm} install && ${flags.pm} run sdods browsers install --with-deps`);
|
|
64
|
+
out(` ${run} sdods doctor`);
|
|
65
|
+
out(` ${run} sdods workspace tree`);
|
|
66
|
+
if (result.demo)
|
|
67
|
+
out(` ${run} sdods run -p demo-shop -e staging -l api`);
|
|
68
|
+
else
|
|
69
|
+
out(` ${run} sdods project create my-app --ui-url http://localhost:3000 --api-url http://localhost:3000/api`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function initWorkspace(target, flags) {
|
|
73
|
+
if (!['sqlite', 'postgres'].includes(flags.db)) {
|
|
74
|
+
throw new SdodsError('CONFIG_INVALID', `--db must be sqlite or postgres, got "${flags.db}".`, {
|
|
75
|
+
exitCode: 2,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!['bun', 'pnpm'].includes(flags.pm)) {
|
|
79
|
+
throw new SdodsError('CONFIG_INVALID', `--pm must be bun or pnpm, got "${flags.pm}".`, {
|
|
80
|
+
exitCode: 2,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const org = OrganizationSchema.parse({
|
|
84
|
+
slug: SlugSchema.parse(flags.org),
|
|
85
|
+
name: flags.orgName ?? titleCase(flags.org),
|
|
86
|
+
});
|
|
87
|
+
const workspace = WorkspaceSchema.parse({
|
|
88
|
+
slug: SlugSchema.parse(flags.workspace),
|
|
89
|
+
name: flags.workspaceName ?? titleCase(flags.workspace),
|
|
90
|
+
organization: org.slug,
|
|
91
|
+
});
|
|
92
|
+
mkdirSync(target, { recursive: true });
|
|
93
|
+
const existing = readdirSync(target).filter((f) => f !== '.git' && f !== '.DS_Store');
|
|
94
|
+
if (existing.length && !flags.force) {
|
|
95
|
+
throw new SdodsError('CONFIG_INVALID', `Directory ${target} is not empty.`, {
|
|
96
|
+
hint: 'Pass --force to write into it anyway.',
|
|
97
|
+
exitCode: 2,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const src = templateRoot();
|
|
101
|
+
const files = [];
|
|
102
|
+
const write = (rel, content) => {
|
|
103
|
+
const file = join(target, rel);
|
|
104
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
105
|
+
writeFileSync(file, content);
|
|
106
|
+
files.push(rel);
|
|
107
|
+
};
|
|
108
|
+
const name = basename(target)
|
|
109
|
+
.replace(/[^a-zA-Z0-9-_]/g, '-')
|
|
110
|
+
.toLowerCase() || 'sdods-tests';
|
|
111
|
+
// --link: bun's `link:` protocol expects a package registered with `bun link` (done below);
|
|
112
|
+
// pnpm accepts a path. Transitive workspace deps resolve from the symlink's real location.
|
|
113
|
+
const dep = (pkg) => flags.link
|
|
114
|
+
? flags.pm === 'bun'
|
|
115
|
+
? `link:${pkg}`
|
|
116
|
+
: `link:${join(sourceRepoRoot(), 'packages', pkg.replace('@sdods/', ''))}`
|
|
117
|
+
: `^${VERSION}`;
|
|
118
|
+
const run = flags.pm === 'bun' ? 'bun run' : 'pnpm';
|
|
119
|
+
write('package.json', JSON.stringify({
|
|
120
|
+
name,
|
|
121
|
+
private: true,
|
|
122
|
+
type: 'module',
|
|
123
|
+
version: '0.1.0',
|
|
124
|
+
description: `${name} — SDODS automation workspace`,
|
|
125
|
+
engines: { node: '>=22' },
|
|
126
|
+
scripts: {
|
|
127
|
+
sdods: 'sdods',
|
|
128
|
+
test: 'sdods run',
|
|
129
|
+
'test:api': 'sdods run -l api',
|
|
130
|
+
'test:ui': 'sdods run -l ui -b chromium -t @smoke',
|
|
131
|
+
lint: 'sdods lint',
|
|
132
|
+
serve: 'sdods serve',
|
|
133
|
+
doctor: 'sdods doctor',
|
|
134
|
+
},
|
|
135
|
+
dependencies: {
|
|
136
|
+
'@sdods/cli': dep('@sdods/cli'),
|
|
137
|
+
'@sdods/core': dep('@sdods/core'),
|
|
138
|
+
'@sdods/contracts': dep('@sdods/contracts'),
|
|
139
|
+
'@playwright/test': flags.link && flags.pm === 'bun' ? 'link:@playwright/test' : '^1.62.1',
|
|
140
|
+
'playwright-bdd': flags.link && flags.pm === 'bun' ? 'link:playwright-bdd' : '^9.2.0',
|
|
141
|
+
},
|
|
142
|
+
devDependencies: {
|
|
143
|
+
'@types/node': '^22.15.0',
|
|
144
|
+
typescript: '~5.9.3',
|
|
145
|
+
tsx: '^4.23.0',
|
|
146
|
+
},
|
|
147
|
+
trustedDependencies: [
|
|
148
|
+
'@playwright/test',
|
|
149
|
+
'playwright',
|
|
150
|
+
'esbuild',
|
|
151
|
+
'better-sqlite3',
|
|
152
|
+
'argon2',
|
|
153
|
+
],
|
|
154
|
+
}, null, 2) + '\n');
|
|
155
|
+
write(WORKSPACE_FILE, `# Hierarchy: organization → workspace → project (projects/<slug>) → module (features/<module>).
|
|
156
|
+
organization:
|
|
157
|
+
slug: ${org.slug}
|
|
158
|
+
name: ${org.name}
|
|
159
|
+
workspaces:
|
|
160
|
+
- slug: ${workspace.slug}
|
|
161
|
+
name: ${workspace.name}
|
|
162
|
+
organization: ${org.slug}
|
|
163
|
+
defaultWorkspace: ${workspace.slug}
|
|
164
|
+
defaults:
|
|
165
|
+
browsers: [chromium, firefox, webkit]
|
|
166
|
+
suites: [smoke, regression, sanity]
|
|
167
|
+
testIdAttribute: data-testid
|
|
168
|
+
processes:
|
|
169
|
+
- { name: pr-check, title: Pull request check, trigger: pr, tags: '@smoke', browsers: [chromium], harMode: replay, gates: { minPassRate: 100 } }
|
|
170
|
+
- { name: nightly-regression, title: Nightly regression, trigger: nightly, tags: '@regression', browsers: [chromium, firefox, webkit], schedule: '0 2 * * *' }
|
|
171
|
+
- { name: release-gate, title: Release gate, trigger: release, tags: '@smoke or @regression', failOnFlaky: true, gates: { minPassRate: 100, maxFlaky: 0 } }
|
|
172
|
+
`);
|
|
173
|
+
write('sdods.runner.config.ts', `/**
|
|
174
|
+
* SDODS runner config. Generated from the project registry:
|
|
175
|
+
* one run target per (project × layer × browser), driven by SDODS_* env vars set by \`sdods run\`.
|
|
176
|
+
*/
|
|
177
|
+
import { defineConfig } from '@playwright/test';
|
|
178
|
+
import { ProjectRegistry, buildRunnerConfig, selectionFromEnv } from '@sdods/core/config';
|
|
179
|
+
|
|
180
|
+
const rootDir = process.env.SDODS_ROOT ?? ProjectRegistry.findRepoRoot(import.meta.dirname);
|
|
181
|
+
const registry = ProjectRegistry.discover(rootDir);
|
|
182
|
+
|
|
183
|
+
export default defineConfig(buildRunnerConfig(registry, selectionFromEnv()));
|
|
184
|
+
`);
|
|
185
|
+
write('tsconfig.json', JSON.stringify({
|
|
186
|
+
compilerOptions: {
|
|
187
|
+
target: 'ES2022',
|
|
188
|
+
module: 'NodeNext',
|
|
189
|
+
moduleResolution: 'NodeNext',
|
|
190
|
+
lib: ['ES2023', 'DOM', 'DOM.Iterable'],
|
|
191
|
+
strict: true,
|
|
192
|
+
skipLibCheck: true,
|
|
193
|
+
esModuleInterop: true,
|
|
194
|
+
resolveJsonModule: true,
|
|
195
|
+
noEmit: true,
|
|
196
|
+
types: ['node'],
|
|
197
|
+
},
|
|
198
|
+
include: ['sdods.runner.config.ts', 'projects/**/*.ts'],
|
|
199
|
+
exclude: ['node_modules', '.features-gen', '.sdods'],
|
|
200
|
+
}, null, 2) + '\n');
|
|
201
|
+
const envExample = existsSync(join(src, '.env.example'))
|
|
202
|
+
? readFileSync(join(src, '.env.example'), 'utf8')
|
|
203
|
+
: '';
|
|
204
|
+
write('.env.example', (envExample || '# SDODS platform\nDB_DRIVER=sqlite\nSQLITE_PATH=.sdods/sdods.db\n')
|
|
205
|
+
.replace(/^DB_DRIVER=.*$/m, `DB_DRIVER=${flags.db}`)
|
|
206
|
+
.replace(/^# DATABASE_URL=.*$/m, flags.db === 'postgres'
|
|
207
|
+
? 'DATABASE_URL=postgres://sdods:sdods@localhost:5432/sdods'
|
|
208
|
+
: '# DATABASE_URL=postgres://sdods:sdods@localhost:5432/sdods'));
|
|
209
|
+
write('.gitignore', `node_modules/
|
|
210
|
+
dist/
|
|
211
|
+
.sdods/
|
|
212
|
+
test-results/
|
|
213
|
+
html-report/
|
|
214
|
+
shard-reports/
|
|
215
|
+
*.log
|
|
216
|
+
.env
|
|
217
|
+
.env.*
|
|
218
|
+
!.env.example
|
|
219
|
+
projects/*/.auth/
|
|
220
|
+
projects/*/.env.*
|
|
221
|
+
!projects/*/.env.example
|
|
222
|
+
proposals/*/files/
|
|
223
|
+
.DS_Store
|
|
224
|
+
`);
|
|
225
|
+
const composeSrc = join(src, 'docker-compose.yml');
|
|
226
|
+
write('docker-compose.yml', existsSync(composeSrc)
|
|
227
|
+
? readFileSync(composeSrc, 'utf8')
|
|
228
|
+
: `services:
|
|
229
|
+
postgres:
|
|
230
|
+
image: postgres:16
|
|
231
|
+
environment: { POSTGRES_USER: sdods, POSTGRES_PASSWORD: sdods, POSTGRES_DB: sdods }
|
|
232
|
+
ports: ['5432:5432']
|
|
233
|
+
volumes: [sdods-pgdata:/var/lib/postgresql/data]
|
|
234
|
+
volumes:
|
|
235
|
+
sdods-pgdata:
|
|
236
|
+
`);
|
|
237
|
+
const demoSrc = join(src, 'projects', 'demo-shop');
|
|
238
|
+
const withDemo = flags.demo && existsSync(demoSrc);
|
|
239
|
+
write('README.md', `# ${name}
|
|
240
|
+
|
|
241
|
+
SDODS automation workspace (organization **${org.name}**, workspace **${workspace.name}**).
|
|
242
|
+
|
|
243
|
+
\`\`\`bash
|
|
244
|
+
${flags.pm} install && ${flags.pm} run sdods browsers install --with-deps
|
|
245
|
+
${run} sdods doctor
|
|
246
|
+
${run} sdods workspace tree
|
|
247
|
+
${withDemo ? `${run} sdods run -p demo-shop -e staging -l api` : `${run} sdods project create my-app --ui-url http://localhost:3000 --api-url http://localhost:3000/api`}
|
|
248
|
+
\`\`\`
|
|
249
|
+
|
|
250
|
+
Docs: https://docs.sdods.com
|
|
251
|
+
`);
|
|
252
|
+
if (withDemo) {
|
|
253
|
+
cpSync(demoSrc, join(target, 'projects', 'demo-shop'), {
|
|
254
|
+
recursive: true,
|
|
255
|
+
// Match on the path *within* the demo project, never the absolute path: on a published
|
|
256
|
+
// install the template source itself lives under node_modules, and an absolute-path test
|
|
257
|
+
// would reject its own root and copy nothing.
|
|
258
|
+
filter: (p) => {
|
|
259
|
+
const rel = relative(demoSrc, p).replace(/\\/g, '/');
|
|
260
|
+
if (!rel)
|
|
261
|
+
return true;
|
|
262
|
+
return (!/(^|\/)(\.auth|node_modules|\.features-gen)(\/|$)/.test(rel) &&
|
|
263
|
+
!/(^|\/)\.env\.(?!example)[^/]*$/.test(rel));
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
// the demo declares the SDODS org/workspace; re-home it into the new workspace file
|
|
267
|
+
const demoYaml = join(target, 'projects', 'demo-shop', 'sdods.project.yaml');
|
|
268
|
+
if (existsSync(demoYaml)) {
|
|
269
|
+
const rehomed = readFileSync(demoYaml, 'utf8')
|
|
270
|
+
.replace(/^organization:.*$/m, `organization: ${org.slug}`)
|
|
271
|
+
.replace(/^workspace:.*$/m, `workspace: ${workspace.slug}`);
|
|
272
|
+
writeFileSync(demoYaml, rehomed);
|
|
273
|
+
}
|
|
274
|
+
files.push('projects/demo-shop/');
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
write('projects/.gitkeep', '');
|
|
278
|
+
}
|
|
279
|
+
const skillsSrc = join(src, '.claude', 'skills');
|
|
280
|
+
if (existsSync(skillsSrc)) {
|
|
281
|
+
cpSync(skillsSrc, join(target, '.claude', 'skills'), { recursive: true });
|
|
282
|
+
files.push('.claude/skills/');
|
|
283
|
+
}
|
|
284
|
+
if (flags.from) {
|
|
285
|
+
try {
|
|
286
|
+
const analyze = (await import('@sdods/core/analyze'));
|
|
287
|
+
if (analyze.initFromApp) {
|
|
288
|
+
const created = await analyze.initFromApp({
|
|
289
|
+
appPath: resolvePath(flags.from),
|
|
290
|
+
rootDir: target,
|
|
291
|
+
});
|
|
292
|
+
files.push(`projects/${created.slug}/`);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
warn('`sdods analyze` is not available in this build; skipping --from.');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
warn(`--from skipped: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (flags.git) {
|
|
303
|
+
await execa('git', ['init', '-q'], { cwd: target, reject: false });
|
|
304
|
+
}
|
|
305
|
+
let installed = false;
|
|
306
|
+
if (flags.install && flags.link && flags.pm === 'bun') {
|
|
307
|
+
// Register the local packages so `link:@sdods/*` resolves. Playwright and playwright-bdd are
|
|
308
|
+
// linked from the monorepo too: a second @playwright/test copy in the consumer would make
|
|
309
|
+
// Playwright throw "Requiring @playwright/test second time".
|
|
310
|
+
const repo = sourceRepoRoot();
|
|
311
|
+
const linkDirs = [
|
|
312
|
+
...['contracts', 'core', 'cli'].map((pkg) => join(repo, 'packages', pkg)),
|
|
313
|
+
...['@playwright/test', 'playwright-bdd'].map((pkg) => join(repo, 'node_modules', pkg)),
|
|
314
|
+
];
|
|
315
|
+
for (const dir of linkDirs) {
|
|
316
|
+
if (!existsSync(dir))
|
|
317
|
+
continue;
|
|
318
|
+
await execa('bun', ['link'], { cwd: dir, reject: false, stdio: 'ignore' });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (flags.install) {
|
|
322
|
+
const res = await execa(flags.pm, ['install'], {
|
|
323
|
+
cwd: target,
|
|
324
|
+
stdio: 'inherit',
|
|
325
|
+
reject: false,
|
|
326
|
+
});
|
|
327
|
+
installed = res.exitCode === 0;
|
|
328
|
+
if (!installed)
|
|
329
|
+
warn(`${flags.pm} install exited with ${res.exitCode}; run it manually.`);
|
|
330
|
+
}
|
|
331
|
+
let browsersInstalled = false;
|
|
332
|
+
if (flags.install && flags.browsers && installed) {
|
|
333
|
+
const res = await execa('npx', ['playwright', 'install', 'chromium'], {
|
|
334
|
+
cwd: target,
|
|
335
|
+
stdio: 'inherit',
|
|
336
|
+
reject: false,
|
|
337
|
+
});
|
|
338
|
+
browsersInstalled = res.exitCode === 0;
|
|
339
|
+
}
|
|
340
|
+
if (flags.claude) {
|
|
341
|
+
const res = await execa('npx', ['sdods', 'agent', 'install-claude'], {
|
|
342
|
+
cwd: target,
|
|
343
|
+
reject: false,
|
|
344
|
+
stdio: 'pipe',
|
|
345
|
+
});
|
|
346
|
+
if (res.exitCode === 0)
|
|
347
|
+
files.push('.claude/agents/', '.mcp.json');
|
|
348
|
+
else
|
|
349
|
+
warn('`sdods agent install-claude` is not available yet; run it later.');
|
|
350
|
+
}
|
|
351
|
+
if (flags.git) {
|
|
352
|
+
await execa('git', ['add', '-A'], { cwd: target, reject: false });
|
|
353
|
+
await execa('git', ['-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'Initialize SDODS workspace'], { cwd: target, reject: false });
|
|
354
|
+
}
|
|
355
|
+
return { dir: target, files, installed, browsers: browsersInstalled, demo: withDemo };
|
|
356
|
+
}
|
|
357
|
+
function titleCase(slug) {
|
|
358
|
+
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
359
|
+
}
|
|
360
|
+
export function isDir(p) {
|
|
361
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
362
|
+
}
|
|
363
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* `sdods insights compute|show` — flakiness, locator fragility, environment stability and
|
|
4
|
+
* suite health over the last N ingested runs (needs a database with ingested results).
|
|
5
|
+
*/
|
|
6
|
+
export declare function register(program: Command): void;
|
|
7
|
+
//# sourceMappingURL=insights.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { SdodsError } from '@sdods/core';
|
|
5
|
+
import { createContext } from '../context.js';
|
|
6
|
+
import { json, ok, out, table } from '../ui.js';
|
|
7
|
+
/**
|
|
8
|
+
* `sdods insights compute|show` — flakiness, locator fragility, environment stability and
|
|
9
|
+
* suite health over the last N ingested runs (needs a database with ingested results).
|
|
10
|
+
*/
|
|
11
|
+
export function register(program) {
|
|
12
|
+
const insights = program
|
|
13
|
+
.command('insights')
|
|
14
|
+
.description('Flakiness, locator fragility, environment stability and suite health');
|
|
15
|
+
insights
|
|
16
|
+
.command('compute')
|
|
17
|
+
.description('Compute insights from ingested runs and store the result under .sdods/insights/')
|
|
18
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
19
|
+
.option('--window <n>', 'number of most recent runs to consider', '30')
|
|
20
|
+
.option('--flaky-threshold <r>', 'quarantine candidate when flakiness ≥ r', '0.2')
|
|
21
|
+
.option('--fragility-threshold <r>', 'hot locator when fragility ≥ r', '0.1')
|
|
22
|
+
.option('--min-runs <n>', 'minimum runs before a scenario can be flagged', '10')
|
|
23
|
+
.action(async (opts, cmd) => {
|
|
24
|
+
const ctx = createContext(cmd);
|
|
25
|
+
ctx.registry.entry(opts.project);
|
|
26
|
+
const m = await loadDb();
|
|
27
|
+
const adb = m.createDb();
|
|
28
|
+
try {
|
|
29
|
+
await m.migrateToLatest(adb);
|
|
30
|
+
const result = await m.computeInsights(adb.db, {
|
|
31
|
+
projectSlug: opts.project,
|
|
32
|
+
window: Number(opts.window),
|
|
33
|
+
flakyThreshold: Number(opts.flakyThreshold),
|
|
34
|
+
fragilityThreshold: Number(opts.fragilityThreshold),
|
|
35
|
+
minRuns: Number(opts.minRuns),
|
|
36
|
+
});
|
|
37
|
+
const file = resultFile(ctx.rootDir, opts.project);
|
|
38
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
39
|
+
writeFileSync(file, JSON.stringify(result, null, 2));
|
|
40
|
+
if (ctx.opts.json)
|
|
41
|
+
return json(result);
|
|
42
|
+
render(result);
|
|
43
|
+
ok(`Saved ${file}`);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await adb.close();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
insights
|
|
50
|
+
.command('show')
|
|
51
|
+
.description('Show the last computed insights for a project')
|
|
52
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
53
|
+
.option('--top <n>', 'rows per table', '10')
|
|
54
|
+
.action((opts, cmd) => {
|
|
55
|
+
const ctx = createContext(cmd);
|
|
56
|
+
const file = resultFile(ctx.rootDir, opts.project);
|
|
57
|
+
if (!existsSync(file)) {
|
|
58
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `No insights computed yet for ${opts.project}.`, {
|
|
59
|
+
hint: `Run \`sdods insights compute -p ${opts.project}\` after ingesting some runs.`,
|
|
60
|
+
exitCode: 2,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const result = JSON.parse(readFileSync(file, 'utf8'));
|
|
64
|
+
if (ctx.opts.json)
|
|
65
|
+
return json(result);
|
|
66
|
+
render(result, Number(opts.top));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function resultFile(rootDir, slug) {
|
|
70
|
+
return join(rootDir, '.sdods', 'insights', `${slug}.json`);
|
|
71
|
+
}
|
|
72
|
+
async function loadDb() {
|
|
73
|
+
try {
|
|
74
|
+
return await import('@sdods/db');
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw new SdodsError('DB_REQUIRED', 'Insights need the @sdods/db package and a configured database.', {
|
|
78
|
+
hint: 'Set DB_DRIVER=sqlite (default), run `sdods db migrate`, and ingest runs with `sdods run --ingest`.',
|
|
79
|
+
cause: e,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function pct(n) {
|
|
84
|
+
return `${Math.round(n * 100)}%`;
|
|
85
|
+
}
|
|
86
|
+
function render(r, top = 10) {
|
|
87
|
+
out(`${pc.bold(`Insights for ${r.projectSlug}`)} · window ${r.window} · runs considered ${r.runsConsidered} · pass rate ${pct(r.passRate)} · suite health ${pc.bold(pct(r.suiteHealth))}`);
|
|
88
|
+
if (Array.isArray(r.suiteHealthTrend) && r.suiteHealthTrend.length) {
|
|
89
|
+
out(pc.dim(`trend: ${r.suiteHealthTrend.map((v) => pct(v)).join(' → ')}`));
|
|
90
|
+
}
|
|
91
|
+
out(pc.bold('\nScenarios by flakiness'));
|
|
92
|
+
table([...r.scenarios]
|
|
93
|
+
.sort((a, b) => b.flakinessScore - a.flakinessScore)
|
|
94
|
+
.slice(0, top)
|
|
95
|
+
.map((s) => ({
|
|
96
|
+
scenario: s.scenarioName,
|
|
97
|
+
module: s.module ?? '',
|
|
98
|
+
browser: String(s.runnerProject).split('--').pop(),
|
|
99
|
+
runs: s.runs,
|
|
100
|
+
failed: s.failed,
|
|
101
|
+
flaky: s.flaky,
|
|
102
|
+
flakiness: pct(s.flakinessScore),
|
|
103
|
+
p95ms: s.p95DurationMs ?? '',
|
|
104
|
+
quarantine: s.quarantineCandidate ? pc.yellow('candidate') : '',
|
|
105
|
+
})));
|
|
106
|
+
out(pc.bold('\nLocators by fragility'));
|
|
107
|
+
table([...r.locators]
|
|
108
|
+
.sort((a, b) => b.fragility - a.fragility)
|
|
109
|
+
.slice(0, top)
|
|
110
|
+
.map((l) => ({
|
|
111
|
+
selector: l.selector,
|
|
112
|
+
page: l.pageHint ?? '',
|
|
113
|
+
fails: l.fails,
|
|
114
|
+
heals: l.heals,
|
|
115
|
+
fragility: pct(l.fragility),
|
|
116
|
+
hot: l.hot ? pc.red('hot') : '',
|
|
117
|
+
suggestion: l.suggestedSelector ?? '',
|
|
118
|
+
})));
|
|
119
|
+
out(pc.bold('\nEnvironments'));
|
|
120
|
+
table(r.envs.map((e) => ({
|
|
121
|
+
env: e.env,
|
|
122
|
+
runs: e.runs,
|
|
123
|
+
envFailures: e.envAttributedFailures,
|
|
124
|
+
stability: pct(e.stability),
|
|
125
|
+
})));
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=insights.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
/** `sdods integrations test|notify|sync|links` — GitHub and Jira without the server or a database. */
|
|
3
|
+
export declare function register(program: Command): void;
|
|
4
|
+
interface TaggedScenario {
|
|
5
|
+
tag: 'jira' | 'github';
|
|
6
|
+
key: string;
|
|
7
|
+
featureUri: string;
|
|
8
|
+
scenarioName: string;
|
|
9
|
+
layer: string;
|
|
10
|
+
}
|
|
11
|
+
/** Lightweight feature scan for issue tags (feature-level tags inherit to scenarios). */
|
|
12
|
+
export declare function scanFeatureTags(featuresDir: string, projectRoot: string): TaggedScenario[];
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=integrations.d.ts.map
|