@indianic/dialout 1.12.0 → 1.13.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/README.md +4 -3
- package/dist/agent-restart.d.ts +46 -0
- package/dist/agent-restart.d.ts.map +1 -0
- package/dist/agent-restart.js +254 -0
- package/dist/agent-restart.js.map +1 -0
- package/dist/ai-sessions.d.ts +19 -0
- package/dist/ai-sessions.d.ts.map +1 -1
- package/dist/ai-sessions.js +25 -3
- package/dist/ai-sessions.js.map +1 -1
- package/dist/checklist.js +1 -1
- package/dist/checklist.js.map +1 -1
- package/dist/cli-client.d.ts +8 -0
- package/dist/cli-client.d.ts.map +1 -1
- package/dist/cli-client.js +33 -0
- package/dist/cli-client.js.map +1 -1
- package/dist/cli.js +106 -82
- package/dist/cli.js.map +1 -1
- package/dist/cowork.js +1 -1
- package/dist/has-command.d.ts +13 -0
- package/dist/has-command.d.ts.map +1 -1
- package/dist/has-command.js +92 -0
- package/dist/has-command.js.map +1 -1
- package/dist/ssh-local-network.js +1 -1
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +46 -14
- package/dist/update-check.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +3 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +22 -2
- package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts +129 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.js +302 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/notify-template.test.js +285 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +9 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.js +27 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts +20 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.js +20 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/session-announce.test.js +30 -0
- package/node_modules/@indianic/dialout-shared/src/index.ts +37 -1
- package/node_modules/@indianic/dialout-shared/src/notify-template.test.ts +352 -0
- package/node_modules/@indianic/dialout-shared/src/notify-template.ts +319 -0
- package/node_modules/@indianic/dialout-shared/src/notify.ts +51 -0
- package/node_modules/@indianic/dialout-shared/src/session-announce.test.ts +48 -0
- package/node_modules/@indianic/dialout-shared/src/session-announce.ts +40 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -9
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a tmux session the agent just reported is news.
|
|
3
|
+
*
|
|
4
|
+
* The bug this exists to kill: the daemon's close handler used to set
|
|
5
|
+
* `ended_at` on every session, so a reconnect made the registry's UPDATE miss,
|
|
6
|
+
* take the INSERT branch, and push "New terminal" for every terminal that was
|
|
7
|
+
* already open. Task 2 stops writing `ended_at` there; this is the second
|
|
8
|
+
* line of defence, for a session the vanish-pass legitimately ended and that
|
|
9
|
+
* then came back.
|
|
10
|
+
*/
|
|
11
|
+
export interface PriorSession {
|
|
12
|
+
/** Null while the session is live. */
|
|
13
|
+
endedAt: string | null;
|
|
14
|
+
/** When tmux created the session, as the agent reported it. */
|
|
15
|
+
createdLocal: string | null;
|
|
16
|
+
/** When a push for this row went out, or null if none ever did. */
|
|
17
|
+
notifiedAt: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function shouldAnnounceSession(prior: PriorSession | null, createdLocal: string | null): boolean;
|
|
20
|
+
//# sourceMappingURL=session-announce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-announce.d.ts","sourceRoot":"","sources":["../src/session-announce.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B,sCAAsC;IACtC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,mEAAmE;IACnE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,YAAY,GAAG,IAAI,EAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,GAC1B,OAAO,CAeT"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/devdash-shared/src/session-announce.ts
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.shouldAnnounceSession = shouldAnnounceSession;
|
|
5
|
+
function shouldAnnounceSession(prior, createdLocal) {
|
|
6
|
+
// Never seen on this machine under this name: unambiguously new.
|
|
7
|
+
if (!prior)
|
|
8
|
+
return true;
|
|
9
|
+
// Both creation times known and different: tmux really did make a new
|
|
10
|
+
// session that happens to reuse the name.
|
|
11
|
+
if (prior.createdLocal && createdLocal && prior.createdLocal !== createdLocal)
|
|
12
|
+
return true;
|
|
13
|
+
// Everything else is the same session returning — including the case where
|
|
14
|
+
// either creation time is missing. The original push spec said a missed
|
|
15
|
+
// notification is worse than an extra one; for this trigger that is
|
|
16
|
+
// backwards, because the failure being fixed is a burst. The cost is that an
|
|
17
|
+
// agent too old to report a creation time cannot announce a genuinely new
|
|
18
|
+
// session that reuses an old name.
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-announce.test.d.ts","sourceRoot":"","sources":["../src/session-announce.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const session_announce_1 = require("./session-announce");
|
|
5
|
+
(0, vitest_1.describe)('shouldAnnounceSession', () => {
|
|
6
|
+
(0, vitest_1.it)('announces a session with no prior row at all', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)(null, '2026-10-29T09:00:00Z')).toBe(true);
|
|
8
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)(null, null)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
// The reported bug. The agent reconnects, the row was marked ended, and the
|
|
11
|
+
// same tmux session is re-reported — it is not news.
|
|
12
|
+
(0, vitest_1.it)('stays quiet when the prior row is the same session by creation time', () => {
|
|
13
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null }, '2026-10-29T07:00:00Z')).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
(0, vitest_1.it)('announces a genuinely new session that reuses a name', () => {
|
|
16
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' }, '2026-10-29T09:30:00Z')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
// Durable suppression: lastNewNotifiedAt is in memory, so a ws-server
|
|
19
|
+
// restart must not replay an announcement the database already recorded.
|
|
20
|
+
(0, vitest_1.it)('stays quiet when the prior row was already announced and is still live', () => {
|
|
21
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: null, createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' }, '2026-10-29T07:00:00Z')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
// The documented reversal of the original push spec. Without a creation time
|
|
24
|
+
// there is no way to tell a revival from a new session, and the complaint is
|
|
25
|
+
// bursts, so silence is the right direction here.
|
|
26
|
+
(0, vitest_1.it)('treats a missing creation time on either side as the same session', () => {
|
|
27
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: null, notifiedAt: null }, '2026-10-29T09:00:00Z')).toBe(false);
|
|
28
|
+
(0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null }, null)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -12,7 +12,19 @@ export { REPLAY_LIMIT } from './session';
|
|
|
12
12
|
|
|
13
13
|
export type { CommandSource, AiCommand, McpServerInfo, AiCapabilities } from './capabilities';
|
|
14
14
|
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
NOTIFY_COOLDOWN_MS,
|
|
17
|
+
shouldNotifyAi,
|
|
18
|
+
shouldNotifyPrompt,
|
|
19
|
+
shouldNotifyNewTerminal,
|
|
20
|
+
shouldNotifyNewAiSession,
|
|
21
|
+
type PushPrefs,
|
|
22
|
+
} from './notify';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
shouldAnnounceSession,
|
|
26
|
+
type PriorSession,
|
|
27
|
+
} from './session-announce';
|
|
16
28
|
|
|
17
29
|
export type { ToolItem, ChatBlock } from './chat-blocks';
|
|
18
30
|
export { groupEvents, toolGroupStartsOpen } from './chat-blocks';
|
|
@@ -56,3 +68,27 @@ export {
|
|
|
56
68
|
ATTACHMENT_DIR, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES,
|
|
57
69
|
composeWithAttachments, isImageName, safeAttachmentName, splitAttachments,
|
|
58
70
|
} from './attachments';
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
NOTIFICATION_TYPES,
|
|
74
|
+
ACTION_KINDS,
|
|
75
|
+
TEMPLATE_VARIABLES,
|
|
76
|
+
DEFAULT_TEMPLATES,
|
|
77
|
+
TITLE_MAX,
|
|
78
|
+
BODY_MAX,
|
|
79
|
+
MAX_ACTIONS,
|
|
80
|
+
categoryId,
|
|
81
|
+
renderNotification,
|
|
82
|
+
parseActions,
|
|
83
|
+
parseTemplatePatch,
|
|
84
|
+
isNotificationType,
|
|
85
|
+
shouldSendNotification,
|
|
86
|
+
folderLabel,
|
|
87
|
+
isSendable,
|
|
88
|
+
type NotificationType,
|
|
89
|
+
type ActionKind,
|
|
90
|
+
type NotificationAction,
|
|
91
|
+
type NotificationTemplate,
|
|
92
|
+
type TemplateVars,
|
|
93
|
+
type TemplatePatch,
|
|
94
|
+
} from './notify-template';
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// packages/devdash-shared/src/notify-template.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
renderNotification, parseActions, categoryId, DEFAULT_TEMPLATES,
|
|
5
|
+
TEMPLATE_VARIABLES, NOTIFICATION_TYPES, parseTemplatePatch, shouldSendNotification,
|
|
6
|
+
folderLabel, isSendable,
|
|
7
|
+
} from './notify-template';
|
|
8
|
+
|
|
9
|
+
describe('renderNotification', () => {
|
|
10
|
+
// The old hardcoded title chose between these two; a single template string
|
|
11
|
+
// cannot, so {what} carries it. Guards against "finished" reading as blocked.
|
|
12
|
+
it('default ai_question title keeps "needs you" distinct from "finished"', () => {
|
|
13
|
+
const t = DEFAULT_TEMPLATES.ai_question;
|
|
14
|
+
expect(renderNotification(t, { agent: 'Claude', what: 'needs you' }, 'UTC').title).toBe('Claude needs you');
|
|
15
|
+
expect(renderNotification(t, { agent: 'Claude', what: 'finished' }, 'UTC').title).toBe('Claude finished');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// The old hardcoded body had a fallback for each missing half; the values
|
|
19
|
+
// now carry them (folder -> 'session'), so no case may render empty and a
|
|
20
|
+
// finished session must still name its CLI.
|
|
21
|
+
it('default ai_question body is never empty and always names the CLI', () => {
|
|
22
|
+
const t = DEFAULT_TEMPLATES.ai_question;
|
|
23
|
+
const body = (question: string, folder: string) =>
|
|
24
|
+
renderNotification(t, { agent: 'Claude', what: 'finished', kind: 'claude', question, folder }, 'UTC').body;
|
|
25
|
+
expect(body('Q', 'dialout')).toBe('Q · claude · dialout');
|
|
26
|
+
expect(body('Q', 'session')).toBe('Q · claude · session');
|
|
27
|
+
expect(body('', 'dialout')).toBe('claude · dialout');
|
|
28
|
+
expect(body('', 'session')).toBe('claude · session');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('substitutes the variables it is given', () => {
|
|
32
|
+
expect(renderNotification(
|
|
33
|
+
{ title: '{agent} needs you', body: '{question} · {folder}' },
|
|
34
|
+
{ agent: 'Claude', question: 'Run the migration?', folder: 'dialout' },
|
|
35
|
+
'UTC',
|
|
36
|
+
)).toEqual({ title: 'Claude needs you', body: 'Run the migration? · dialout' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A notification showing its own template syntax on a lock screen is worse
|
|
40
|
+
// than one with a gap in it.
|
|
41
|
+
it('renders an unknown or missing variable as empty, never as its own name', () => {
|
|
42
|
+
const r = renderNotification({ title: 'Hi {nobody}', body: '{alsoNobody}' }, {}, 'UTC');
|
|
43
|
+
expect(r.title).toBe('Hi');
|
|
44
|
+
expect(r.body).toBe('');
|
|
45
|
+
expect(r.title).not.toContain('{');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('treats a null or undefined value as missing', () => {
|
|
49
|
+
expect(renderNotification({ title: 'a{x}b', body: '' }, { x: null }, 'UTC').title).toBe('ab');
|
|
50
|
+
expect(renderNotification({ title: 'a{x}b', body: '' }, { x: undefined }, 'UTC').title).toBe('ab');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Emptying a variable otherwise strands the separator that was joining it to
|
|
54
|
+
// the next thing, and "Run it? · " reads as truncation.
|
|
55
|
+
it('collapses a separator left stranded by an empty variable', () => {
|
|
56
|
+
expect(renderNotification(
|
|
57
|
+
{ title: 'x', body: '{question} · {folder}' },
|
|
58
|
+
{ question: 'Run it?' },
|
|
59
|
+
'UTC',
|
|
60
|
+
).body).toBe('Run it?');
|
|
61
|
+
expect(renderNotification(
|
|
62
|
+
{ title: 'x', body: '{a} · {b} · {c}' },
|
|
63
|
+
{ b: 'middle' },
|
|
64
|
+
'UTC',
|
|
65
|
+
).body).toBe('middle');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('collapses runs of whitespace', () => {
|
|
69
|
+
expect(renderNotification({ title: 'a {x} b', body: '' }, {}, 'UTC').title).toBe('a b');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('formats time and date in the given timezone', () => {
|
|
73
|
+
const vars = { at: '2026-10-29T18:30:00Z' };
|
|
74
|
+
const utc = renderNotification({ title: '{time}', body: '{date}' }, vars, 'UTC');
|
|
75
|
+
const ist = renderNotification({ title: '{time}', body: '{date}' }, vars, 'Asia/Kolkata');
|
|
76
|
+
expect(utc.title).not.toBe(ist.title);
|
|
77
|
+
expect(ist.title).toMatch(/\d/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// An unknown zone throws inside Intl, and this runs on the notify path.
|
|
81
|
+
it('falls back to UTC rather than throwing on an unknown timezone', () => {
|
|
82
|
+
expect(() => renderNotification(
|
|
83
|
+
{ title: '{time}', body: '' }, { at: '2026-10-29T18:30:00Z' }, 'Mars/Olympus',
|
|
84
|
+
)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('caps the title at 80 and the body at 240', () => {
|
|
88
|
+
const r = renderNotification({ title: '{x}', body: '{x}' }, { x: 'y'.repeat(500) }, 'UTC');
|
|
89
|
+
expect(r.title).toHaveLength(80);
|
|
90
|
+
expect(r.body).toHaveLength(240);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('never throws on a template that is not a string', () => {
|
|
94
|
+
expect(() => renderNotification(
|
|
95
|
+
{ title: undefined as unknown as string, body: null as unknown as string }, {}, 'UTC',
|
|
96
|
+
)).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('renderNotification: names that were never supplied', () => {
|
|
100
|
+
it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])(
|
|
101
|
+
'renders {%s} as nothing',
|
|
102
|
+
(name) => {
|
|
103
|
+
expect(renderNotification({ title: `a{${name}}b`, body: `{${name}}` }, {}, 'UTC'))
|
|
104
|
+
.toEqual({ title: 'ab', body: '' });
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it('renders a supplied value that is not a string or number as nothing, without throwing', () => {
|
|
109
|
+
const vars = { x: { nested: 1 }, y: ['a'], z: true } as unknown as Record<string, string>;
|
|
110
|
+
expect(renderNotification({ title: 'a{x}b{y}c{z}d', body: '' }, vars, 'UTC').title).toBe('abcd');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still substitutes a supplied number', () => {
|
|
114
|
+
const vars = { n: 3 } as unknown as Record<string, string>;
|
|
115
|
+
expect(renderNotification({ title: 'n={n}', body: '' }, vars, 'UTC').title).toBe('n=3');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// These guard the marker indirection specifically (surviving placeholders are
|
|
120
|
+
// swapped for private-use markers while the skeleton is tidied). They look
|
|
121
|
+
// redundant next to the verbatim-value tests; do not delete them as such.
|
|
122
|
+
describe('renderNotification: marker indirection', () => {
|
|
123
|
+
const E0 = '';
|
|
124
|
+
const E1 = '';
|
|
125
|
+
|
|
126
|
+
it('renders values when the template itself contains marker characters, and emits none', () => {
|
|
127
|
+
const r = renderNotification(
|
|
128
|
+
{ title: `a${E0}{x}${E1}b${E0}0${E1}`, body: `${E0}{y}` }, { x: 'X', y: 'Y' }, 'UTC',
|
|
129
|
+
);
|
|
130
|
+
expect(r.title).toBe('aXb0');
|
|
131
|
+
expect(r.body).toBe('Y');
|
|
132
|
+
expect(r.title + r.body).not.toMatch(/[]/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('renders marker-shaped text inside a value literally, without re-scanning it', () => {
|
|
136
|
+
const lookalike = `${E0}1${E1}`;
|
|
137
|
+
const r = renderNotification({ title: '', body: '{a} {b}' }, { a: lookalike, b: 'B' }, 'UTC');
|
|
138
|
+
expect(r.body).toBe(`${lookalike} B`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('keeps twelve placeholders in their own slots, including adjacent ones', () => {
|
|
142
|
+
const names = 'abcdefghijkl'.split('');
|
|
143
|
+
const vars = Object.fromEntries(names.map((n, i) => [n, `v${i}`]));
|
|
144
|
+
const template = '{a}{b} {c} {d} {e} {f} {g} {h} {i} {j} {k} {l}';
|
|
145
|
+
const r = renderNotification({ title: '', body: template }, vars, 'UTC');
|
|
146
|
+
expect(r.body).toBe('v0v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('substitutes values verbatim, including surrounding whitespace and newlines', () => {
|
|
150
|
+
const r = renderNotification({ title: '', body: '[{x}]' }, { x: ' a\nb ' }, 'UTC');
|
|
151
|
+
expect(r.body).toBe('[ a\nb ]');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('renderNotification: vars that are not an object', () => {
|
|
156
|
+
it.each([null, undefined, 'str', 42, true, ['a']])('does not throw for vars = %j', (v) => {
|
|
157
|
+
const r = renderNotification({ title: 'a{question}b', body: '{time}' }, v as never, 'UTC');
|
|
158
|
+
expect(r.title).toBe('ab');
|
|
159
|
+
expect(r.body).toMatch(/^\d{2}:\d{2}$/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('renderNotification: a value is never tidied', () => {
|
|
164
|
+
it.each([
|
|
165
|
+
'Should I run git push --force?',
|
|
166
|
+
'Use the --no-verify flag',
|
|
167
|
+
'Run cmd1 || cmd2 ?',
|
|
168
|
+
'-rf flag is set',
|
|
169
|
+
'ends with a dash -',
|
|
170
|
+
' spaced out ',
|
|
171
|
+
'a · · b',
|
|
172
|
+
])('keeps %j character for character', (q) => {
|
|
173
|
+
expect(renderNotification({ title: '', body: '{question}' }, { question: q }, 'UTC').body).toBe(q);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not let a value inject a placeholder', () => {
|
|
177
|
+
expect(renderNotification({ title: '', body: '{a}' }, { a: '{b}', b: 'no' }, 'UTC').body).toBe('{b}');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('still tidies the skeleton around a verbatim value', () => {
|
|
181
|
+
expect(renderNotification({ title: '', body: '{question} · {folder}' }, { question: 'git push --force?' }, 'UTC').body)
|
|
182
|
+
.toBe('git push --force?');
|
|
183
|
+
expect(renderNotification({ title: '', body: '{folder} · {question}' }, { question: '-rf' }, 'UTC').body)
|
|
184
|
+
.toBe('-rf');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('DEFAULT_TEMPLATES', () => {
|
|
190
|
+
it('covers every type', () => {
|
|
191
|
+
for (const t of NOTIFICATION_TYPES) expect(DEFAULT_TEMPLATES[t]).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A template may only use variables its own type supplies, or it renders a
|
|
195
|
+
// gap on every send.
|
|
196
|
+
it('only uses variables its own type offers', () => {
|
|
197
|
+
for (const t of NOTIFICATION_TYPES) {
|
|
198
|
+
const used = `${DEFAULT_TEMPLATES[t].title} ${DEFAULT_TEMPLATES[t].body}`
|
|
199
|
+
.match(/\{(\w+)\}/g)?.map((m) => m.slice(1, -1)) ?? [];
|
|
200
|
+
for (const v of used) expect(TEMPLATE_VARIABLES[t], `${t} uses {${v}}`).toContain(v);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('categoryId', () => {
|
|
206
|
+
// SDK 57: a category identifier may contain no ':' or '-', and a hyphen
|
|
207
|
+
// silently costs the action buttons. ai_question must land on exactly the
|
|
208
|
+
// string AI_CATEGORY already holds, so notifications in flight during the
|
|
209
|
+
// deploy keep their Reply button.
|
|
210
|
+
it('strips underscores and hyphens', () => {
|
|
211
|
+
expect(categoryId('ai_question')).toBe('aiquestion');
|
|
212
|
+
expect(categoryId('new_terminal')).toBe('newterminal');
|
|
213
|
+
expect(categoryId('new_ai_session')).toBe('newaisession');
|
|
214
|
+
expect(categoryId('announcement')).toBe('announcement');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('never produces an id with a character the SDK rejects', () => {
|
|
218
|
+
for (const t of NOTIFICATION_TYPES) {
|
|
219
|
+
expect(categoryId(t)).not.toMatch(/[:-]/);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('parseActions', () => {
|
|
225
|
+
it('keeps well-formed actions', () => {
|
|
226
|
+
expect(parseActions([{ id: 'reply', label: 'Reply', kind: 'reply' }]))
|
|
227
|
+
.toEqual([{ id: 'reply', label: 'Reply', kind: 'reply' }]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('parses a JSON string, which is what the jsonb column may hand back', () => {
|
|
231
|
+
expect(parseActions('[{"id":"open","label":"Open","kind":"open"}]'))
|
|
232
|
+
.toEqual([{ id: 'open', label: 'Open', kind: 'open' }]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// kind is a closed set so a button cannot be configured to do something the
|
|
236
|
+
// app has no code for.
|
|
237
|
+
it('drops an action with an unknown kind', () => {
|
|
238
|
+
expect(parseActions([{ id: 'x', label: 'X', kind: 'launchMissiles' }])).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('drops an action missing an id or a label', () => {
|
|
242
|
+
expect(parseActions([{ label: 'X', kind: 'open' }, { id: 'y', kind: 'open' }])).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns an empty array for anything unusable', () => {
|
|
246
|
+
for (const bad of [null, undefined, 42, 'not json', {}]) {
|
|
247
|
+
expect(parseActions(bad)).toEqual([]);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('caps the list at four, which is what iOS shows', () => {
|
|
252
|
+
const many = Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A', kind: 'open' }));
|
|
253
|
+
expect(parseActions(many)).toHaveLength(4);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('drops a duplicate id, which would collide in the response handler', () => {
|
|
257
|
+
expect(parseActions([
|
|
258
|
+
{ id: 'open', label: 'Open', kind: 'open' },
|
|
259
|
+
{ id: 'open', label: 'Again', kind: 'open' },
|
|
260
|
+
])).toHaveLength(1);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('shouldSendNotification', () => {
|
|
265
|
+
// The feature's central rule, and the one most likely to be re-implemented
|
|
266
|
+
// wrongly at a third call site. The repo has no route tests — the convention
|
|
267
|
+
// is pure-logic tests in src/lib/__tests__ — so this is where the gate is
|
|
268
|
+
// pinned.
|
|
269
|
+
it('needs both gates to say yes', () => {
|
|
270
|
+
expect(shouldSendNotification(true, true)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('sends nothing when the admin has switched the type off', () => {
|
|
274
|
+
expect(shouldSendNotification(false, true)).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('sends nothing to a device whose owner opted out', () => {
|
|
278
|
+
expect(shouldSendNotification(true, false)).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('is false when neither says yes', () => {
|
|
282
|
+
expect(shouldSendNotification(false, false)).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('parseTemplatePatch', () => {
|
|
287
|
+
it('accepts a partial patch', () => {
|
|
288
|
+
const r = parseTemplatePatch({ enabled: false, title: ' Hi ' });
|
|
289
|
+
expect(r).toEqual({ ok: true, patch: { enabled: false, title: 'Hi' } });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('rejects a body that is not an object', () => {
|
|
293
|
+
expect(parseTemplatePatch(null).ok).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('caps title and body rather than rejecting them', () => {
|
|
297
|
+
const r = parseTemplatePatch({ title: 'x'.repeat(300), body: 'y'.repeat(900) });
|
|
298
|
+
expect(r.ok).toBe(true);
|
|
299
|
+
if (r.ok) {
|
|
300
|
+
expect(r.patch.title).toHaveLength(80);
|
|
301
|
+
expect(r.patch.body).toHaveLength(240);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('normalises actions through parseActions', () => {
|
|
306
|
+
const r = parseTemplatePatch({ actions: [{ id: 'x', label: 'X', kind: 'nope' }] });
|
|
307
|
+
expect(r.ok).toBe(true);
|
|
308
|
+
if (r.ok) expect(r.patch.actions).toEqual([]);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// The push this wording replaced named a terminal by its folder and fell back
|
|
313
|
+
// to the tmux name. For a browser session the tmux name is dd-<uuid>, so a
|
|
314
|
+
// default built on {terminal} would turn "dialout" into an opaque id.
|
|
315
|
+
describe('default wording for a new session', () => {
|
|
316
|
+
const tz = 'UTC';
|
|
317
|
+
|
|
318
|
+
it('new_terminal names the folder, not the tmux name', () => {
|
|
319
|
+
const r = renderNotification(DEFAULT_TEMPLATES.new_terminal, {
|
|
320
|
+
terminal: 'dd-7f3a9c2b4e', folder: 'dialout', machine: 'MacBook Pro',
|
|
321
|
+
}, tz);
|
|
322
|
+
expect(r.body).toBe('dialout on MacBook Pro');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('new_ai_session never leaves a gap when the sender has no folder', () => {
|
|
326
|
+
const vars = { agent: 'dd-ai-x', machine: 'MacBook Pro', folder: folderLabel(null, 'session') };
|
|
327
|
+
expect(renderNotification(DEFAULT_TEMPLATES.new_ai_session, vars, tz).body)
|
|
328
|
+
.toBe('dd-ai-x in session on MacBook Pro');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('folderLabel', () => {
|
|
333
|
+
it('uses the folder when there is one', () => {
|
|
334
|
+
expect(folderLabel('dialout', 'dd-abc')).toBe('dialout');
|
|
335
|
+
});
|
|
336
|
+
it('falls back for null, undefined, empty and whitespace', () => {
|
|
337
|
+
for (const f of [null, undefined, '', ' ']) expect(folderLabel(f, 'dd-abc')).toBe('dd-abc');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// A cleared title or body is a deliberate choice, so the sender has to decide
|
|
342
|
+
// what an empty one means rather than discovering it on a lock screen.
|
|
343
|
+
describe('isSendable', () => {
|
|
344
|
+
it('sends a notification with only a body or only a title', () => {
|
|
345
|
+
expect(isSendable({ title: '', body: 'Run it?' })).toBe(true);
|
|
346
|
+
expect(isSendable({ title: 'Agent needs you', body: '' })).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
it('does not send one with neither', () => {
|
|
349
|
+
expect(isSendable({ title: '', body: '' })).toBe(false);
|
|
350
|
+
expect(isSendable({ title: ' ', body: ' ' })).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
});
|