@raidiant/notifai 2.0.2 → 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 +48 -0
- package/README.md +7 -4
- package/dist/client.d.ts +2 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- 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/client.test.ts +46 -0
- package/src/client.ts +13 -0
- package/src/commands.test.ts +333 -37
- 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 +144 -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 () => {
|
|
@@ -2392,12 +2512,7 @@ describe('interactive command UX', () => {
|
|
|
2392
2512
|
const deps: CommandDeps = {
|
|
2393
2513
|
...makeDeps(io, client),
|
|
2394
2514
|
cwd,
|
|
2395
|
-
env:
|
|
2396
|
-
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2397
|
-
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2398
|
-
CODEX_HOME: path.join(cwd, 'codex'),
|
|
2399
|
-
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
2400
|
-
},
|
|
2515
|
+
env: isolatedEnv(cwd),
|
|
2401
2516
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
2402
2517
|
}
|
|
2403
2518
|
|
|
@@ -2993,19 +3108,16 @@ describe('init', () => {
|
|
|
2993
3108
|
const deps = {
|
|
2994
3109
|
...makeDeps(io, client),
|
|
2995
3110
|
cwd,
|
|
2996
|
-
env:
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
3002
|
-
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
|
|
3003
|
-
},
|
|
3111
|
+
env: isolatedEnv(cwd),
|
|
3112
|
+
nativeSkills: {
|
|
3113
|
+
list: async () => ({ skills: [] }),
|
|
3114
|
+
add: async () => 0,
|
|
3115
|
+
} satisfies NativeSkills,
|
|
3004
3116
|
}
|
|
3005
3117
|
|
|
3006
3118
|
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
3007
|
-
expect(asked.
|
|
3008
|
-
expect(asked.
|
|
3119
|
+
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/hooks/)]))
|
|
3120
|
+
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/skill/)]))
|
|
3009
3121
|
const out = io.outLines.join('\n')
|
|
3010
3122
|
expect(out).toContain('Next: Your devices')
|
|
3011
3123
|
// Device wait prompts only after optionals have been considered.
|
|
@@ -3740,6 +3852,74 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3740
3852
|
expect(io.outLines).toContain(
|
|
3741
3853
|
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
3742
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
|
+
})
|
|
3743
3923
|
})
|
|
3744
3924
|
|
|
3745
3925
|
// A harness exports its markers into everything it starts, so a nested
|
|
@@ -3771,7 +3951,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3771
3951
|
io.outLines = []
|
|
3772
3952
|
|
|
3773
3953
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3774
|
-
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
|
+
})
|
|
3775
3958
|
expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
|
|
3776
3959
|
})
|
|
3777
3960
|
|
|
@@ -3800,7 +3983,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3800
3983
|
io.outLines = []
|
|
3801
3984
|
|
|
3802
3985
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3803
|
-
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
|
+
})
|
|
3804
3990
|
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
3805
3991
|
})
|
|
3806
3992
|
|
|
@@ -4574,6 +4760,66 @@ describe('a server behind this CLI', () => {
|
|
|
4574
4760
|
})
|
|
4575
4761
|
})
|
|
4576
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
|
+
|
|
4577
4823
|
describe('question sets', () => {
|
|
4578
4824
|
it('maps --reply-multi into the single question', async () => {
|
|
4579
4825
|
const io = new CapturedIo()
|
|
@@ -4603,26 +4849,76 @@ describe('question sets', () => {
|
|
|
4603
4849
|
expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
|
|
4604
4850
|
})
|
|
4605
4851
|
|
|
4606
|
-
it('
|
|
4852
|
+
it('derives the answerable question from the first banner block', async () => {
|
|
4607
4853
|
const io = new CapturedIo()
|
|
4608
|
-
let
|
|
4854
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
4609
4855
|
const client = {
|
|
4610
|
-
submit: async () => {
|
|
4611
|
-
|
|
4856
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
4857
|
+
submitted = body
|
|
4612
4858
|
return receipt
|
|
4613
4859
|
},
|
|
4860
|
+
replies: async () => replyResponse([reply]),
|
|
4614
4861
|
} as unknown as ApiClient
|
|
4615
4862
|
|
|
4863
|
+
const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
|
|
4616
4864
|
expect(
|
|
4617
4865
|
await sendCommand(makeDeps(io, client), {
|
|
4618
|
-
title: '
|
|
4619
|
-
body
|
|
4866
|
+
title: 'Choose the deployment environment',
|
|
4867
|
+
body,
|
|
4620
4868
|
reply: true,
|
|
4621
4869
|
replyTimeout: 30,
|
|
4622
4870
|
}),
|
|
4623
|
-
).toBe(EXIT.
|
|
4624
|
-
expect(
|
|
4625
|
-
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
|
+
})
|
|
4626
4922
|
})
|
|
4627
4923
|
|
|
4628
4924
|
it('generates unique question ids when texts collide', () => {
|