@company-semantics/contracts 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/auth/index.ts +1 -1
- package/src/ci-envelope/__tests__/transitions.test.ts +82 -0
- package/src/email/__tests__/registry.test.ts +115 -0
- package/src/execution/__tests__/events.test.ts +55 -0
- package/src/execution/__tests__/registry.test.ts +244 -0
- package/src/identity/__tests__/avatar.test.ts +75 -0
- package/src/index.ts +1 -0
- package/src/message-parts/__tests__/builder.test.ts +181 -0
- package/src/message-parts/__tests__/confirmation.test.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./src/ralph/index.ts",
|
|
41
41
|
"default": "./src/ralph/index.ts"
|
|
42
42
|
},
|
|
43
|
+
"./auth": {
|
|
44
|
+
"types": "./src/auth/index.ts",
|
|
45
|
+
"default": "./src/auth/index.ts"
|
|
46
|
+
},
|
|
43
47
|
"./chat": {
|
|
44
48
|
"types": "./src/chat/index.ts",
|
|
45
49
|
"default": "./src/chat/index.ts"
|
package/src/auth/index.ts
CHANGED
|
@@ -22,4 +22,4 @@ export type AuthStartMode = "otp" | "sso" | "hybrid";
|
|
|
22
22
|
export type AuthStartResponse =
|
|
23
23
|
| { mode: "otp"; devOtp?: string }
|
|
24
24
|
| { mode: "sso"; providers: string[]; redirectUrl: string }
|
|
25
|
-
| { mode: "hybrid"; providers: string[]; recommendedProvider?: string; otpAllowed: true; devOtp?: string };
|
|
25
|
+
| { mode: "hybrid"; providers: string[]; redirectUrl: string; recommendedProvider?: string; otpAllowed: true; devOtp?: string };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { ENVELOPE_TRANSITIONS } from '../types.js'
|
|
3
|
+
import type { CIEnvelopeStatus } from '../types.js'
|
|
4
|
+
|
|
5
|
+
const ALL_STATUSES: CIEnvelopeStatus[] = [
|
|
6
|
+
'proposed',
|
|
7
|
+
'validated',
|
|
8
|
+
'blocked',
|
|
9
|
+
'executing',
|
|
10
|
+
'finalized',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('ENVELOPE_TRANSITIONS terminal states', () => {
|
|
14
|
+
it('blocked is terminal (empty transition array)', () => {
|
|
15
|
+
expect(ENVELOPE_TRANSITIONS['blocked']).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('finalized is terminal (empty transition array)', () => {
|
|
19
|
+
expect(ENVELOPE_TRANSITIONS['finalized']).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('only blocked and finalized are terminal', () => {
|
|
23
|
+
const nonTerminal = ALL_STATUSES.filter(
|
|
24
|
+
(s) => s !== 'blocked' && s !== 'finalized'
|
|
25
|
+
)
|
|
26
|
+
for (const state of nonTerminal) {
|
|
27
|
+
expect(ENVELOPE_TRANSITIONS[state].length).toBeGreaterThan(0)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('ENVELOPE_TRANSITIONS state machine', () => {
|
|
33
|
+
it('proposed can transition to validated and blocked', () => {
|
|
34
|
+
expect(ENVELOPE_TRANSITIONS['proposed']).toContain('validated')
|
|
35
|
+
expect(ENVELOPE_TRANSITIONS['proposed']).toContain('blocked')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('validated can transition to executing and blocked', () => {
|
|
39
|
+
expect(ENVELOPE_TRANSITIONS['validated']).toContain('executing')
|
|
40
|
+
expect(ENVELOPE_TRANSITIONS['validated']).toContain('blocked')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('executing can transition to finalized and blocked', () => {
|
|
44
|
+
expect(ENVELOPE_TRANSITIONS['executing']).toContain('finalized')
|
|
45
|
+
expect(ENVELOPE_TRANSITIONS['executing']).toContain('blocked')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('proposed cannot transition to executing or finalized directly', () => {
|
|
49
|
+
expect(ENVELOPE_TRANSITIONS['proposed']).not.toContain('executing')
|
|
50
|
+
expect(ENVELOPE_TRANSITIONS['proposed']).not.toContain('finalized')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('ENVELOPE_TRANSITIONS exhaustiveness', () => {
|
|
55
|
+
it('has entries for all 5 CIEnvelopeStatus values', () => {
|
|
56
|
+
for (const status of ALL_STATUSES) {
|
|
57
|
+
expect(ENVELOPE_TRANSITIONS).toHaveProperty(status)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('every transition target is a valid CIEnvelopeStatus', () => {
|
|
62
|
+
for (const state of ALL_STATUSES) {
|
|
63
|
+
for (const target of ENVELOPE_TRANSITIONS[state]) {
|
|
64
|
+
expect(ALL_STATUSES).toContain(target)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('ENVELOPE_TRANSITIONS forward progression', () => {
|
|
71
|
+
it('no state can transition to proposed (initial state only)', () => {
|
|
72
|
+
for (const state of ALL_STATUSES) {
|
|
73
|
+
expect(ENVELOPE_TRANSITIONS[state]).not.toContain('proposed')
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('no state can transition to itself (no self-loops)', () => {
|
|
78
|
+
for (const state of ALL_STATUSES) {
|
|
79
|
+
expect(ENVELOPE_TRANSITIONS[state]).not.toContain(state)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { EMAIL_KINDS, getEmailKindDefinition, isValidEmailKind } from '../registry.js'
|
|
3
|
+
|
|
4
|
+
describe('EMAIL_KINDS golden snapshot', () => {
|
|
5
|
+
it('exact values are frozen', () => {
|
|
6
|
+
expect(EMAIL_KINDS).toStrictEqual({
|
|
7
|
+
'auth.otp': {
|
|
8
|
+
kind: 'auth.otp',
|
|
9
|
+
subject: 'Your login code',
|
|
10
|
+
plainTextRequired: true,
|
|
11
|
+
htmlSupported: false,
|
|
12
|
+
},
|
|
13
|
+
'auth.magic_link': {
|
|
14
|
+
kind: 'auth.magic_link',
|
|
15
|
+
subject: 'Your login link',
|
|
16
|
+
plainTextRequired: true,
|
|
17
|
+
htmlSupported: false,
|
|
18
|
+
},
|
|
19
|
+
'org.invite': {
|
|
20
|
+
kind: 'org.invite',
|
|
21
|
+
subject: 'You have been invited to join a workspace',
|
|
22
|
+
plainTextRequired: true,
|
|
23
|
+
htmlSupported: true,
|
|
24
|
+
},
|
|
25
|
+
'security.alert': {
|
|
26
|
+
kind: 'security.alert',
|
|
27
|
+
subject: 'Security alert for your account',
|
|
28
|
+
plainTextRequired: true,
|
|
29
|
+
htmlSupported: false,
|
|
30
|
+
},
|
|
31
|
+
'chat.shared': {
|
|
32
|
+
kind: 'chat.shared',
|
|
33
|
+
subject: 'A chat has been shared with you',
|
|
34
|
+
plainTextRequired: true,
|
|
35
|
+
htmlSupported: true,
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('EMAIL_KINDS registry invariants', () => {
|
|
42
|
+
it('every registry key matches its definition.kind field', () => {
|
|
43
|
+
for (const [key, def] of Object.entries(EMAIL_KINDS)) {
|
|
44
|
+
expect(def.kind).toBe(key)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('all subjects are non-empty strings', () => {
|
|
49
|
+
for (const def of Object.values(EMAIL_KINDS)) {
|
|
50
|
+
expect(typeof def.subject).toBe('string')
|
|
51
|
+
expect(def.subject.length).toBeGreaterThan(0)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('all entries have required boolean fields plainTextRequired and htmlSupported', () => {
|
|
56
|
+
for (const def of Object.values(EMAIL_KINDS)) {
|
|
57
|
+
expect(typeof def.plainTextRequired).toBe('boolean')
|
|
58
|
+
expect(typeof def.htmlSupported).toBe('boolean')
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('getEmailKindDefinition', () => {
|
|
64
|
+
it('returns correct definition for auth.otp', () => {
|
|
65
|
+
const def = getEmailKindDefinition('auth.otp')
|
|
66
|
+
expect(def).toStrictEqual(EMAIL_KINDS['auth.otp'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns correct definition for auth.magic_link', () => {
|
|
70
|
+
const def = getEmailKindDefinition('auth.magic_link')
|
|
71
|
+
expect(def).toStrictEqual(EMAIL_KINDS['auth.magic_link'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns correct definition for org.invite', () => {
|
|
75
|
+
const def = getEmailKindDefinition('org.invite')
|
|
76
|
+
expect(def).toStrictEqual(EMAIL_KINDS['org.invite'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns correct definition for security.alert', () => {
|
|
80
|
+
const def = getEmailKindDefinition('security.alert')
|
|
81
|
+
expect(def).toStrictEqual(EMAIL_KINDS['security.alert'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns correct definition for chat.shared', () => {
|
|
85
|
+
const def = getEmailKindDefinition('chat.shared')
|
|
86
|
+
expect(def).toStrictEqual(EMAIL_KINDS['chat.shared'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('return value matches registry entry exactly', () => {
|
|
90
|
+
for (const [key, expected] of Object.entries(EMAIL_KINDS)) {
|
|
91
|
+
const def = getEmailKindDefinition(key as keyof typeof EMAIL_KINDS)
|
|
92
|
+
expect(def).toBe(expected)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('isValidEmailKind', () => {
|
|
98
|
+
it('returns true for all 5 valid email kinds', () => {
|
|
99
|
+
for (const kind of Object.keys(EMAIL_KINDS)) {
|
|
100
|
+
expect(isValidEmailKind(kind)).toBe(true)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns false for unknown string', () => {
|
|
105
|
+
expect(isValidEmailKind('unknown.kind')).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns false for empty string', () => {
|
|
109
|
+
expect(isValidEmailKind('')).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns false for partial match', () => {
|
|
113
|
+
expect(isValidEmailKind('auth')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { EXECUTION_EVENT_TYPES, isExecutionEventType } from '../events.js'
|
|
3
|
+
|
|
4
|
+
describe('EXECUTION_EVENT_TYPES', () => {
|
|
5
|
+
it('contains exactly 10 entries', () => {
|
|
6
|
+
expect(EXECUTION_EVENT_TYPES).toHaveLength(10)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('contains all expected event types', () => {
|
|
10
|
+
const expected = [
|
|
11
|
+
'execution_created',
|
|
12
|
+
'execution_started',
|
|
13
|
+
'confirmation_approved',
|
|
14
|
+
'confirmation_rejected',
|
|
15
|
+
'confirmation_expired',
|
|
16
|
+
'approval_requested',
|
|
17
|
+
'execution_completed',
|
|
18
|
+
'execution_failed',
|
|
19
|
+
'undo_completed',
|
|
20
|
+
'cancelled',
|
|
21
|
+
]
|
|
22
|
+
for (const evt of expected) {
|
|
23
|
+
expect(EXECUTION_EVENT_TYPES).toContain(evt)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('has no duplicates', () => {
|
|
28
|
+
const unique = new Set(EXECUTION_EVENT_TYPES)
|
|
29
|
+
expect(unique.size).toBe(EXECUTION_EVENT_TYPES.length)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('isExecutionEventType', () => {
|
|
34
|
+
it('returns true for every member of EXECUTION_EVENT_TYPES', () => {
|
|
35
|
+
for (const evt of EXECUTION_EVENT_TYPES) {
|
|
36
|
+
expect(isExecutionEventType(evt)).toBe(true)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns false for empty string', () => {
|
|
41
|
+
expect(isExecutionEventType('')).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false for unknown string', () => {
|
|
45
|
+
expect(isExecutionEventType('not_an_event')).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns false for partial match', () => {
|
|
49
|
+
expect(isExecutionEventType('execution')).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns false for similar but wrong string', () => {
|
|
53
|
+
expect(isExecutionEventType('execution_create')).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
EXECUTION_KINDS,
|
|
4
|
+
getExecutionKindDefinition,
|
|
5
|
+
isValidExecutionKind,
|
|
6
|
+
} from '../registry.js'
|
|
7
|
+
|
|
8
|
+
describe('EXECUTION_KINDS golden snapshot', () => {
|
|
9
|
+
it('exact values are frozen', () => {
|
|
10
|
+
expect(EXECUTION_KINDS).toStrictEqual({
|
|
11
|
+
'integration.connect': {
|
|
12
|
+
kind: 'integration.connect',
|
|
13
|
+
domain: 'integration',
|
|
14
|
+
display: {
|
|
15
|
+
label: 'Connect Slack',
|
|
16
|
+
pastTenseLabel: 'Slack connected',
|
|
17
|
+
icon: 'plug',
|
|
18
|
+
},
|
|
19
|
+
governance: {
|
|
20
|
+
visibility: 'admin',
|
|
21
|
+
requiresAdmin: true,
|
|
22
|
+
reversibleBy: 'integration.disconnect',
|
|
23
|
+
},
|
|
24
|
+
ui: {
|
|
25
|
+
showInAdmin: true,
|
|
26
|
+
showInTimeline: true,
|
|
27
|
+
confirmBeforeRun: false,
|
|
28
|
+
},
|
|
29
|
+
explanation: {
|
|
30
|
+
templateId: 'integration.connect',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
'integration.disconnect': {
|
|
34
|
+
kind: 'integration.disconnect',
|
|
35
|
+
domain: 'integration',
|
|
36
|
+
display: {
|
|
37
|
+
label: 'Disconnect Slack',
|
|
38
|
+
pastTenseLabel: 'Slack disconnected',
|
|
39
|
+
icon: 'unlink',
|
|
40
|
+
},
|
|
41
|
+
governance: {
|
|
42
|
+
visibility: 'admin',
|
|
43
|
+
requiresAdmin: true,
|
|
44
|
+
},
|
|
45
|
+
ui: {
|
|
46
|
+
showInAdmin: true,
|
|
47
|
+
showInTimeline: true,
|
|
48
|
+
confirmBeforeRun: true,
|
|
49
|
+
},
|
|
50
|
+
explanation: {
|
|
51
|
+
templateId: 'integration.disconnect',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
'profile.update': {
|
|
55
|
+
kind: 'profile.update',
|
|
56
|
+
domain: 'profile',
|
|
57
|
+
display: {
|
|
58
|
+
label: 'Update Profile',
|
|
59
|
+
pastTenseLabel: 'Profile updated',
|
|
60
|
+
icon: 'pencil',
|
|
61
|
+
},
|
|
62
|
+
governance: {
|
|
63
|
+
visibility: 'user',
|
|
64
|
+
requiresAdmin: false,
|
|
65
|
+
},
|
|
66
|
+
ui: {
|
|
67
|
+
showInAdmin: false,
|
|
68
|
+
showInTimeline: true,
|
|
69
|
+
confirmBeforeRun: true,
|
|
70
|
+
},
|
|
71
|
+
explanation: {
|
|
72
|
+
templateId: 'profile.update',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
'slack.send': {
|
|
76
|
+
kind: 'slack.send',
|
|
77
|
+
domain: 'communication',
|
|
78
|
+
display: {
|
|
79
|
+
label: 'Send Slack Message',
|
|
80
|
+
pastTenseLabel: 'Slack message sent',
|
|
81
|
+
icon: 'send',
|
|
82
|
+
},
|
|
83
|
+
governance: {
|
|
84
|
+
visibility: 'user',
|
|
85
|
+
requiresAdmin: false,
|
|
86
|
+
},
|
|
87
|
+
ui: {
|
|
88
|
+
showInAdmin: false,
|
|
89
|
+
showInTimeline: true,
|
|
90
|
+
confirmBeforeRun: true,
|
|
91
|
+
},
|
|
92
|
+
explanation: {
|
|
93
|
+
templateId: 'slack.send',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
'data.ingest': {
|
|
97
|
+
kind: 'data.ingest',
|
|
98
|
+
domain: 'data',
|
|
99
|
+
display: {
|
|
100
|
+
label: 'Import Channel',
|
|
101
|
+
pastTenseLabel: 'Channel imported',
|
|
102
|
+
icon: 'send',
|
|
103
|
+
},
|
|
104
|
+
governance: {
|
|
105
|
+
visibility: 'user',
|
|
106
|
+
requiresAdmin: false,
|
|
107
|
+
},
|
|
108
|
+
ui: {
|
|
109
|
+
showInAdmin: true,
|
|
110
|
+
showInTimeline: true,
|
|
111
|
+
confirmBeforeRun: true,
|
|
112
|
+
},
|
|
113
|
+
explanation: {
|
|
114
|
+
templateId: 'data-ingest',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
'data.scope': {
|
|
118
|
+
kind: 'data.scope',
|
|
119
|
+
domain: 'data',
|
|
120
|
+
display: {
|
|
121
|
+
label: 'Update Channel Scope',
|
|
122
|
+
pastTenseLabel: 'Channel scope updated',
|
|
123
|
+
icon: 'pencil',
|
|
124
|
+
},
|
|
125
|
+
governance: {
|
|
126
|
+
visibility: 'admin',
|
|
127
|
+
requiresAdmin: true,
|
|
128
|
+
},
|
|
129
|
+
ui: {
|
|
130
|
+
showInAdmin: true,
|
|
131
|
+
showInTimeline: true,
|
|
132
|
+
confirmBeforeRun: true,
|
|
133
|
+
},
|
|
134
|
+
explanation: {
|
|
135
|
+
templateId: 'data-scope',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
'system.cleanup': {
|
|
139
|
+
kind: 'system.cleanup',
|
|
140
|
+
domain: 'system',
|
|
141
|
+
display: {
|
|
142
|
+
label: 'Cleanup Connections',
|
|
143
|
+
pastTenseLabel: 'Connections cleaned up',
|
|
144
|
+
icon: 'unlink',
|
|
145
|
+
},
|
|
146
|
+
governance: {
|
|
147
|
+
visibility: 'admin',
|
|
148
|
+
requiresAdmin: true,
|
|
149
|
+
},
|
|
150
|
+
ui: {
|
|
151
|
+
showInAdmin: true,
|
|
152
|
+
showInTimeline: false,
|
|
153
|
+
confirmBeforeRun: true,
|
|
154
|
+
},
|
|
155
|
+
explanation: {
|
|
156
|
+
templateId: 'system-cleanup',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('registry structural invariants', () => {
|
|
164
|
+
it('every registry key matches its definition.kind field', () => {
|
|
165
|
+
for (const [key, def] of Object.entries(EXECUTION_KINDS)) {
|
|
166
|
+
expect(def.kind).toBe(key)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('all entries have required display fields as non-empty strings', () => {
|
|
171
|
+
for (const [, def] of Object.entries(EXECUTION_KINDS)) {
|
|
172
|
+
expect(typeof def.display.label).toBe('string')
|
|
173
|
+
expect(def.display.label.length).toBeGreaterThan(0)
|
|
174
|
+
expect(typeof def.display.pastTenseLabel).toBe('string')
|
|
175
|
+
expect(def.display.pastTenseLabel.length).toBeGreaterThan(0)
|
|
176
|
+
expect(typeof def.display.icon).toBe('string')
|
|
177
|
+
expect(def.display.icon.length).toBeGreaterThan(0)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('all entries have required governance fields', () => {
|
|
182
|
+
for (const [, def] of Object.entries(EXECUTION_KINDS)) {
|
|
183
|
+
expect(typeof def.governance.visibility).toBe('string')
|
|
184
|
+
expect(typeof def.governance.requiresAdmin).toBe('boolean')
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('all entries have required ui fields as booleans', () => {
|
|
189
|
+
for (const [, def] of Object.entries(EXECUTION_KINDS)) {
|
|
190
|
+
expect(typeof def.ui.showInAdmin).toBe('boolean')
|
|
191
|
+
expect(typeof def.ui.showInTimeline).toBe('boolean')
|
|
192
|
+
expect(typeof def.ui.confirmBeforeRun).toBe('boolean')
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('admin-required kinds have visibility=admin', () => {
|
|
197
|
+
for (const [, def] of Object.entries(EXECUTION_KINDS)) {
|
|
198
|
+
if (def.governance.requiresAdmin) {
|
|
199
|
+
expect(def.governance.visibility).toBe('admin')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('getExecutionKindDefinition', () => {
|
|
206
|
+
it('returns correct definition for each of the 7 kinds', () => {
|
|
207
|
+
const kinds = Object.keys(EXECUTION_KINDS) as Array<
|
|
208
|
+
keyof typeof EXECUTION_KINDS
|
|
209
|
+
>
|
|
210
|
+
for (const kind of kinds) {
|
|
211
|
+
const def = getExecutionKindDefinition(kind)
|
|
212
|
+
expect(def.kind).toBe(kind)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('return value matches registry entry (same reference)', () => {
|
|
217
|
+
const kinds = Object.keys(EXECUTION_KINDS) as Array<
|
|
218
|
+
keyof typeof EXECUTION_KINDS
|
|
219
|
+
>
|
|
220
|
+
for (const kind of kinds) {
|
|
221
|
+
expect(getExecutionKindDefinition(kind)).toBe(EXECUTION_KINDS[kind])
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('isValidExecutionKind', () => {
|
|
227
|
+
it('returns true for all 7 valid execution kinds', () => {
|
|
228
|
+
for (const kind of Object.keys(EXECUTION_KINDS)) {
|
|
229
|
+
expect(isValidExecutionKind(kind)).toBe(true)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('returns false for unknown string', () => {
|
|
234
|
+
expect(isValidExecutionKind('unknown.action')).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('returns false for empty string', () => {
|
|
238
|
+
expect(isValidExecutionKind('')).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('returns false for partial match', () => {
|
|
242
|
+
expect(isValidExecutionKind('integration')).toBe(false)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { generateInitials, resolveAvatar } from '../avatar.js'
|
|
3
|
+
|
|
4
|
+
describe('generateInitials', () => {
|
|
5
|
+
it('returns single uppercase initial for single-word name', () => {
|
|
6
|
+
expect(generateInitials('Madonna')).toBe('M')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns first+last uppercase initials for two-word name', () => {
|
|
10
|
+
expect(generateInitials('Ian Heidt')).toBe('IH')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns first+last initials, skipping middle for multi-word name', () => {
|
|
14
|
+
expect(generateInitials('Mary Jane Doe')).toBe('MD')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns empty string for empty input', () => {
|
|
18
|
+
expect(generateInitials('')).toBe('')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns empty string for whitespace-only input', () => {
|
|
22
|
+
expect(generateInitials(' ')).toBe('')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles name with extra whitespace', () => {
|
|
26
|
+
expect(generateInitials(' Ian Heidt ')).toBe('IH')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns single character for single character name', () => {
|
|
30
|
+
expect(generateInitials('X')).toBe('X')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns uppercase for lowercase input', () => {
|
|
34
|
+
expect(generateInitials('ian heidt')).toBe('IH')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('resolveAvatar', () => {
|
|
39
|
+
it('initials is always populated when source is slack (critical invariant)', () => {
|
|
40
|
+
const result = resolveAvatar({ slackAvatarUrl: 'https://example.com/img.jpg', fullName: 'Ian Heidt' })
|
|
41
|
+
expect(result.source).toBe('slack')
|
|
42
|
+
expect(result.url).toBe('https://example.com/img.jpg')
|
|
43
|
+
// INVARIANT: initials is ALWAYS populated, regardless of source.
|
|
44
|
+
// This prevents UI regressions when Slack avatars fail to load.
|
|
45
|
+
expect(typeof result.initials).toBe('string')
|
|
46
|
+
expect(result.initials).toBe('IH')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns initials source when no slackAvatarUrl', () => {
|
|
50
|
+
const result = resolveAvatar({ fullName: 'Ian Heidt' } as any)
|
|
51
|
+
expect(result.source).toBe('initials')
|
|
52
|
+
expect(result.initials).toBe('IH')
|
|
53
|
+
expect(result.url).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns slack source with empty initials when fullName is empty', () => {
|
|
57
|
+
const result = resolveAvatar({ slackAvatarUrl: 'https://example.com/img.jpg', fullName: '' })
|
|
58
|
+
expect(result.source).toBe('slack')
|
|
59
|
+
expect(result.url).toBe('https://example.com/img.jpg')
|
|
60
|
+
// Empty is valid — just not undefined
|
|
61
|
+
expect(result.initials).toBe('')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns initials source when slackAvatarUrl is explicitly undefined', () => {
|
|
65
|
+
const result = resolveAvatar({ slackAvatarUrl: undefined, fullName: 'Ian Heidt' })
|
|
66
|
+
expect(result.source).toBe('initials')
|
|
67
|
+
expect(result.initials).toBe('IH')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns initials source when slackAvatarUrl is empty string (falsy)', () => {
|
|
71
|
+
const result = resolveAvatar({ slackAvatarUrl: '', fullName: 'Ian Heidt' })
|
|
72
|
+
expect(result.source).toBe('initials')
|
|
73
|
+
expect(result.initials).toBe('IH')
|
|
74
|
+
})
|
|
75
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -121,6 +121,7 @@ export type { AvatarSource, ResolvedAvatar } from './identity/index'
|
|
|
121
121
|
export { generateInitials, resolveAvatar } from './identity/index'
|
|
122
122
|
|
|
123
123
|
// Auth domain types
|
|
124
|
+
export type { AuthStartMode, AuthStartResponse } from './auth/index'
|
|
124
125
|
export { OTPErrorCode } from './auth/index'
|
|
125
126
|
|
|
126
127
|
// Email domain types
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createPartBuilder,
|
|
4
|
+
addText,
|
|
5
|
+
addSurface,
|
|
6
|
+
addPart,
|
|
7
|
+
buildParts,
|
|
8
|
+
getDroppedCount,
|
|
9
|
+
} from '../builder.js'
|
|
10
|
+
import type { SurfacePart, TextPart } from '../types.js'
|
|
11
|
+
|
|
12
|
+
const mockSurface: SurfacePart = {
|
|
13
|
+
type: 'tool-list',
|
|
14
|
+
tools: [],
|
|
15
|
+
} as unknown as SurfacePart
|
|
16
|
+
|
|
17
|
+
const anotherSurface: SurfacePart = {
|
|
18
|
+
type: 'status-panel',
|
|
19
|
+
title: 'Status',
|
|
20
|
+
entries: [],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Builder Creation
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
describe('createPartBuilder', () => {
|
|
28
|
+
it('returns initial state with defaults', () => {
|
|
29
|
+
const state = createPartBuilder()
|
|
30
|
+
expect(state.parts).toEqual([])
|
|
31
|
+
expect(state.hasSurface).toBe(false)
|
|
32
|
+
expect(state.devMode).toBe(false)
|
|
33
|
+
expect(state.droppedCount).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns state with devMode=true when passed true', () => {
|
|
37
|
+
const state = createPartBuilder(true)
|
|
38
|
+
expect(state.devMode).toBe(true)
|
|
39
|
+
expect(state.parts).toEqual([])
|
|
40
|
+
expect(state.hasSurface).toBe(false)
|
|
41
|
+
expect(state.droppedCount).toBe(0)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// NarrativeState (before any surface)
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
describe('NarrativeState', () => {
|
|
50
|
+
it('addText is accepted and appends text part', () => {
|
|
51
|
+
const state = createPartBuilder()
|
|
52
|
+
const result = addText(state, 'hello')
|
|
53
|
+
expect(result.accepted).toBe(true)
|
|
54
|
+
expect(result.state.parts).toEqual([{ type: 'text', text: 'hello' }])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('multiple addText calls accumulate parts in order', () => {
|
|
58
|
+
let state = createPartBuilder()
|
|
59
|
+
state = addText(state, 'first').state
|
|
60
|
+
state = addText(state, 'second').state
|
|
61
|
+
state = addText(state, 'third').state
|
|
62
|
+
expect(state.parts).toEqual([
|
|
63
|
+
{ type: 'text', text: 'first' },
|
|
64
|
+
{ type: 'text', text: 'second' },
|
|
65
|
+
{ type: 'text', text: 'third' },
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('addSurface is accepted and transitions hasSurface to true', () => {
|
|
70
|
+
const state = createPartBuilder()
|
|
71
|
+
const result = addSurface(state, mockSurface)
|
|
72
|
+
expect(result.accepted).toBe(true)
|
|
73
|
+
expect(result.state.hasSurface).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// SurfaceState — THE CRITICAL INVARIANT
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
describe('SurfaceState (text after surface)', () => {
|
|
82
|
+
it('silently drops text in prod mode (devMode=false)', () => {
|
|
83
|
+
let state = createPartBuilder(false)
|
|
84
|
+
state = addSurface(state, mockSurface).state
|
|
85
|
+
const partsBefore = state.parts.length
|
|
86
|
+
|
|
87
|
+
const result = addText(state, 'dropped')
|
|
88
|
+
expect(result.accepted).toBe(false)
|
|
89
|
+
expect(result.state.droppedCount).toBe(1)
|
|
90
|
+
expect(result.state.parts.length).toBe(partsBefore)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('throws invariant violation in dev mode (devMode=true)', () => {
|
|
94
|
+
let state = createPartBuilder(true)
|
|
95
|
+
state = addSurface(state, mockSurface).state
|
|
96
|
+
|
|
97
|
+
expect(() => addText(state, 'illegal')).toThrow('invariant violation')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('addSurface after addSurface is always accepted', () => {
|
|
101
|
+
let state = createPartBuilder()
|
|
102
|
+
state = addSurface(state, mockSurface).state
|
|
103
|
+
const result = addSurface(state, anotherSurface)
|
|
104
|
+
expect(result.accepted).toBe(true)
|
|
105
|
+
expect(result.state.parts.length).toBe(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('droppedCount accumulates across multiple drops in prod mode', () => {
|
|
109
|
+
let state = createPartBuilder(false)
|
|
110
|
+
state = addSurface(state, mockSurface).state
|
|
111
|
+
|
|
112
|
+
state = addText(state, 'drop1').state
|
|
113
|
+
state = addText(state, 'drop2').state
|
|
114
|
+
state = addText(state, 'drop3').state
|
|
115
|
+
|
|
116
|
+
expect(state.droppedCount).toBe(3)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// addPart dispatch
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
describe('addPart', () => {
|
|
125
|
+
it('dispatches TextPart to addText behavior', () => {
|
|
126
|
+
const state = createPartBuilder()
|
|
127
|
+
const textPart: TextPart = { type: 'text', text: 'dispatched' }
|
|
128
|
+
const result = addPart(state, textPart)
|
|
129
|
+
expect(result.accepted).toBe(true)
|
|
130
|
+
expect(result.state.parts).toEqual([{ type: 'text', text: 'dispatched' }])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('dispatches SurfacePart to addSurface behavior', () => {
|
|
134
|
+
const state = createPartBuilder()
|
|
135
|
+
const result = addPart(state, mockSurface)
|
|
136
|
+
expect(result.accepted).toBe(true)
|
|
137
|
+
expect(result.state.hasSurface).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// =============================================================================
|
|
142
|
+
// Finalization
|
|
143
|
+
// =============================================================================
|
|
144
|
+
|
|
145
|
+
describe('buildParts', () => {
|
|
146
|
+
it('returns array copy — mutating result does not affect builder', () => {
|
|
147
|
+
let state = createPartBuilder()
|
|
148
|
+
state = addText(state, 'text1').state
|
|
149
|
+
state = addSurface(state, mockSurface).state
|
|
150
|
+
|
|
151
|
+
const parts = buildParts(state)
|
|
152
|
+
parts.push({ type: 'text', text: 'injected' })
|
|
153
|
+
|
|
154
|
+
expect(buildParts(state)).toHaveLength(2)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('preserves correct part ordering (text before surfaces)', () => {
|
|
158
|
+
let state = createPartBuilder()
|
|
159
|
+
state = addText(state, 'narrative').state
|
|
160
|
+
state = addSurface(state, mockSurface).state
|
|
161
|
+
|
|
162
|
+
const parts = buildParts(state)
|
|
163
|
+
expect(parts[0].type).toBe('text')
|
|
164
|
+
expect(parts[1].type).not.toBe('text')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('getDroppedCount', () => {
|
|
169
|
+
it('returns current droppedCount from state', () => {
|
|
170
|
+
let state = createPartBuilder(false)
|
|
171
|
+
state = addSurface(state, mockSurface).state
|
|
172
|
+
state = addText(state, 'dropped').state
|
|
173
|
+
|
|
174
|
+
expect(getDroppedCount(state)).toBe(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('returns 0 when no drops have occurred', () => {
|
|
178
|
+
const state = createPartBuilder()
|
|
179
|
+
expect(getDroppedCount(state)).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -71,6 +71,9 @@ describe('getConfirmationLabel', () => {
|
|
|
71
71
|
expect(getConfirmationLabel('integration.disconnect')).toBe('Disconnect Integration')
|
|
72
72
|
expect(getConfirmationLabel('profile.update')).toBe('Update Profile')
|
|
73
73
|
expect(getConfirmationLabel('slack.send')).toBe('Send Slack Message')
|
|
74
|
+
expect(getConfirmationLabel('data.ingest')).toBe('Import Channel')
|
|
75
|
+
expect(getConfirmationLabel('data.scope')).toBe('Update Scope')
|
|
76
|
+
expect(getConfirmationLabel('system.cleanup')).toBe('Cleanup Connections')
|
|
74
77
|
})
|
|
75
78
|
|
|
76
79
|
it('returns default fallback for undefined', () => {
|
|
@@ -92,4 +95,16 @@ describe('CONFIRMATION_LABELS', () => {
|
|
|
92
95
|
expect(CONFIRMATION_LABELS[kind].length).toBeGreaterThan(0)
|
|
93
96
|
}
|
|
94
97
|
})
|
|
98
|
+
|
|
99
|
+
it('values are exact and stable (golden snapshot)', () => {
|
|
100
|
+
expect(CONFIRMATION_LABELS).toStrictEqual({
|
|
101
|
+
'integration.connect': 'Connect Integration',
|
|
102
|
+
'integration.disconnect': 'Disconnect Integration',
|
|
103
|
+
'profile.update': 'Update Profile',
|
|
104
|
+
'slack.send': 'Send Slack Message',
|
|
105
|
+
'data.ingest': 'Import Channel',
|
|
106
|
+
'data.scope': 'Update Scope',
|
|
107
|
+
'system.cleanup': 'Cleanup Connections',
|
|
108
|
+
})
|
|
109
|
+
})
|
|
95
110
|
})
|