@pikku/core 0.12.100 → 0.12.101

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.
@@ -10,6 +10,7 @@ import type {
10
10
  ConverseOptions,
11
11
  ActorFlowVerdict,
12
12
  TargetAgentReply,
13
+ TargetPendingApproval,
13
14
  } from '../wirings/actor-flow/actor-flow.types.js'
14
15
  import { runConversation } from '../wirings/actor-flow/run-conversation.js'
15
16
  import {
@@ -75,6 +76,17 @@ export interface HttpPersonasConfig {
75
76
  sessionPath?: string
76
77
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
77
78
  rpcPath?: string
79
+ /**
80
+ * Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
81
+ * Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
82
+ *
83
+ * Asked before better-auth's `user.role`, because in an app that authorizes
84
+ * on scopes that column is a projection rather than the model — it exists so
85
+ * better-auth's own admin endpoints have something to read, is written by
86
+ * whatever keeps it in step, and is absent entirely from an app that declares
87
+ * no such field. A persona verified against it is verified against a copy.
88
+ */
89
+ rolesRpc?: string | false
78
90
  /**
79
91
  * Default model a persona thinks with when `converse(...)` is called without
80
92
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -207,16 +219,46 @@ export class HttpPersona implements ScenarioPersona {
207
219
  /**
208
220
  * The roles the stage says this session holds.
209
221
  *
210
- * Read from better-auth's `get-session`, which is what most pikku apps are
211
- * running and where its admin plugin puts `role` on the user, as a
212
- * comma-separated list. A target that answers something else returns `null`
213
- * rather than an empty list, because "this stage does not report roles" and
214
- * "this person has none" call for opposite responses from the caller.
222
+ * {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
223
+ * `get-session` where its admin plugin puts `role` on the user, as a
224
+ * comma-separated list. A target that answers neither returns `null` rather
225
+ * than an empty list, because "this stage does not report roles" and "this
226
+ * person has none" call for opposite responses from the caller.
215
227
  */
216
228
  async sessionRoles(): Promise<string[] | null> {
217
229
  if (!this.signedIn) {
218
230
  await this.login()
219
231
  }
232
+ const fromRpc = await this.rolesFromRpc()
233
+ if (fromRpc) return fromRpc
234
+ return await this.rolesFromSession()
235
+ }
236
+
237
+ /**
238
+ * The caller's own roles, from the app's own RPC. `null` for every answer
239
+ * that is not a role list — an app without the RPC 404s here, which is a
240
+ * reason to go on and ask better-auth, not a reason to report "none".
241
+ */
242
+ private async rolesFromRpc(): Promise<string[] | null> {
243
+ const rpcName = this.config.rolesRpc ?? 'getMyScopes'
244
+ if (rpcName === false) return null
245
+ let res: Response
246
+ try {
247
+ res = await this.postRpc(rpcName, {})
248
+ } catch {
249
+ return null
250
+ }
251
+ if (!res.ok) return null
252
+ const { body } = await readScenarioHttpResponse<{
253
+ roles?: unknown
254
+ data?: { roles?: unknown }
255
+ }>(res)
256
+ const roles = body?.roles ?? body?.data?.roles
257
+ if (!Array.isArray(roles)) return null
258
+ return roles.filter((name): name is string => typeof name === 'string')
259
+ }
260
+
261
+ private async rolesFromSession(): Promise<string[] | null> {
220
262
  const mount = authMount(
221
263
  this.config.operator?.signInPath ?? this.config.signInPath
222
264
  )
@@ -254,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
254
296
  return user ? [] : null
255
297
  }
256
298
 
257
- /** Start/continue the target agent's run over HTTP as this persona. */
299
+ /**
300
+ * Start/continue the target agent's run over HTTP as this persona.
301
+ *
302
+ * The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
303
+ * run before it sends a single byte, so a run longer than the client's
304
+ * headers timeout — 300s in undici, which is what Node and Bun both use —
305
+ * fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
306
+ * stage that is down. An agent that talks for several minutes, which is the
307
+ * normal case for anything conversational, cannot be driven that way at all.
308
+ * The stream sends its first event immediately and the run's length stops
309
+ * mattering.
310
+ */
258
311
  private async agentRun(
259
312
  agentName: string,
260
313
  message: string,
261
314
  threadId: string,
262
315
  resourceId: string
263
316
  ): Promise<TargetAgentReply> {
264
- const raw = await this.postAgent(`agent/${agentName}`, {
317
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
265
318
  message,
266
319
  threadId,
267
320
  resourceId,
268
321
  })
269
- return normalizeAgentReply(raw)
322
+ return await collectAgentStream(res)
270
323
  }
271
324
 
272
325
  /** Answer the target agent's pending approvals over HTTP and continue. */
@@ -283,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
283
336
  }
284
337
 
285
338
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
286
- private async postAgent(subPath: string, body: unknown): Promise<unknown> {
339
+ private async sendAgent(subPath: string, body: unknown): Promise<Response> {
287
340
  const rpcPath = this.config.rpcPath ?? '/rpc'
288
341
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
289
342
  const send = () =>
@@ -308,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
308
361
  `[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
309
362
  )
310
363
  }
364
+ return res
365
+ }
366
+
367
+ private async postAgent(subPath: string, body: unknown): Promise<unknown> {
368
+ const res = await this.sendAgent(subPath, body)
311
369
  if (res.status === 204) return undefined
312
370
  const text = await res.text()
313
371
  return text ? JSON.parse(text) : undefined
@@ -342,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
342
400
  }
343
401
  }
344
402
 
403
+ /**
404
+ * Reduce the agent's SSE run into the same reply shape the plain route returns.
405
+ *
406
+ * `RUN_ERROR` is raised rather than returned: the plain route answers a failed
407
+ * run with a non-2xx, and a scenario that read an error as an empty transcript
408
+ * would score the agent on silence it never produced.
409
+ */
410
+ async function collectAgentStream(res: Response): Promise<TargetAgentReply> {
411
+ const body = res.body
412
+ if (!body) {
413
+ throw new Error('[scenario] the agent stream carried no body')
414
+ }
415
+ const decoder = new TextDecoder()
416
+ const reader = body.getReader()
417
+ let buffer = ''
418
+ let text = ''
419
+ let runId = ''
420
+ const pendingApprovals: TargetPendingApproval[] = []
421
+
422
+ const consume = (line: string) => {
423
+ if (!line.startsWith('data:')) return
424
+ const payload = line.slice(5).trim()
425
+ if (!payload) return
426
+ let event: Record<string, unknown>
427
+ try {
428
+ event = JSON.parse(payload)
429
+ } catch {
430
+ return
431
+ }
432
+ if (typeof event.runId === 'string' && event.runId) runId = event.runId
433
+ if (
434
+ event.type === 'TEXT_MESSAGE_CONTENT' &&
435
+ typeof event.delta === 'string'
436
+ ) {
437
+ text += event.delta
438
+ } else if (event.type === 'approval-request') {
439
+ pendingApprovals.push({
440
+ toolCallId: String(event.toolCallId),
441
+ toolName: String(event.toolName),
442
+ args: event.args,
443
+ reason: typeof event.reason === 'string' ? event.reason : undefined,
444
+ })
445
+ } else if (event.type === 'RUN_ERROR' || event.type === 'error') {
446
+ const message = event.message ?? event.errorText ?? 'the agent run failed'
447
+ throw new Error(`[scenario] agent run failed: ${String(message)}`)
448
+ }
449
+ }
450
+
451
+ try {
452
+ for (;;) {
453
+ const { done, value } = await reader.read()
454
+ if (done) break
455
+ buffer += decoder.decode(value, { stream: true })
456
+ const lines = buffer.split('\n')
457
+ buffer = lines.pop() ?? ''
458
+ for (const line of lines) consume(line)
459
+ }
460
+ if (buffer) consume(buffer)
461
+ } finally {
462
+ await reader.cancel().catch(() => {})
463
+ }
464
+
465
+ return {
466
+ text,
467
+ runId,
468
+ status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
469
+ pendingApprovals:
470
+ pendingApprovals.length > 0 ? pendingApprovals : undefined,
471
+ }
472
+ }
473
+
345
474
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
346
475
  function normalizeAgentReply(raw: unknown): TargetAgentReply {
347
476
  const r = (raw ?? {}) as Record<string, unknown>
@@ -14,7 +14,7 @@ const OPERATOR_TOKEN = 'operator.jwt.token'
14
14
  */
15
15
  const startStage = async (seeded: Array<{ id: string; email: string }>) => {
16
16
  const users = [...seeded]
17
- let created = 0
17
+ const sentBodies: unknown[] = []
18
18
  const server: Server = createServer((req, res) => {
19
19
  const chunks: Buffer[] = []
20
20
  req.on('data', (c) => chunks.push(c))
@@ -31,21 +31,15 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
31
31
  }
32
32
  let actAs: { userId: string } | undefined
33
33
  if (body.actAs) {
34
- let user = users.find((u) => u.email === body.actAs.email)
34
+ sentBodies.push(body.actAs)
35
+ const user = users.find((u) => u.email === body.actAs.email)
35
36
  if (!user) {
36
- if (!body.actAs.create) {
37
- res
38
- .writeHead(404)
39
- .end(
40
- JSON.stringify({
41
- message: `No account on this stage for ${body.actAs.email}`,
42
- })
43
- )
44
- return
45
- }
46
- created++
47
- user = { id: `made-${created}`, email: body.actAs.email }
48
- users.push(user)
37
+ res.writeHead(404).end(
38
+ JSON.stringify({
39
+ message: `No account on this stage for ${body.actAs.email}`,
40
+ })
41
+ )
42
+ return
49
43
  }
50
44
  actAs = { userId: user.id }
51
45
  }
@@ -74,8 +68,8 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
74
68
  return {
75
69
  apiUrl: `http://127.0.0.1:${port}/api`,
76
70
  server,
77
- get createdCount() {
78
- return created
71
+ get sentBodies() {
72
+ return sentBodies
79
73
  },
80
74
  }
81
75
  }
@@ -112,7 +106,6 @@ describe('operator persona sign-in', () => {
112
106
  }
113
107
  assert.equal(result.actingAs, 'user-7')
114
108
  assert.match(result.cookie, /session=operator/)
115
- assert.equal(stage.createdCount, 0)
116
109
  })
117
110
 
118
111
  // An app that mounts auth somewhere other than the root moves both sign-in
@@ -151,24 +144,25 @@ describe('operator persona sign-in', () => {
151
144
  () => personas.customer!.invoke('whoami', {}),
152
145
  /operator sign-in failed for 'customer' \(404\)/
153
146
  )
154
- assert.equal(stage.createdCount, 0)
155
147
  })
