@raidiant/notifai 3.0.1 → 4.0.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/CHANGELOG.md +27 -0
- package/README.md +7 -4
- package/dist/commands.d.ts +17 -9
- package/dist/commands.js +261 -100
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +9 -9
- package/dist/config-schema.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks.d.ts +12 -7
- package/dist/hooks.js +26 -46
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/main.js +46 -38
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +32 -7
- package/dist/send.js +77 -32
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +325 -21
- package/src/commands.ts +354 -136
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +9 -9
- package/src/config.test.ts +144 -16
- package/src/config.ts +1 -1
- package/src/hooks.test.ts +143 -38
- package/src/hooks.ts +67 -73
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/main.ts +72 -41
- package/src/send.ts +116 -38
- package/src/skill.test.ts +31 -7
package/src/commands.test.ts
CHANGED
|
@@ -388,6 +388,117 @@ describe('command contracts', () => {
|
|
|
388
388
|
expect(io.errLines.join('\n')).toContain('project')
|
|
389
389
|
})
|
|
390
390
|
|
|
391
|
+
it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
|
|
392
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
|
|
393
|
+
const io = new CapturedIo()
|
|
394
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
395
|
+
const client = {
|
|
396
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
397
|
+
submitted = body
|
|
398
|
+
return receipt
|
|
399
|
+
},
|
|
400
|
+
} as unknown as ApiClient
|
|
401
|
+
const deps = {
|
|
402
|
+
...makeDeps(io, client),
|
|
403
|
+
cwd,
|
|
404
|
+
env: {
|
|
405
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
406
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
407
|
+
CLAUDECODE: '1',
|
|
408
|
+
CLAUDE_CODE_SESSION_ID: 'opaque-claude-session-42',
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
expect(
|
|
413
|
+
await sendCommand(deps, {
|
|
414
|
+
title: 'All checks passed',
|
|
415
|
+
body: '**42 checks** passed.',
|
|
416
|
+
kind: 'done',
|
|
417
|
+
}),
|
|
418
|
+
).toBe(EXIT.ok)
|
|
419
|
+
|
|
420
|
+
expect(submitted?.draft.project).toMatch(/^notifai-project-/)
|
|
421
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
422
|
+
session_id: 'opaque-claude-session-42',
|
|
423
|
+
harness: 'claude-code',
|
|
424
|
+
})
|
|
425
|
+
expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
426
|
+
expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
427
|
+
expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('uploads repeatable images in order and sends only canonical media references', async () => {
|
|
431
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
|
|
432
|
+
const first = path.join(root, 'first.png')
|
|
433
|
+
const second = path.join(root, 'second.gif')
|
|
434
|
+
writeFileSync(first, 'first image')
|
|
435
|
+
writeFileSync(second, 'second image')
|
|
436
|
+
const uploaded: string[] = []
|
|
437
|
+
let grants = 0
|
|
438
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
439
|
+
const client = {
|
|
440
|
+
createMediaUpload: async () => {
|
|
441
|
+
grants += 1
|
|
442
|
+
return {
|
|
443
|
+
media_id: `med_uploaded_${grants}`,
|
|
444
|
+
upload_url: `https://upload.invalid/${grants}`,
|
|
445
|
+
upload_headers: {},
|
|
446
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
uploadMedia: async (grant: { media_id: string }) => {
|
|
450
|
+
uploaded.push(grant.media_id)
|
|
451
|
+
},
|
|
452
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
453
|
+
submitted = body
|
|
454
|
+
return receipt
|
|
455
|
+
},
|
|
456
|
+
} as unknown as ApiClient
|
|
457
|
+
|
|
458
|
+
expect(
|
|
459
|
+
await sendCommand(makeDeps(new CapturedIo(), client), {
|
|
460
|
+
title: 'Visual comparison is ready',
|
|
461
|
+
body:
|
|
462
|
+
'  ',
|
|
463
|
+
image: [first, second, 'med_attached_only'],
|
|
464
|
+
imageAlt: ['First state', 'Second state'],
|
|
465
|
+
}),
|
|
466
|
+
).toBe(EXIT.ok)
|
|
467
|
+
|
|
468
|
+
expect(uploaded).toEqual(['med_uploaded_1', 'med_uploaded_2'])
|
|
469
|
+
expect(submitted?.draft.presentation.media).toEqual([
|
|
470
|
+
{ media_id: 'med_uploaded_1', alt: 'First state' },
|
|
471
|
+
{ media_id: 'med_uploaded_2', alt: 'Second state' },
|
|
472
|
+
{ media_id: 'med_attached_only' },
|
|
473
|
+
])
|
|
474
|
+
expect(submitted?.draft.presentation.body).toBe(
|
|
475
|
+
'  ',
|
|
476
|
+
)
|
|
477
|
+
expect(submitted?.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('rejects media cardinality and alt pairing before any upload', async () => {
|
|
481
|
+
const io = new CapturedIo()
|
|
482
|
+
let uploads = 0
|
|
483
|
+
const client = {
|
|
484
|
+
createMediaUpload: async () => {
|
|
485
|
+
uploads += 1
|
|
486
|
+
throw new Error('must not upload')
|
|
487
|
+
},
|
|
488
|
+
} as unknown as ApiClient
|
|
489
|
+
|
|
490
|
+
expect(
|
|
491
|
+
await sendCommand(makeDeps(io, client), {
|
|
492
|
+
title: 'Visual comparison',
|
|
493
|
+
body: 'See it.',
|
|
494
|
+
image: ['one.png'],
|
|
495
|
+
imageAlt: ['one', 'two'],
|
|
496
|
+
}),
|
|
497
|
+
).toBe(EXIT.usage)
|
|
498
|
+
expect(uploads).toBe(0)
|
|
499
|
+
expect(io.errLines.join(' ')).toContain('2 --image-alt')
|
|
500
|
+
})
|
|
501
|
+
|
|
391
502
|
it('rejects done plus reply before authentication or submission', async () => {
|
|
392
503
|
const io = new CapturedIo()
|
|
393
504
|
let submitCalls = 0
|
|
@@ -415,17 +526,13 @@ describe('command contracts', () => {
|
|
|
415
526
|
flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
416
527
|
warning: /titles work best around 40/i,
|
|
417
528
|
},
|
|
418
|
-
{
|
|
419
|
-
flags: { title: 'Details', body: '**bold Markdown**' },
|
|
420
|
-
warning: /--body looks like Markdown.*--detail/i,
|
|
421
|
-
},
|
|
422
529
|
{
|
|
423
530
|
flags: { title: 'Done · build', body: 'All green.' },
|
|
424
|
-
warning: /
|
|
531
|
+
warning: /keep the title to the specific substance/i,
|
|
425
532
|
},
|
|
426
533
|
{
|
|
427
534
|
flags: { title: 'Failed · build', body: 'One integration test failed.' },
|
|
428
|
-
warning: /
|
|
535
|
+
warning: /Put notification type in --kind/i,
|
|
429
536
|
},
|
|
430
537
|
{
|
|
431
538
|
flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
@@ -456,14 +563,27 @@ describe('command contracts', () => {
|
|
|
456
563
|
expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
|
|
457
564
|
})
|
|
458
565
|
|
|
459
|
-
it('
|
|
566
|
+
it('keeps only the current public notification-authoring flags', () => {
|
|
460
567
|
const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
|
|
461
568
|
expect(source).not.toContain(".option('--badge")
|
|
462
569
|
expect(source).not.toContain(".option('--relevance")
|
|
463
570
|
expect(source).not.toContain(".option('--target-content-id")
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
571
|
+
const sendGrammar = source.slice(source.indexOf(".command('send')"), source.indexOf('send.addHelpText'))
|
|
572
|
+
const askGrammar = source.slice(source.indexOf(".command('ask [question]')"), source.indexOf(".command('close"))
|
|
573
|
+
for (const grammar of [sendGrammar, askGrammar]) {
|
|
574
|
+
expect(grammar).not.toContain(".option('--detail")
|
|
575
|
+
expect(grammar).not.toContain(".option('--session <")
|
|
576
|
+
expect(grammar).toContain(".option('--body-file")
|
|
577
|
+
expect(grammar).toContain(".option('--session-id")
|
|
578
|
+
expect(grammar).toContain(".option('--session-label")
|
|
579
|
+
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
580
|
+
expect(grammar).toContain("'--image-alt <text>'")
|
|
581
|
+
}
|
|
582
|
+
expect(source).toContain('Kind describes status in the Companion Apps')
|
|
583
|
+
expect(source).toContain('it never chooses banner sound or interruption level')
|
|
584
|
+
expect(source).toContain('override saved sound')
|
|
585
|
+
expect(source).not.toContain('Kind profiles apply automatically')
|
|
586
|
+
expect(source).not.toContain('sound alert level active')
|
|
467
587
|
})
|
|
468
588
|
|
|
469
589
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -3732,6 +3852,74 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3732
3852
|
expect(io.outLines).toContain(
|
|
3733
3853
|
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
3734
3854
|
)
|
|
3855
|
+
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
3856
|
+
session_id: 'codex-current-thread',
|
|
3857
|
+
harness: 'codex',
|
|
3858
|
+
})
|
|
3859
|
+
expect(
|
|
3860
|
+
readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
|
|
3861
|
+
).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
3862
|
+
})
|
|
3863
|
+
|
|
3864
|
+
it('uploads ask images before registration and freezes canonical body media', async () => {
|
|
3865
|
+
const cwd = scratchDir('notifai-ask-media-')
|
|
3866
|
+
const first = path.join(cwd, 'first.png')
|
|
3867
|
+
const second = path.join(cwd, 'second.gif')
|
|
3868
|
+
writeFileSync(first, 'first')
|
|
3869
|
+
writeFileSync(second, 'second')
|
|
3870
|
+
const io = new CapturedIo()
|
|
3871
|
+
const env = {
|
|
3872
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3873
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3874
|
+
HOME: path.join(cwd, 'home'),
|
|
3875
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3876
|
+
CODEX_THREAD_ID: 'codex-media-thread',
|
|
3877
|
+
}
|
|
3878
|
+
let grant = 0
|
|
3879
|
+
const uploaded: string[] = []
|
|
3880
|
+
const client = {
|
|
3881
|
+
createMediaUpload: async () => {
|
|
3882
|
+
grant += 1
|
|
3883
|
+
return {
|
|
3884
|
+
media_id: `med_ask_${grant}`,
|
|
3885
|
+
upload_url: `https://upload.invalid/${grant}`,
|
|
3886
|
+
upload_headers: {},
|
|
3887
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
3888
|
+
}
|
|
3889
|
+
},
|
|
3890
|
+
uploadMedia: async (value: { media_id: string }) => {
|
|
3891
|
+
uploaded.push(value.media_id)
|
|
3892
|
+
},
|
|
3893
|
+
} as unknown as ApiClient
|
|
3894
|
+
const deps = { ...makeDeps(io, client), cwd, env, now: () => 42 }
|
|
3895
|
+
|
|
3896
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3897
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3898
|
+
writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3899
|
+
writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
|
|
3900
|
+
io.outLines = []
|
|
3901
|
+
|
|
3902
|
+
expect(
|
|
3903
|
+
await askCommand(deps, 'Which visual should I use?', {
|
|
3904
|
+
body: 'Compare  with .',
|
|
3905
|
+
image: [first, second],
|
|
3906
|
+
imageAlt: ['First option', 'Second option'],
|
|
3907
|
+
}),
|
|
3908
|
+
).toBe(EXIT.ok)
|
|
3909
|
+
|
|
3910
|
+
const pending = readSessionState('codex-media-thread', env).pending?.[0]
|
|
3911
|
+
expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
|
|
3912
|
+
expect(pending?.body).toBe(
|
|
3913
|
+
'Which visual should I use?\n\nCompare  with .',
|
|
3914
|
+
)
|
|
3915
|
+
expect(pending?.media).toEqual([
|
|
3916
|
+
{ media_id: 'med_ask_1', alt: 'First option' },
|
|
3917
|
+
{ media_id: 'med_ask_2', alt: 'Second option' },
|
|
3918
|
+
])
|
|
3919
|
+
expect(pending?.source).toMatchObject({
|
|
3920
|
+
session_id: 'codex-media-thread',
|
|
3921
|
+
harness: 'codex',
|
|
3922
|
+
})
|
|
3735
3923
|
})
|
|
3736
3924
|
|
|
3737
3925
|
// A harness exports its markers into everything it starts, so a nested
|
|
@@ -3763,7 +3951,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3763
3951
|
io.outLines = []
|
|
3764
3952
|
|
|
3765
3953
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3766
|
-
expect(readSessionState('codex-current-thread', env).pending?.[0]
|
|
3954
|
+
expect(readSessionState('codex-current-thread', env).pending?.[0]).toMatchObject({
|
|
3955
|
+
question: 'Ship it?',
|
|
3956
|
+
source: { session_id: 'codex-current-thread', harness: 'codex' },
|
|
3957
|
+
})
|
|
3767
3958
|
expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
|
|
3768
3959
|
})
|
|
3769
3960
|
|
|
@@ -3792,7 +3983,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3792
3983
|
io.outLines = []
|
|
3793
3984
|
|
|
3794
3985
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3795
|
-
expect(readSessionState('claude-current', env).pending?.[0]
|
|
3986
|
+
expect(readSessionState('claude-current', env).pending?.[0]).toMatchObject({
|
|
3987
|
+
question: 'Ship it?',
|
|
3988
|
+
source: { session_id: 'claude-current', harness: 'claude-code' },
|
|
3989
|
+
})
|
|
3796
3990
|
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
3797
3991
|
})
|
|
3798
3992
|
|
|
@@ -4566,6 +4760,66 @@ describe('a server behind this CLI', () => {
|
|
|
4566
4760
|
})
|
|
4567
4761
|
})
|
|
4568
4762
|
|
|
4763
|
+
/**
|
|
4764
|
+
* The same class of failure through the command path. A rejected `send` printed
|
|
4765
|
+
* only "invalid_request: The draft was not accepted." while the server's
|
|
4766
|
+
* details — naming the exact offending field — went to the local log alone.
|
|
4767
|
+
* stderr is the one line an agent's next turn actually reads, so the diagnosis
|
|
4768
|
+
* has to be on it. Direction-neutral on purpose: the mismatch has been seen
|
|
4769
|
+
* with the server behind the CLI and with the CLI behind the server.
|
|
4770
|
+
*/
|
|
4771
|
+
describe('command failures carrying server details', () => {
|
|
4772
|
+
const throwingClient = (status: number, details: unknown = null): ApiClient =>
|
|
4773
|
+
({
|
|
4774
|
+
submit: async () => {
|
|
4775
|
+
throw new ApiCallError(status, 'invalid_request', 'The draft was not accepted.', null, details)
|
|
4776
|
+
},
|
|
4777
|
+
}) as unknown as ApiClient
|
|
4778
|
+
|
|
4779
|
+
it('names the rejected field and the contract mismatch on a 422', async () => {
|
|
4780
|
+
const io = new CapturedIo()
|
|
4781
|
+
const client = throwingClient(422, [
|
|
4782
|
+
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4783
|
+
])
|
|
4784
|
+
|
|
4785
|
+
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4786
|
+
EXIT.failed,
|
|
4787
|
+
)
|
|
4788
|
+
|
|
4789
|
+
const said = io.errLines.join(' ')
|
|
4790
|
+
expect(said).toContain('the server rejected: presentation.detail')
|
|
4791
|
+
expect(said).toMatch(/sent a field the server did not accept/)
|
|
4792
|
+
expect(said).toContain('notifai doctor')
|
|
4793
|
+
expect(said).not.toMatch(/older than this CLI/)
|
|
4794
|
+
})
|
|
4795
|
+
|
|
4796
|
+
it('prints the rejected paths without the contract line when the status is not 422', async () => {
|
|
4797
|
+
const io = new CapturedIo()
|
|
4798
|
+
const client = throwingClient(400, [
|
|
4799
|
+
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4800
|
+
])
|
|
4801
|
+
|
|
4802
|
+
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4803
|
+
EXIT.failed,
|
|
4804
|
+
)
|
|
4805
|
+
|
|
4806
|
+
const said = io.errLines.join(' ')
|
|
4807
|
+
expect(said).toContain('the server rejected: presentation.detail')
|
|
4808
|
+
expect(said).not.toContain('notifai doctor')
|
|
4809
|
+
expect(said).not.toMatch(/did not accept/)
|
|
4810
|
+
})
|
|
4811
|
+
|
|
4812
|
+
it('prints exactly the code and message when the error carries no details', async () => {
|
|
4813
|
+
const io = new CapturedIo()
|
|
4814
|
+
|
|
4815
|
+
expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4816
|
+
EXIT.failed,
|
|
4817
|
+
)
|
|
4818
|
+
|
|
4819
|
+
expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
|
|
4820
|
+
})
|
|
4821
|
+
})
|
|
4822
|
+
|
|
4569
4823
|
describe('question sets', () => {
|
|
4570
4824
|
it('maps --reply-multi into the single question', async () => {
|
|
4571
4825
|
const io = new CapturedIo()
|
|
@@ -4595,26 +4849,76 @@ describe('question sets', () => {
|
|
|
4595
4849
|
expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
|
|
4596
4850
|
})
|
|
4597
4851
|
|
|
4598
|
-
it('
|
|
4852
|
+
it('derives the answerable question from the first banner block', async () => {
|
|
4599
4853
|
const io = new CapturedIo()
|
|
4600
|
-
let
|
|
4854
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
4601
4855
|
const client = {
|
|
4602
|
-
submit: async () => {
|
|
4603
|
-
|
|
4856
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
4857
|
+
submitted = body
|
|
4604
4858
|
return receipt
|
|
4605
4859
|
},
|
|
4860
|
+
replies: async () => replyResponse([reply]),
|
|
4606
4861
|
} as unknown as ApiClient
|
|
4607
4862
|
|
|
4863
|
+
const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
|
|
4608
4864
|
expect(
|
|
4609
4865
|
await sendCommand(makeDeps(io, client), {
|
|
4610
|
-
title: '
|
|
4611
|
-
body
|
|
4866
|
+
title: 'Choose the deployment environment',
|
|
4867
|
+
body,
|
|
4612
4868
|
reply: true,
|
|
4613
4869
|
replyTimeout: 30,
|
|
4614
4870
|
}),
|
|
4615
|
-
).toBe(EXIT.
|
|
4616
|
-
expect(
|
|
4617
|
-
expect(
|
|
4871
|
+
).toBe(EXIT.ok)
|
|
4872
|
+
expect(submitted?.draft.presentation.body).toBe(body)
|
|
4873
|
+
expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
|
|
4874
|
+
})
|
|
4875
|
+
|
|
4876
|
+
it('composes one question before optional Markdown context', () => {
|
|
4877
|
+
expect(
|
|
4878
|
+
buildQuestions(
|
|
4879
|
+
{ body: '## Why\nThe release window closes today.' },
|
|
4880
|
+
'Which environment?',
|
|
4881
|
+
),
|
|
4882
|
+
).toMatchObject({
|
|
4883
|
+
ok: true,
|
|
4884
|
+
body: 'Which environment?\n\n## Why\nThe release window closes today.',
|
|
4885
|
+
questions: [{ text: 'Which environment?' }],
|
|
4886
|
+
})
|
|
4887
|
+
})
|
|
4888
|
+
|
|
4889
|
+
it('composes numbered form questions before form context', () => {
|
|
4890
|
+
expect(
|
|
4891
|
+
buildQuestions(
|
|
4892
|
+
{
|
|
4893
|
+
form: JSON.stringify({
|
|
4894
|
+
questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }],
|
|
4895
|
+
body: '## Context\nTraffic is elevated.',
|
|
4896
|
+
}),
|
|
4897
|
+
},
|
|
4898
|
+
undefined,
|
|
4899
|
+
),
|
|
4900
|
+
).toMatchObject({
|
|
4901
|
+
ok: true,
|
|
4902
|
+
body:
|
|
4903
|
+
'1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
|
|
4904
|
+
})
|
|
4905
|
+
})
|
|
4906
|
+
|
|
4907
|
+
it('rejects the deleted form detail key instead of treating it as context', () => {
|
|
4908
|
+
expect(
|
|
4909
|
+
buildQuestions(
|
|
4910
|
+
{
|
|
4911
|
+
form: JSON.stringify({
|
|
4912
|
+
questions: [{ text: 'Deploy?' }],
|
|
4913
|
+
detail: 'legacy context',
|
|
4914
|
+
}),
|
|
4915
|
+
},
|
|
4916
|
+
undefined,
|
|
4917
|
+
),
|
|
4918
|
+
).toMatchObject({
|
|
4919
|
+
ok: false,
|
|
4920
|
+
error: expect.stringContaining('Unknown --form key: detail'),
|
|
4921
|
+
})
|
|
4618
4922
|
})
|
|
4619
4923
|
|
|
4620
4924
|
it('generates unique question ids when texts collide', () => {
|