@opice/cli 0.7.0 → 0.8.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/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/commands/failures.ts +64 -24
- package/src/commands/test.ts +62 -11
- package/src/config.ts +8 -0
- package/src/dsn.ts +12 -9
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ Commands:
|
|
|
13
13
|
Scaffold opice.config.json in the current project. Pass
|
|
14
14
|
--with-workflow to also drop a .github/workflows/opice.yml.
|
|
15
15
|
|
|
16
|
-
test [--retries=N] [--tier=NAME] [bun test args...]
|
|
16
|
+
test [--retries=N] [--tier=NAME] [--fail-on-report-error] [bun test args...]
|
|
17
17
|
Wrapper around 'bun test' that exports OPICE_* env vars from
|
|
18
18
|
opice.config.json + git so the harness reporter streams results
|
|
19
19
|
to the platform. All trailing args pass through to bun test.
|
|
@@ -26,6 +26,11 @@ Commands:
|
|
|
26
26
|
standard. Scenarios above it are reported "skipped", not run.
|
|
27
27
|
Falls back to OPICE_TIER, then "tier" in opice.config.json;
|
|
28
28
|
omit to run everything.
|
|
29
|
+
--fail-on-report-error exits non-zero if reporting to the platform
|
|
30
|
+
fails (default: reporting is best-effort and never reddens CI).
|
|
31
|
+
Use it so a bad token / unreachable endpoint can't leave CI green
|
|
32
|
+
while nothing reaches the dashboard. Falls back to
|
|
33
|
+
OPICE_REPORT_STRICT, then "failOnReportError" in opice.config.json.
|
|
29
34
|
|
|
30
35
|
failures <run-url|run-id> [--json]
|
|
31
36
|
Pull a failed run's details (failed scenarios, the failing step,
|
package/src/commands/failures.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* source test file that produced them (the test is the spec — each step
|
|
6
6
|
* carries its `intent`, so there's no separate scenario file).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* dashboard
|
|
8
|
+
* Two read modes:
|
|
9
|
+
* - SERVICE TOKEN (OPICE_READ_DSN): a propustka service-token principal → REST
|
|
10
|
+
* GET /api/v1/<slug>/… with the CF-Access-Client-* headers.
|
|
11
|
+
* - CAPABILITY SHARE: a pasted dashboard link's `?token=` (or OPICE_READ_TOKEN)
|
|
12
|
+
* → the anonymous read RPC at /s/rpc.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { loadConfig } from '../config'
|
|
@@ -45,10 +46,18 @@ interface Step {
|
|
|
45
46
|
interface Target {
|
|
46
47
|
endpoint: string
|
|
47
48
|
runId: string
|
|
48
|
-
token: string | undefined
|
|
49
49
|
slug?: string
|
|
50
|
+
// Exactly one auth mode: a pasted share link / OPICE_READ_TOKEN is a capability on /s/rpc;
|
|
51
|
+
// OPICE_READ_DSN is a service token (CF-Access-Client-*) on /api/v1.
|
|
52
|
+
shareToken?: string
|
|
53
|
+
service?: { clientId: string; clientSecret: string }
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
type ReadOp =
|
|
57
|
+
| { kind: 'run'; runId: string }
|
|
58
|
+
| { kind: 'scenarios'; runId: string }
|
|
59
|
+
| { kind: 'steps'; scenarioId: string }
|
|
60
|
+
|
|
52
61
|
export async function failuresCommand(args: string[]): Promise<number> {
|
|
53
62
|
const asJson = args.includes('--json')
|
|
54
63
|
const positional = args.filter((a) => !a.startsWith('--'))
|
|
@@ -67,8 +76,8 @@ export async function failuresCommand(args: string[]): Promise<number> {
|
|
|
67
76
|
let run: Run
|
|
68
77
|
let scenarios: Scenario[]
|
|
69
78
|
try {
|
|
70
|
-
run = await
|
|
71
|
-
scenarios = await
|
|
79
|
+
run = await read<Run>(target, { kind: 'run', runId: target.runId })
|
|
80
|
+
scenarios = await read<Scenario[]>(target, { kind: 'scenarios', runId: target.runId })
|
|
72
81
|
} catch (err) {
|
|
73
82
|
console.error(`[opice] ${(err as Error).message}`)
|
|
74
83
|
return 1
|
|
@@ -78,7 +87,7 @@ export async function failuresCommand(args: string[]): Promise<number> {
|
|
|
78
87
|
const detailed = await Promise.all(
|
|
79
88
|
failed.map(async (s) => ({
|
|
80
89
|
scenario: s,
|
|
81
|
-
steps: await
|
|
90
|
+
steps: await read<Step[]>(target, { kind: 'steps', scenarioId: s.id }).catch(() => [] as Step[]),
|
|
82
91
|
})),
|
|
83
92
|
)
|
|
84
93
|
|
|
@@ -141,37 +150,68 @@ function printDigest(run: Run, detailed: { scenario: Scenario; steps: Step[] }[]
|
|
|
141
150
|
|
|
142
151
|
function absoluteScreenshot(relativeOrAbsolute: string, target: Target): string {
|
|
143
152
|
const base = relativeOrAbsolute.startsWith('http') ? relativeOrAbsolute : `${target.endpoint}${relativeOrAbsolute}`
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
// A share link can carry its token in the URL; a service token needs headers, so leave it bare.
|
|
154
|
+
if (!target.shareToken) return base
|
|
155
|
+
return `${base}${base.includes('?') ? '&' : '?'}token=${target.shareToken}`
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
async function resolveTarget(ref: string): Promise<Target | null> {
|
|
149
159
|
const readDsn = parseOpiceDsn(process.env['OPICE_READ_DSN'])
|
|
150
|
-
const
|
|
160
|
+
const service = readDsn ? { clientId: readDsn.clientId, clientSecret: readDsn.clientSecret } : undefined
|
|
161
|
+
const envShareToken = process.env['OPICE_READ_TOKEN']
|
|
151
162
|
|
|
152
163
|
if (/^https?:\/\//.test(ref)) {
|
|
153
164
|
const url = new URL(ref)
|
|
154
|
-
const
|
|
165
|
+
const urlToken = url.searchParams.get('token')
|
|
155
166
|
const match = url.pathname.match(/\/p\/([^/]+)\/r\/([^/]+)/)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return null
|
|
167
|
+
const slug = match ? decodeURIComponent(match[1]!) : (process.env['OPICE_PROJECT'] ?? readDsn?.project)
|
|
168
|
+
const runId = match ? decodeURIComponent(match[2]!) : url.pathname.split('/').filter(Boolean).pop()
|
|
169
|
+
if (!runId) return null
|
|
170
|
+
// A pasted share link (?token=) or OPICE_READ_TOKEN → capability; otherwise the read DSN.
|
|
171
|
+
const shareToken = urlToken ?? envShareToken
|
|
172
|
+
if (shareToken) return { endpoint: url.origin, runId, slug, shareToken }
|
|
173
|
+
return { endpoint: url.origin, runId, slug, service }
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
// Bare run id — endpoint from config/env/DSN,
|
|
176
|
+
// Bare run id — endpoint + slug from config/env/DSN, auth from the read DSN or OPICE_READ_TOKEN.
|
|
167
177
|
const config = await loadConfig()
|
|
168
178
|
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? readDsn?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
169
179
|
if (!endpoint) return null
|
|
170
|
-
|
|
180
|
+
const slug = process.env['OPICE_PROJECT'] ?? config?.project ?? readDsn?.project
|
|
181
|
+
if (envShareToken) return { endpoint, runId: ref, slug, shareToken: envShareToken }
|
|
182
|
+
return { endpoint, runId: ref, slug, service }
|
|
171
183
|
}
|
|
172
184
|
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Read one resource, routed by the target's auth mode: a service token → REST GET on /api/v1
|
|
187
|
+
* (which needs the project slug); a capability share token → the /s/rpc read RPC.
|
|
188
|
+
*/
|
|
189
|
+
async function read<T>(target: Target, op: ReadOp): Promise<T> {
|
|
190
|
+
if (target.service) {
|
|
191
|
+
if (!target.slug) {
|
|
192
|
+
throw new Error('reading via OPICE_READ_DSN needs the project slug — paste a full run URL or set OPICE_PROJECT')
|
|
193
|
+
}
|
|
194
|
+
const path = op.kind === 'run'
|
|
195
|
+
? `runs/${op.runId}`
|
|
196
|
+
: op.kind === 'scenarios'
|
|
197
|
+
? `runs/${op.runId}/scenarios`
|
|
198
|
+
: `scenarios/${op.scenarioId}/steps`
|
|
199
|
+
const response = await fetch(`${target.endpoint}/api/v1/${target.slug}/${path}`, {
|
|
200
|
+
headers: {
|
|
201
|
+
'cf-access-client-id': target.service.clientId,
|
|
202
|
+
'cf-access-client-secret': target.service.clientSecret,
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`${op.kind}: ${response.status} ${response.statusText}`)
|
|
207
|
+
}
|
|
208
|
+
return (await response.json()) as T
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Capability share → the anonymous read RPC.
|
|
212
|
+
const method = op.kind === 'run' ? 'runs.get' : op.kind === 'scenarios' ? 'runs.scenarios' : 'scenarios.steps'
|
|
213
|
+
const input = op.kind === 'steps' ? { scenarioId: op.scenarioId } : { runId: op.runId }
|
|
214
|
+
const url = `${target.endpoint}/s/rpc${target.shareToken ? `?token=${target.shareToken}` : ''}`
|
|
175
215
|
const response = await fetch(url, {
|
|
176
216
|
method: 'POST',
|
|
177
217
|
headers: { 'content-type': 'application/json' },
|
package/src/commands/test.ts
CHANGED
|
@@ -12,7 +12,9 @@ interface Handoff {
|
|
|
12
12
|
endpoint: string
|
|
13
13
|
/** Project slug — used to build the /api/v1/<project>/runs/<id>/finish URL. */
|
|
14
14
|
project: string
|
|
15
|
-
|
|
15
|
+
/** Service-token credentials for the CF-Access-Client-* headers on POST /finish. */
|
|
16
|
+
clientId: string
|
|
17
|
+
clientSecret: string
|
|
16
18
|
runId: string
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -21,7 +23,8 @@ export async function testCommand(args: string[]): Promise<number> {
|
|
|
21
23
|
const dsn = parseOpiceDsn(process.env['OPICE_DSN'])
|
|
22
24
|
const project = process.env['OPICE_PROJECT'] ?? config?.project ?? dsn?.project
|
|
23
25
|
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? dsn?.endpoint
|
|
24
|
-
const
|
|
26
|
+
const clientId = process.env['OPICE_CLIENT_ID'] ?? dsn?.clientId
|
|
27
|
+
const clientSecret = process.env['OPICE_CLIENT_SECRET'] ?? dsn?.clientSecret
|
|
25
28
|
|
|
26
29
|
if (!project) {
|
|
27
30
|
warn('OPICE_PROJECT not set and no opice.config.json found. Run `opice init` or set the env var.')
|
|
@@ -29,8 +32,8 @@ export async function testCommand(args: string[]): Promise<number> {
|
|
|
29
32
|
if (!endpoint) {
|
|
30
33
|
warn('OPICE_ENDPOINT not set and no opice.config.json found. Tests will run without reporting.')
|
|
31
34
|
}
|
|
32
|
-
if (!
|
|
33
|
-
warn('
|
|
35
|
+
if (!clientId || !clientSecret) {
|
|
36
|
+
warn('OPICE_CLIENT_ID / OPICE_CLIENT_SECRET not set (the OPICE_DSN userinfo). Tests will run without reporting.')
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// `--tier=NAME` selects which test tier runs (critical < standard < extended).
|
|
@@ -40,23 +43,33 @@ export async function testCommand(args: string[]): Promise<number> {
|
|
|
40
43
|
const { tier, rest: afterTier } = extractTier(args)
|
|
41
44
|
const resolvedTier = tier ?? process.env['OPICE_TIER'] ?? config?.tier
|
|
42
45
|
|
|
46
|
+
// `--fail-on-report-error` turns a swallowed reporting failure into a non-zero
|
|
47
|
+
// exit (default is best-effort: reporting never reddens CI). CLI flag wins over
|
|
48
|
+
// OPICE_REPORT_STRICT, which wins over opice.config.json's `failOnReportError`.
|
|
49
|
+
// We propagate it to the harness via OPICE_REPORT_STRICT (it fails the run from
|
|
50
|
+
// a scenario's afterAll) AND honour it here for the POST /finish finalize.
|
|
51
|
+
const { strict: strictFlag, rest: afterStrict } = extractStrict(afterTier)
|
|
52
|
+
const strict = strictFlag || isTruthy(process.env['OPICE_REPORT_STRICT']) || config?.failOnReportError === true
|
|
53
|
+
|
|
43
54
|
const git = detectGitMeta()
|
|
44
55
|
const env: NodeJS.ProcessEnv = {
|
|
45
56
|
...process.env,
|
|
46
57
|
...(project ? { OPICE_PROJECT: project } : {}),
|
|
47
58
|
...(endpoint ? { OPICE_ENDPOINT: endpoint } : {}),
|
|
48
|
-
// Resolve the
|
|
59
|
+
// Resolve the service-token pair (incl. from a DSN) into the explicit vars the
|
|
49
60
|
// harness reporter reads, so a bare OPICE_DSN is enough to report.
|
|
50
|
-
...(
|
|
61
|
+
...(clientId ? { OPICE_CLIENT_ID: clientId } : {}),
|
|
62
|
+
...(clientSecret ? { OPICE_CLIENT_SECRET: clientSecret } : {}),
|
|
51
63
|
...(git.branch ? { OPICE_BRANCH: git.branch } : {}),
|
|
52
64
|
...(git.commit ? { OPICE_COMMIT: git.commit } : {}),
|
|
53
65
|
...(resolvedTier ? { OPICE_TIER: resolvedTier } : {}),
|
|
66
|
+
...(strict ? { OPICE_REPORT_STRICT: '1' } : {}),
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
// `--retries=N` (opice's spelling) → bun's `--retry=N`, the global default
|
|
57
70
|
// retry budget for every scenario. CLI flag wins over opice.config.json's
|
|
58
71
|
// `retries`. A per-scenario `walkthrough`/meta `retries` overrides both.
|
|
59
|
-
const { retries, rest } = extractRetries(
|
|
72
|
+
const { retries, rest } = extractRetries(afterStrict)
|
|
60
73
|
const resolvedRetries = retries ?? config?.retries
|
|
61
74
|
const bunArgs = ['test', ...rest]
|
|
62
75
|
// Don't clobber an explicit `--retry` the caller passed through to bun.
|
|
@@ -72,7 +85,16 @@ export async function testCommand(args: string[]): Promise<number> {
|
|
|
72
85
|
|
|
73
86
|
// After bun test exits, look for handoff files the reporter wrote and
|
|
74
87
|
// POST /finish for each run so it leaves "running" state.
|
|
75
|
-
await finalizeHandoffs(child.pid, project)
|
|
88
|
+
const finalizeFailed = await finalizeHandoffs(child.pid, project)
|
|
89
|
+
|
|
90
|
+
// Under strict reporting, a failed finalize (POST /finish) reddens an
|
|
91
|
+
// otherwise-green run — the same contract the harness enforces for in-test
|
|
92
|
+
// reports. Don't mask a real test failure: only escalate when bun itself was
|
|
93
|
+
// green. (An in-test report failure already failed bun via the harness.)
|
|
94
|
+
if (exitCode === 0 && strict && finalizeFailed) {
|
|
95
|
+
warn('reporting failed and --fail-on-report-error is set — exiting non-zero.')
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
return exitCode
|
|
78
100
|
}
|
|
@@ -131,14 +153,38 @@ function extractTier(args: string[]): { tier: string | undefined; rest: string[]
|
|
|
131
153
|
return { tier, rest }
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
|
|
156
|
+
/** Returns true if finalizing any run failed (so strict mode can escalate). */
|
|
157
|
+
/**
|
|
158
|
+
* Pull opice's `--fail-on-report-error` boolean flag out of the arg list (it's
|
|
159
|
+
* an opice concept, not a bun flag) and return whether it was present plus the
|
|
160
|
+
* remaining args.
|
|
161
|
+
*/
|
|
162
|
+
function extractStrict(args: string[]): { strict: boolean; rest: string[] } {
|
|
163
|
+
const rest: string[] = []
|
|
164
|
+
let strict = false
|
|
165
|
+
for (const arg of args) {
|
|
166
|
+
if (arg === '--fail-on-report-error') strict = true
|
|
167
|
+
else rest.push(arg)
|
|
168
|
+
}
|
|
169
|
+
return { strict, rest }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isTruthy(value: string | undefined): boolean {
|
|
173
|
+
if (!value) return false
|
|
174
|
+
const v = value.toLowerCase()
|
|
175
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Returns true if finalizing any run failed (so strict mode can escalate). */
|
|
179
|
+
async function finalizeHandoffs(childPid: number | undefined, slug: string | undefined): Promise<boolean> {
|
|
135
180
|
let files: string[]
|
|
136
181
|
try {
|
|
137
182
|
files = await fs.readdir(HANDOFF_DIR)
|
|
138
183
|
} catch {
|
|
139
|
-
return // no handoff dir → no runs reported, nothing to finalize
|
|
184
|
+
return false // no handoff dir → no runs reported, nothing to finalize
|
|
140
185
|
}
|
|
141
186
|
const matching = childPid ? files.filter((f) => f === `${childPid}.json`) : files
|
|
187
|
+
let failed = false
|
|
142
188
|
for (const file of matching) {
|
|
143
189
|
const full = path.join(HANDOFF_DIR, file)
|
|
144
190
|
try {
|
|
@@ -146,11 +192,13 @@ async function finalizeHandoffs(childPid: number | undefined, slug: string | und
|
|
|
146
192
|
await finishRun(handoff)
|
|
147
193
|
printRunUrl(handoff, slug)
|
|
148
194
|
} catch (err) {
|
|
195
|
+
failed = true
|
|
149
196
|
warn(`Failed to finalize run from ${file}: ${(err as Error).message}`)
|
|
150
197
|
} finally {
|
|
151
198
|
await fs.unlink(full).catch(() => {})
|
|
152
199
|
}
|
|
153
200
|
}
|
|
201
|
+
return failed
|
|
154
202
|
}
|
|
155
203
|
|
|
156
204
|
function printRunUrl(handoff: Handoff, slug: string | undefined): void {
|
|
@@ -164,7 +212,10 @@ async function finishRun(handoff: Handoff): Promise<void> {
|
|
|
164
212
|
const url = `${handoff.endpoint}/api/v1/${handoff.project}/runs/${handoff.runId}/finish`
|
|
165
213
|
const response = await fetch(url, {
|
|
166
214
|
method: 'POST',
|
|
167
|
-
headers: {
|
|
215
|
+
headers: {
|
|
216
|
+
'cf-access-client-id': handoff.clientId,
|
|
217
|
+
'cf-access-client-secret': handoff.clientSecret,
|
|
218
|
+
},
|
|
168
219
|
})
|
|
169
220
|
if (!response.ok) {
|
|
170
221
|
throw new Error(`${response.status} ${await response.text()}`)
|
package/src/config.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface OpiceConfig {
|
|
|
18
18
|
* Scenarios above the selected tier are reported `skipped`, not run.
|
|
19
19
|
*/
|
|
20
20
|
tier?: 'critical' | 'standard' | 'extended'
|
|
21
|
+
/**
|
|
22
|
+
* Fail the run if reporting to the platform fails (default best-effort: a
|
|
23
|
+
* reporting failure is logged but never reddens CI). When set, a failed report
|
|
24
|
+
* during the test (harness) or a failed `POST /finish` (this CLI) exits
|
|
25
|
+
* non-zero. Overridden by `opice test --fail-on-report-error` and the
|
|
26
|
+
* `OPICE_REPORT_STRICT` env var.
|
|
27
|
+
*/
|
|
28
|
+
failOnReportError?: boolean
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
const CONFIG_NAME = 'opice.config.json'
|
package/src/dsn.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An opice DSN packs everything a project needs to report into one string:
|
|
3
3
|
*
|
|
4
|
-
* OPICE_DSN=https://<
|
|
4
|
+
* OPICE_DSN=https://<clientId>:<clientSecret>@<host>/<slug>
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The DSN is a Cloudflare Access SERVICE TOKEN: the client id + secret ride in the
|
|
7
|
+
* userinfo (sent as `CF-Access-Client-Id` / `CF-Access-Client-Secret`), the host is
|
|
8
|
+
* the platform endpoint, and the first path segment is the project slug. The
|
|
9
|
+
* individual `OPICE_*` vars (and opice.config.json) still win when present — the DSN
|
|
10
|
+
* is a convenience fallback so the dashboard can hand out a single value.
|
|
10
11
|
*
|
|
11
12
|
* Kept in sync with `@opice/harness`'s copy; duplicated to avoid a CLI→harness
|
|
12
13
|
* dependency.
|
|
13
14
|
*/
|
|
14
15
|
export interface OpiceDsn {
|
|
15
|
-
|
|
16
|
+
clientId: string
|
|
17
|
+
clientSecret: string
|
|
16
18
|
endpoint: string
|
|
17
19
|
project: string
|
|
18
20
|
}
|
|
@@ -25,8 +27,9 @@ export function parseOpiceDsn(raw: string | undefined | null): OpiceDsn | null {
|
|
|
25
27
|
} catch {
|
|
26
28
|
return null
|
|
27
29
|
}
|
|
28
|
-
const
|
|
30
|
+
const clientId = decodeURIComponent(url.username)
|
|
31
|
+
const clientSecret = decodeURIComponent(url.password)
|
|
29
32
|
const project = url.pathname.replace(/^\/+/, '').split('/')[0] ?? ''
|
|
30
|
-
if (!
|
|
31
|
-
return {
|
|
33
|
+
if (!clientId || !clientSecret || !project) return null
|
|
34
|
+
return { clientId, clientSecret, endpoint: `${url.protocol}//${url.host}`, project }
|
|
32
35
|
}
|