156
148
 
157
- test('provisions the account only when told to', async () => {
158
- const stage = await startStage([])
149
+ // Provisioning moved into the stage, so the handshake carries an address and
150
+ // nothing else. A `create` flag or a role list here would be the caller
151
+ // deciding what the stage holds, which is the arrangement this replaced.
152
+ test('asks only to act as an address', async () => {
153
+ const stage = await startStage([
154
+ { id: 'user-3', email: 'customer@personas.invalid' },
155
+ ])
159
156
  servers.push(stage.server)
160
157
 
161
158
  const personas = createHttpPersonas({
162
159
  apiUrl: stage.apiUrl,
163
- operator: { token: OPERATOR_TOKEN, createMissing: true },
164
- personas: { customer: persona('fresh@personas.invalid') },
160
+ operator: { token: OPERATOR_TOKEN },
161
+ personas: { customer: persona('customer@personas.invalid') },
165
162
  })
166
163
 
167
- const result = (await personas.customer!.invoke('whoami', {})) as {
168
- actingAs: string | null
169
- }
170
- assert.equal(result.actingAs, 'made-1')
171
- assert.equal(stage.createdCount, 1)
164
+ await personas.customer!.invoke('whoami', {})
165
+ assert.deepEqual(stage.sentBodies, [{ email: 'customer@personas.invalid' }])
172
166
  })
173
167
 
174
168
  test('mints the operator session from a token factory', async () => {
@@ -1,4 +1,5 @@
1
1
  import { deriveActorSecret } from './persona-actor-secret.js'
2
+ import { PikkuError } from '../errors/error-handler.js'
2
3
  import type { ResolvedPersona } from './personas-service.js'
3
4
  import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
4
5
 
@@ -30,13 +31,19 @@ export interface PersonaSignIn {
30
31
  headers(): Record<string, string>
31
32
  }
32
33
 
34
+ /**
35
+ * A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
36
+ * this message alone: an expired token or a persona the stage has never seen is
37
+ * something to go and fix, and a stack trace through the fetch internals only
38
+ * buries the status and the body that say which one it is.
39
+ */
33
40
  const failed = async (
34
41
  what: string,
35
42
  personaId: string,
36
43
  res: Response
37
44
  ): Promise<Error> => {
38
45
  const body = (await res.text().catch(() => '')).slice(0, 300)
39
- return new Error(
46
+ return new PikkuError(
40
47
  `[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
41
48
  )
42
49
  }
@@ -123,16 +130,6 @@ export interface OperatorSignInOptions {
123
130
  * the function: tokens expire, and a long run re-logs-in after a 401.
124
131
  */
125
132
  token: string | (() => string | Promise<string>)
126
- /**
127
- * Create the persona's user row when the target has no account for that
128
- * address.
129
- *
130
- * Off by default, which is the whole point of the deployed path: a persona is
131
- * meant to be a real account somebody provisioned, and a test run that
132
- * silently writes users into a live database is a side effect nobody asked
133
- * for. Turn it on for throwaway stages.
134
- */
135
- createMissing?: boolean
136
133
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
137
134
  signInPath?: string
138
135
  }
@@ -171,12 +168,7 @@ export const establishOperatorSession = async (
171
168
  headers: { 'content-type': 'application/json', ...extraHeaders },
172
169
  body: JSON.stringify({
173
170
  token,
174
- actAs: {
175
- email: persona.email,
176
- name: persona.name,
177
- create: options.createMissing ?? false,
178
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
179
- },
171
+ actAs: { email: persona.email },
180
172
  }),
181
173
  })
182
174
  if (!res.ok) {
@@ -184,7 +176,7 @@ export const establishOperatorSession = async (
184
176
  }
185
177
  const setCookies = res.headers.getSetCookie?.() ?? []
186
178
  if (setCookies.length === 0) {
187
- throw new Error(
179
+ throw new PikkuError(
188
180
  `[scenario] operator sign-in for '${persona.id}' returned no session cookie`
189
181
  )
190
182
  }
@@ -194,7 +186,7 @@ export const establishOperatorSession = async (
194
186
  } | null
195
187
  const userId = body?.actAs?.userId
196
188
  if (!userId) {
197
- throw new Error(
189
+ throw new PikkuError(
198
190
  `[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
199
191
  'the target is running a @pikku/better-auth too old to resolve one'
200
192
  )
@@ -1,6 +1,7 @@
1
1
  import { test, describe, beforeEach, afterEach } from 'node:test'
2
2
  import * as assert from 'assert'
3
3
  import { NotFoundError } from '../../errors/errors.js'
4
+ import { PikkuError } from '../../errors/error-handler.js'
4
5
  import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js'
5
6
  import {
6
7
  CLIError,
@@ -724,5 +725,108 @@ describe('CLI Runner', () => {
724
725
  singletonServices
725
726
  )
726
727
  })
728
+ const wireFailingCommand = (error: unknown) => {
729
+ pikkuState(null, 'cli', 'meta', {
730
+ programs: {
731
+ 'test-cli': {
732
+ program: 'test-cli',
733
+ commands: {
734
+ boom: {
735
+ command: 'boom',
736
+ pikkuFuncId: 'boomFunc',
737
+ positionals: [],
738
+ options: {},
739
+ },
740
+ },
741
+ options: {},
742
+ },
743
+ },
744
+ renderers: {},
745
+ })
746
+ pikkuState(null, 'cli', 'programs', {
747
+ 'test-cli': {
748
+ defaultRenderer: undefined,
749
+ middleware: [],
750
+ renderers: {},
751
+ },
752
+ })
753
+ pikkuState(null, 'function', 'meta', {
754
+ boomFunc: {
755
+ pikkuFuncId: 'boomFunc',
756
+ inputSchemaName: null,
757
+ outputSchemaName: null,
758
+ sessionless: true,
759
+ },
760
+ })
761
+ addFunction('boomFunc', {
762
+ func: async () => {
763
+ throw error
764
+ },
765
+ auth: false,
766
+ })
767
+ }
768
+
769
+ const captureStderr = async (run: () => Promise<void>) => {
770
+ const errors: string[] = []
771
+ const originalError = console.error
772
+ console.error = (message?: any) => {
773
+ errors.push(String(message))
774
+ }
775
+ try {
776
+ await assert.rejects(run, CLIError)
777
+ } finally {
778
+ console.error = originalError
779
+ }
780
+ return errors
781
+ }
782
+
783
+ test('should print an expected failure as its message alone', async () => {
784
+ wireFailingCommand(new PikkuError('Refusing to run — the seed drifted.'))
785
+
786
+ const errors = await captureStderr(() =>
787
+ executeCLI({
788
+ programName: 'test-cli',
789
+ args: ['boom'],
790
+ createSingletonServices: async () => singletonServices,
791
+ })
792
+ )
793
+
794
+ assert.deepStrictEqual(errors, ['Refusing to run — the seed drifted.'])
795
+ })
796
+
797
+ test('should keep the stack of an unexpected failure', async () => {
798
+ wireFailingCommand(new TypeError('cannot read x of undefined'))
799
+
800
+ const errors = await captureStderr(() =>
801
+ executeCLI({
802
+ programName: 'test-cli',
803
+ args: ['boom'],
804
+ createSingletonServices: async () => singletonServices,
805
+ })
806
+ )
807
+
808
+ assert.strictEqual(errors.length, 1)
809
+ assert.ok(errors[0]!.startsWith('TypeError: cannot read x of undefined'))
810
+ assert.ok(errors[0]!.includes('at '))
811
+ assert.ok(!errors[0]!.includes('Error: Error:'))
812
+ })
813
+
814
+ test('should add the stack of an expected failure when --verbose is passed', async () => {
815
+ wireFailingCommand(new PikkuError('Refusing to run.'))
816
+
817
+ const errors = await captureStderr(() =>
818
+ executeCLI({
819
+ programName: 'test-cli',
820
+ args: ['boom', '--verbose'],
821
+ createSingletonServices: async () => singletonServices,
822
+ })
823
+ )
824
+
825
+ // `--verbose` is not an option this command declares, so the parser
826
+ // warns about it first — the trace is whatever it printed last.
827
+ const printed = errors.at(-1)!
828
+ assert.ok(printed.startsWith('Refusing to run.\n'))
829
+ assert.ok(printed.includes('at '))
830
+ })
727
831
  })
728
832
  })
@@ -1,5 +1,4 @@
1
1
  import { NotFoundError } from '../../errors/errors.js'
2
- import { isExpectedError } from '../../errors/error-handler.js'
3
2
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
4
3
  import { pikkuState } from '../../pikku-state.js'
5
4
  import type { CoreUserSession } from '../../types/core.types.js'
@@ -30,6 +29,7 @@ import {
30
29
  } from '../../services/user-session-service.js'
31
30
  import { LocalVariablesService } from '../../services/local-variables.js'
32
31
  import { generateCommandHelp, parseCLIArguments } from './command-parser.js'
32
+ import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
33
33
 
34
34
  /** The caller is expected to catch this and call `process.exit(exitCode)`. */
35
35
  export class CLIError extends Error {
@@ -532,16 +532,7 @@ export async function executeCLI({
532
532
  throw error
533
533
  }
534
534
 
535
- // An expected PikkuError's message is written to be the whole output.
536
- if (isExpectedError(error)) {
537
- console.error(error.message)
538
- } else {
539
- console.error('Error:', error)
540
- }
541
-
542
- if (args.includes('--verbose') || args.includes('-v')) {
543
- console.error('Stack trace:', error.stack)
544
- }
535
+ console.error(formatCLIError(error, { verbose: wantsStackTrace(args) }))
545
536
 
546
537
  throw new CLIError(error.message || String(error), 1)
547
538
  }
@@ -0,0 +1,91 @@
1
+ import { test, describe } from 'node:test'
2
+ import * as assert from 'assert'
3
+ import { PikkuError } from '../../errors/error-handler.js'
4
+ import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
5
+
6
+ describe('formatCLIError', () => {
7
+ test('prints an expected error as its message alone', () => {
8
+ const error = new PikkuError(
9
+ "Persona 'guest' missing guest. Refusing to run."
10
+ )
11
+ assert.strictEqual(
12
+ formatCLIError(error),
13
+ "Persona 'guest' missing guest. Refusing to run."
14
+ )
15
+ })
16
+
17
+ test('prints an error flagged as expected as its message alone', () => {
18
+ const error = Object.assign(new Error('token expired'), { expected: true })
19
+ assert.strictEqual(formatCLIError(error), 'token expired')
20
+ })
21
+
22
+ test('keeps the stack for an unexpected error', () => {
23
+ const error = new TypeError('cannot read properties of undefined')
24
+ const output = formatCLIError(error)
25
+ assert.ok(output.includes('TypeError: cannot read properties of undefined'))
26
+ assert.ok(output.includes('at '))
27
+ })
28
+
29
+ test('does not double the error name', () => {
30
+ const output = formatCLIError(new Error('boom'))
31
+ assert.ok(!output.includes('Error: Error:'))
32
+ assert.ok(output.startsWith('Error: boom'))
33
+ })
34
+
35
+ test('adds the stack to an expected error when verbose', () => {
36
+ const error = new PikkuError('nope')
37
+ const output = formatCLIError(error, { verbose: true })
38
+ assert.ok(output.startsWith('nope\n'))
39
+ assert.ok(output.includes('at '))
40
+ })
41
+
42
+ test('summarises a fetch failure without inspecting the response', () => {
43
+ const error = Object.assign(new Error('Bad Gateway'), {
44
+ status: 502,
45
+ statusText: 'Bad Gateway',
46
+ response: {
47
+ url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus',
48
+ headers: { forbidden: 'do not print me' },
49
+ body: 'a stream',
50
+ },
51
+ })
52
+ assert.strictEqual(
53
+ formatCLIError(error),
54
+ '502 Bad Gateway from https://api.pikkufabric.com/rpc/getDeploymentStatus'
55
+ )
56
+ })
57
+
58
+ test('keeps a fetch failure message that says more than the status', () => {
59
+ const error = Object.assign(new Error('Deployment not found'), {
60
+ status: 404,
61
+ statusText: 'Not Found',
62
+ response: { url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus' },
63
+ })
64
+ assert.strictEqual(
65
+ formatCLIError(error),
66
+ 'Deployment not found\n' +
67
+ ' 404 Not Found from https://api.pikkufabric.com/rpc/getDeploymentStatus'
68
+ )
69
+ })
70
+
71
+ test('falls back to a string for a thrown non-error', () => {
72
+ assert.strictEqual(formatCLIError('just a string'), 'just a string')
73
+ })
74
+ })
75
+
76
+ describe('wantsStackTrace', () => {
77
+ test('is off by default', () => {
78
+ assert.strictEqual(wantsStackTrace(['deploy'], {}), false)
79
+ })
80
+
81
+ test('honours --verbose and -v', () => {
82
+ assert.strictEqual(wantsStackTrace(['deploy', '--verbose'], {}), true)
83
+ assert.strictEqual(wantsStackTrace(['deploy', '-v'], {}), true)
84
+ })
85
+
86
+ test('honours PIKKU_DEBUG, but not when it is switched off', () => {
87
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '1' }), true)
88
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '0' }), false)
89
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '' }), false)
90
+ })
91
+ })