@company-semantics/contracts 0.92.0 → 0.94.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 +1 -1
- package/src/chat/__tests__/runtime-profile.test.ts +55 -0
- 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__/lifecycle.test.ts +1 -1
- package/src/execution/__tests__/registry.test.ts +244 -0
- package/src/identity/__tests__/avatar.test.ts +75 -0
- package/src/message-parts/__tests__/builder.test.ts +181 -0
- package/src/message-parts/__tests__/confirmation.test.ts +15 -0
- package/src/message-parts/__tests__/wire.test.ts +127 -0
- package/src/org/__tests__/view-scopes.test.ts +47 -0
- package/src/ralph/__tests__/prd.test.ts +41 -0
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { CHAT_RUNTIME_PROFILES, DEFAULT_CHAT_RUNTIME_PROFILE } from '../runtime-profile.js'
|
|
3
|
+
|
|
4
|
+
describe('CHAT_RUNTIME_PROFILES golden snapshot', () => {
|
|
5
|
+
it('exact values are frozen', () => {
|
|
6
|
+
expect(CHAT_RUNTIME_PROFILES).toStrictEqual([
|
|
7
|
+
{ id: 'fast', label: 'Fast', description: 'Single-step, no tools', model: 'gpt-3.5-turbo' },
|
|
8
|
+
{ id: 'balanced', label: 'Balanced', description: 'Multi-step with tools', model: 'gpt-4o' },
|
|
9
|
+
{ id: 'agentic', label: 'Agentic', description: 'Agent loop, full reasoning', model: 'claude-sonnet-4-20250514' },
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('CHAT_RUNTIME_PROFILES invariants', () => {
|
|
15
|
+
it('covers all ChatRuntimeProfile values', () => {
|
|
16
|
+
const ids = CHAT_RUNTIME_PROFILES.map(p => p.id)
|
|
17
|
+
expect(ids).toContain('fast')
|
|
18
|
+
expect(ids).toContain('balanced')
|
|
19
|
+
expect(ids).toContain('agentic')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('has no duplicate profile IDs', () => {
|
|
23
|
+
const ids = CHAT_RUNTIME_PROFILES.map(p => p.id)
|
|
24
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('all entries have non-empty label and description', () => {
|
|
28
|
+
for (const profile of CHAT_RUNTIME_PROFILES) {
|
|
29
|
+
expect(profile.label.length).toBeGreaterThan(0)
|
|
30
|
+
expect(profile.description.length).toBeGreaterThan(0)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('all entries have non-empty model strings', () => {
|
|
35
|
+
for (const profile of CHAT_RUNTIME_PROFILES) {
|
|
36
|
+
expect(profile.model.length).toBeGreaterThan(0)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('DEFAULT_CHAT_RUNTIME_PROFILE', () => {
|
|
42
|
+
it('is agentic', () => {
|
|
43
|
+
expect(DEFAULT_CHAT_RUNTIME_PROFILE).toBe('agentic')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('exists in CHAT_RUNTIME_PROFILES', () => {
|
|
47
|
+
const ids = CHAT_RUNTIME_PROFILES.map(p => p.id)
|
|
48
|
+
expect(ids).toContain(DEFAULT_CHAT_RUNTIME_PROFILE)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is a valid ChatRuntimeProfile value', () => {
|
|
52
|
+
const validIds = CHAT_RUNTIME_PROFILES.map(p => p.id) as readonly string[]
|
|
53
|
+
expect(validIds).toContain(DEFAULT_CHAT_RUNTIME_PROFILE)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -172,7 +172,7 @@ describe('applyIntent', () => {
|
|
|
172
172
|
expect(applyIntent('undone', 'confirm')).toBe('conflict')
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
-
it('pending_confirmation + reject
|
|
175
|
+
it('pending_confirmation + reject resolves as approve (rejection uses same cancel path)', () => {
|
|
176
176
|
expect(applyIntent('pending_confirmation', 'reject')).toBe('approve')
|
|
177
177
|
})
|
|
178
178
|
|
|
@@ -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
|
+
})
|
|
@@ -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
|
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { WireSurfaceBuilder } from '../wire.js'
|
|
3
|
+
import type { ExecutionResultData } from '../execution.js'
|
|
4
|
+
import type { UndoResultData } from '../execution.js'
|
|
5
|
+
|
|
6
|
+
describe('WireSurfaceBuilder.toolList', () => {
|
|
7
|
+
it('returns data part with type data-tool-list', () => {
|
|
8
|
+
const tools = [{ name: 'search', description: 'Search tool', inputSchema: {} }] as any
|
|
9
|
+
const result = WireSurfaceBuilder.toolList(tools)
|
|
10
|
+
expect(result.type).toBe('data-tool-list')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('data.tools matches input array exactly', () => {
|
|
14
|
+
const tools = [
|
|
15
|
+
{ name: 'search', description: 'Search tool', inputSchema: {} },
|
|
16
|
+
{ name: 'fetch', description: 'Fetch tool', inputSchema: { type: 'object' } },
|
|
17
|
+
] as any
|
|
18
|
+
const result = WireSurfaceBuilder.toolList(tools)
|
|
19
|
+
expect(result.data.tools).toEqual(tools)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('works with empty tools array', () => {
|
|
23
|
+
const result = WireSurfaceBuilder.toolList([])
|
|
24
|
+
expect(result.type).toBe('data-tool-list')
|
|
25
|
+
expect(result.data.tools).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('WireSurfaceBuilder.executionResult', () => {
|
|
30
|
+
const data: ExecutionResultData = {
|
|
31
|
+
actionId: 'act-1',
|
|
32
|
+
executionId: 'exec-001',
|
|
33
|
+
state: 'completed',
|
|
34
|
+
artifacts: [{ kind: 'profile_name', label: 'Name', status: 'success' }],
|
|
35
|
+
summary: { title: 'Profile Updated' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('returns data part with type data-execution-result', () => {
|
|
39
|
+
const result = WireSurfaceBuilder.executionResult(data)
|
|
40
|
+
expect(result.type).toBe('data-execution-result')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('data matches input ExecutionResultData exactly', () => {
|
|
44
|
+
const result = WireSurfaceBuilder.executionResult(data)
|
|
45
|
+
expect(result.data).toEqual(data)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves actionId from input', () => {
|
|
49
|
+
const result = WireSurfaceBuilder.executionResult(data)
|
|
50
|
+
expect(result.data.actionId).toBe('act-1')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('WireSurfaceBuilder.undoResult', () => {
|
|
55
|
+
const data: UndoResultData = {
|
|
56
|
+
actionId: 'act-1',
|
|
57
|
+
executionId: 'exec-001',
|
|
58
|
+
undoExecutionId: 'undo-exec-002',
|
|
59
|
+
status: 'success',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('returns data part with type data-undo-result', () => {
|
|
63
|
+
const result = WireSurfaceBuilder.undoResult(data)
|
|
64
|
+
expect(result.type).toBe('data-undo-result')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('data matches input UndoResultData exactly', () => {
|
|
68
|
+
const result = WireSurfaceBuilder.undoResult(data)
|
|
69
|
+
expect(result.data).toEqual(data)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('preserves undoExecutionId from input', () => {
|
|
73
|
+
const result = WireSurfaceBuilder.undoResult(data)
|
|
74
|
+
expect(result.data.undoExecutionId).toBe('undo-exec-002')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('WireSurfaceBuilder.messageStart', () => {
|
|
79
|
+
it('returns data part with type data-message-start', () => {
|
|
80
|
+
const result = WireSurfaceBuilder.messageStart('msg-123')
|
|
81
|
+
expect(result.type).toBe('data-message-start')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('data.messageId matches input', () => {
|
|
85
|
+
const result = WireSurfaceBuilder.messageStart('msg-456')
|
|
86
|
+
expect(result.data.messageId).toBe('msg-456')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('data.version is 1', () => {
|
|
90
|
+
const result = WireSurfaceBuilder.messageStart('msg-789')
|
|
91
|
+
expect(result.data.version).toBe(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('data.timestamp is a valid ISO 8601 string', () => {
|
|
95
|
+
const result = WireSurfaceBuilder.messageStart('msg-ts')
|
|
96
|
+
const parsed = new Date(result.data.timestamp)
|
|
97
|
+
expect(parsed.toISOString()).toBe(result.data.timestamp)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('WireSurfaceBuilder.messageComplete', () => {
|
|
102
|
+
it('returns data part with type data-message-complete', () => {
|
|
103
|
+
const result = WireSurfaceBuilder.messageComplete('msg-123', 500)
|
|
104
|
+
expect(result.type).toBe('data-message-complete')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('data.messageId matches input', () => {
|
|
108
|
+
const result = WireSurfaceBuilder.messageComplete('msg-456', 200)
|
|
109
|
+
expect(result.data.messageId).toBe('msg-456')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('data.narrativeLength matches input', () => {
|
|
113
|
+
const result = WireSurfaceBuilder.messageComplete('msg-789', 1234)
|
|
114
|
+
expect(result.data.narrativeLength).toBe(1234)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('data.version is 1', () => {
|
|
118
|
+
const result = WireSurfaceBuilder.messageComplete('msg-v', 100)
|
|
119
|
+
expect(result.data.version).toBe(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('data.timestamp is a valid ISO 8601 string', () => {
|
|
123
|
+
const result = WireSurfaceBuilder.messageComplete('msg-ts', 300)
|
|
124
|
+
const parsed = new Date(result.data.timestamp)
|
|
125
|
+
expect(parsed.toISOString()).toBe(result.data.timestamp)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { VIEW_SCOPE_MAP, getViewScope } from '../view-scopes.js'
|
|
3
|
+
|
|
4
|
+
describe('VIEW_SCOPE_MAP golden snapshot', () => {
|
|
5
|
+
it('exact values are frozen', () => {
|
|
6
|
+
expect(VIEW_SCOPE_MAP).toStrictEqual({
|
|
7
|
+
workspace: 'org.view_workspace',
|
|
8
|
+
timeline: 'org.view_timeline',
|
|
9
|
+
activity: 'org.view_activity',
|
|
10
|
+
strategy: 'org.view_strategy',
|
|
11
|
+
'internal-admin': 'internal.view_admin',
|
|
12
|
+
chat: null,
|
|
13
|
+
settings: null,
|
|
14
|
+
chats: null,
|
|
15
|
+
upgrade: null,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('getViewScope', () => {
|
|
21
|
+
it('returns the required scope for protected views', () => {
|
|
22
|
+
expect(getViewScope('workspace')).toBe('org.view_workspace')
|
|
23
|
+
expect(getViewScope('timeline')).toBe('org.view_timeline')
|
|
24
|
+
expect(getViewScope('activity')).toBe('org.view_activity')
|
|
25
|
+
expect(getViewScope('strategy')).toBe('org.view_strategy')
|
|
26
|
+
expect(getViewScope('internal-admin')).toBe('internal.view_admin')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns null for public views', () => {
|
|
30
|
+
expect(getViewScope('chat')).toBeNull()
|
|
31
|
+
expect(getViewScope('settings')).toBeNull()
|
|
32
|
+
expect(getViewScope('chats')).toBeNull()
|
|
33
|
+
expect(getViewScope('upgrade')).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns null for unknown view string', () => {
|
|
37
|
+
expect(getViewScope('nonexistent-view')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns null for empty string', () => {
|
|
41
|
+
expect(getViewScope('')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns null for view name that is a substring of a real view', () => {
|
|
45
|
+
expect(getViewScope('work')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { REPO_PRECEDENCE } from '../prd.js'
|
|
3
|
+
|
|
4
|
+
describe('REPO_PRECEDENCE', () => {
|
|
5
|
+
it('has contracts first (dependency source)', () => {
|
|
6
|
+
expect(REPO_PRECEDENCE[0]).toBe('company-semantics-contracts')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('exact values and order are frozen (golden snapshot)', () => {
|
|
10
|
+
expect(REPO_PRECEDENCE).toStrictEqual([
|
|
11
|
+
'company-semantics-contracts',
|
|
12
|
+
'company-semantics-control',
|
|
13
|
+
'company-semantics-ci',
|
|
14
|
+
'company-semantics-backend',
|
|
15
|
+
'company-semantics-edge',
|
|
16
|
+
'company-semantics-app',
|
|
17
|
+
'company-semantics-site',
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('has exactly 7 entries (all known repos)', () => {
|
|
22
|
+
expect(REPO_PRECEDENCE).toHaveLength(7)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('has no duplicates', () => {
|
|
26
|
+
expect(new Set(REPO_PRECEDENCE).size).toBe(REPO_PRECEDENCE.length)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('all entries start with company-semantics- prefix', () => {
|
|
30
|
+
for (const repo of REPO_PRECEDENCE) {
|
|
31
|
+
expect(repo).toMatch(/^company-semantics-/)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('all entries are non-empty strings', () => {
|
|
36
|
+
for (const repo of REPO_PRECEDENCE) {
|
|
37
|
+
expect(typeof repo).toBe('string')
|
|
38
|
+
expect(repo.length).toBeGreaterThan(0)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|