switchroom 0.18.28 → 0.18.29
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/bin/handoff-briefing.sh +8 -1
- package/dist/auth-broker/index.js +0 -57
- package/dist/cli/switchroom.js +501 -497
- package/dist/host-control/main.js +1 -58
- package/dist/vault/approvals/kernel-server.js +0 -57
- package/dist/vault/broker/server.js +0 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +37 -19
- package/telegram-plugin/dist/gateway/gateway.js +578 -580
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +458 -385
- package/telegram-plugin/gateway/model-command.ts +227 -602
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/shared/local-time.ts +56 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -0
- package/telegram-plugin/tests/model-command.test.ts +317 -1535
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
- package/vendor/hindsight-memory/tests/test_content.py +28 -7
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structural pins for the
|
|
3
|
-
* (reference/rfcs/session-model-stickiness.md §0.
|
|
2
|
+
* Structural pins for the DETERMINISTIC /model wiring in gateway.ts
|
|
3
|
+
* (reference/rfcs/session-model-stickiness.md §0.05, rev 5 — every switch
|
|
4
|
+
* relaunches through the consume-once carrier; the inject/scrape path retired).
|
|
4
5
|
*
|
|
5
6
|
* The behaviour lives in un-exported inline closures (buildModelDeps's
|
|
6
|
-
* scheduleModelRelaunch/
|
|
7
|
-
* boot re-hydration block inside the startup IIFE), so — mirroring the other
|
|
7
|
+
* scheduleModelRelaunch / scheduleModelDefaultRelaunch / scheduleRestart, and
|
|
8
|
+
* the boot re-hydration block inside the startup IIFE), so — mirroring the other
|
|
8
9
|
* gateway-*.test.ts source-pins — we assert on the source structure. The
|
|
9
10
|
* end-to-end boot behaviour is exercised in tests/scaffold.session-model.test.ts
|
|
10
11
|
* (rendered start.sh), the file helpers in session-model-file.test.ts, and the
|
|
@@ -42,7 +43,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
|
|
|
42
43
|
it('writes the carrier via writeSessionModelFile before dispatching the restart', () => {
|
|
43
44
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
44
45
|
expect(idx).toBeGreaterThan(0)
|
|
45
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
46
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2600)
|
|
46
47
|
const writeIdx = win.indexOf('writeSessionModelFile(')
|
|
47
48
|
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
48
49
|
expect(writeIdx).toBeGreaterThan(0)
|
|
@@ -51,7 +52,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
|
|
|
51
52
|
|
|
52
53
|
it('sets the in-memory session-model override before dispatching the restart', () => {
|
|
53
54
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
54
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
55
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2600)
|
|
55
56
|
const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
|
|
56
57
|
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
57
58
|
expect(setIdx).toBeGreaterThan(0)
|
|
@@ -60,7 +61,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
|
|
|
60
61
|
|
|
61
62
|
it('rolls back the prior carrier content (not just deletion) on a non-in-flight dispatch failure', () => {
|
|
62
63
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
63
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
64
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2600)
|
|
64
65
|
expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
|
|
65
66
|
expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
|
|
66
67
|
expect(win).toContain("!== 'restart_in_flight'")
|
|
@@ -68,82 +69,108 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
|
|
|
68
69
|
|
|
69
70
|
it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
|
|
70
71
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
71
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
72
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2600)
|
|
72
73
|
expect(win).toContain('await deps.scheduleRestart(reason)')
|
|
73
74
|
})
|
|
74
75
|
})
|
|
75
76
|
|
|
76
|
-
describe('gateway:
|
|
77
|
-
it('
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(
|
|
83
|
-
const overrideIdx = win.indexOf('sessionModelSource.setOverride(outcome.selectedModel)')
|
|
84
|
-
const nextClearIdx = win.indexOf('outcome.clearedDefault')
|
|
85
|
-
const writeBetween = win.slice(overrideIdx, nextClearIdx)
|
|
86
|
-
expect(writeBetween).not.toContain('writeSessionModelFile(')
|
|
77
|
+
describe('gateway: the retired scrape-recorders are GONE (rev 5 inversion)', () => {
|
|
78
|
+
it('recordTypedModelSwitch and recordModelMenuSideEffects no longer exist', () => {
|
|
79
|
+
// INVERTED from rev 4: these helpers recorded a scrape-derived selectedModel
|
|
80
|
+
// and drove the sr-to-claude special case. Every switch now relaunches, so
|
|
81
|
+
// they are deleted — their presence would mean the retired path survived.
|
|
82
|
+
expect(GATEWAY_SRC).not.toContain('function recordTypedModelSwitch')
|
|
83
|
+
expect(GATEWAY_SRC).not.toContain('function recordModelMenuSideEffects')
|
|
87
84
|
})
|
|
88
85
|
|
|
89
|
-
it('
|
|
90
|
-
|
|
91
|
-
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
92
|
-
expect(win).toContain('outcome.clearedDefault')
|
|
93
|
-
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
86
|
+
it('no isSrToClaudeTransition wiring (every switch relaunches — no distinct transition)', () => {
|
|
87
|
+
expect(GATEWAY_SRC).not.toContain('isSrToClaudeTransition')
|
|
94
88
|
})
|
|
95
89
|
|
|
96
|
-
it('the
|
|
97
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
90
|
+
it('buildModelDeps wires neither the inject nor the select terminal-driver dep', () => {
|
|
91
|
+
const idx = GATEWAY_SRC.indexOf('function buildModelDeps')
|
|
98
92
|
expect(idx).toBeGreaterThan(0)
|
|
99
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
100
|
-
expect(win).toContain('
|
|
101
|
-
expect(win).
|
|
93
|
+
const win = GATEWAY_SRC.slice(idx, idx + 4000)
|
|
94
|
+
expect(win).not.toContain('inject: injectSlashCommandImpl')
|
|
95
|
+
expect(win).not.toContain('select: (a, label) => selectModel')
|
|
102
96
|
})
|
|
97
|
+
})
|
|
103
98
|
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
describe('gateway: scheduleModelDefaultRelaunch (G1 — clear + revert relaunch)', () => {
|
|
100
|
+
it('clears the carrier + override and mirrors scheduleModelRelaunch rollback', () => {
|
|
101
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelDefaultRelaunch: async')
|
|
106
102
|
expect(idx).toBeGreaterThan(0)
|
|
107
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
104
|
+
const clearIdx = win.indexOf('clearSessionModelFile(agentDir)')
|
|
105
|
+
const overrideIdx = win.indexOf('sessionModelSource.setOverride(null)')
|
|
106
|
+
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
107
|
+
expect(clearIdx).toBeGreaterThan(0)
|
|
108
|
+
expect(overrideIdx).toBeGreaterThan(clearIdx)
|
|
109
|
+
expect(restartIdx).toBeGreaterThan(overrideIdx)
|
|
110
|
+
// G1 rollback on a non-in-flight dispatch failure.
|
|
111
|
+
expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
|
|
112
|
+
expect(win).toContain("!== 'restart_in_flight'")
|
|
111
113
|
})
|
|
112
114
|
})
|
|
113
115
|
|
|
114
|
-
describe('gateway:
|
|
115
|
-
it('
|
|
116
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
116
|
+
describe('gateway: the live callback dispatcher routes every switch tap to the handler', () => {
|
|
117
|
+
it('calls handleModelMenuCallback and no longer post-processes a scrape outcome', () => {
|
|
118
|
+
const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
|
|
117
119
|
expect(idx).toBeGreaterThan(0)
|
|
118
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
119
|
-
expect(win).toContain('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
expect(win).toContain(
|
|
128
|
-
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
129
|
-
expect(win).toContain('sessionModelSource.setOverride(null)')
|
|
130
|
-
// The file-clear is not gated on a positive confirmation.
|
|
131
|
-
const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
|
|
132
|
-
const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
|
|
133
|
-
expect(clearIdx).toBeGreaterThan(0)
|
|
134
|
-
expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
|
|
120
|
+
const win = GATEWAY_SRC.slice(idx, idx + 600)
|
|
121
|
+
expect(win).not.toContain('recordModelMenuSideEffects')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('the typed dispatcher relays the handler reply directly (no recordTypedModelSwitch)', () => {
|
|
125
|
+
const idx = GATEWAY_SRC.indexOf('const reply = await handleModelCommand(parsed, deps)')
|
|
126
|
+
expect(idx).toBeGreaterThan(0)
|
|
127
|
+
const win = GATEWAY_SRC.slice(idx, idx + 400)
|
|
128
|
+
expect(win).not.toContain('recordTypedModelSwitch')
|
|
129
|
+
expect(win).toContain('switchroomReply(ctx, reply.text')
|
|
135
130
|
})
|
|
136
131
|
})
|
|
137
132
|
|
|
138
|
-
describe('gateway boot: session-model re-hydration + alert relay', () => {
|
|
139
|
-
it('re-hydrates the override from .active-session-model', () => {
|
|
133
|
+
describe('gateway boot: session-model re-hydration + confirmation + alert relay', () => {
|
|
134
|
+
it('re-hydrates the override from .active-session-model (launched !== configured)', () => {
|
|
140
135
|
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
|
|
141
136
|
expect(idx).toBeGreaterThan(0)
|
|
142
|
-
const win = GATEWAY_SRC.slice(idx - 200, idx +
|
|
143
|
-
|
|
137
|
+
const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
|
|
138
|
+
// F1: `launched !== configured` is the deterministic apply-boot signal.
|
|
139
|
+
expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
|
|
140
|
+
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
|
|
144
141
|
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
145
142
|
})
|
|
146
143
|
|
|
144
|
+
it('logs the applied model for diagnosability (F1)', () => {
|
|
145
|
+
expect(GATEWAY_SRC).toContain('gw /model relaunch applied agent=')
|
|
146
|
+
expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
|
|
150
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
151
|
+
expect(idx).toBeGreaterThan(0)
|
|
152
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3400)
|
|
153
|
+
// Keyed on the deterministic /model switch reason, so it also fires on a
|
|
154
|
+
// launched===configured apply-boot (/model default) — N4. Never optimistic.
|
|
155
|
+
expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
|
|
156
|
+
expect(win).toContain('✅ Now running')
|
|
157
|
+
// N4: the launched===configured branch still confirms.
|
|
158
|
+
expect(win).toContain('(the configured default)')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
|
|
162
|
+
expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
|
|
163
|
+
expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('N3: the generic boot card is suppressed on a /model apply-boot (one card per switch)', () => {
|
|
167
|
+
const idx = GATEWAY_SRC.indexOf('const suppressBootCardForModelSwitch')
|
|
168
|
+
expect(idx).toBeGreaterThan(0)
|
|
169
|
+
const win = GATEWAY_SRC.slice(idx, idx + 800)
|
|
170
|
+
expect(win).toContain('modelSwitchReason != null && modelSwitchMarkerChat != null')
|
|
171
|
+
expect(win).toContain('else if (target)')
|
|
172
|
+
})
|
|
173
|
+
|
|
147
174
|
it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
|
|
148
175
|
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
|
|
149
176
|
expect(idx).toBeGreaterThan(0)
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
hasOutboundDeliveredSince,
|
|
17
17
|
hasOutboundWithText,
|
|
18
18
|
normalizeDeliveryText,
|
|
19
|
+
verifyHistoryWritable,
|
|
19
20
|
_resetForTests,
|
|
20
21
|
} from '../history.js'
|
|
21
22
|
|
|
@@ -963,3 +964,90 @@ describe('hasOutboundWithText (durable text-identity oracle)', () => {
|
|
|
963
964
|
expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
|
|
964
965
|
})
|
|
965
966
|
})
|
|
967
|
+
|
|
968
|
+
// ── 2026-07-16 incident hardening: writer durability across restart + surfacing
|
|
969
|
+
// swallowed insert failures. Root cause: turn-flush deliveries (18944/18958)
|
|
970
|
+
// reached Telegram but were absent from history.db, blinding
|
|
971
|
+
// getRecentOutboundCount / hasOutboundDeliveredSince. The gateway had already
|
|
972
|
+
// logged "history capture enabled" on both restarts, so DB-open success was
|
|
973
|
+
// NOT proof the row-insert path worked. These tests pin the durable fix.
|
|
974
|
+
describe('history writer durability (2026-07-16 incident)', () => {
|
|
975
|
+
it('verifyHistoryWritable proves the INSERT path works on a live DB', () => {
|
|
976
|
+
initHistory(stateDir, 30)
|
|
977
|
+
const res = verifyHistoryWritable()
|
|
978
|
+
expect(res.ok).toBe(true)
|
|
979
|
+
// The self-check must leave NO sentinel residue behind.
|
|
980
|
+
expect(getRecentOutboundCount('__history_selfcheck__', 86_400)).toBe(0)
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
it('verifyHistoryWritable reports not-ok before init (no silent success)', () => {
|
|
984
|
+
// No initHistory() this test — the writer is uninitialised.
|
|
985
|
+
const res = verifyHistoryWritable()
|
|
986
|
+
expect(res.ok).toBe(false)
|
|
987
|
+
expect(res.error).toMatch(/initHistory/)
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
// The core recovery contract: recording must survive a shutdown + reinit
|
|
991
|
+
// (a gateway restart, which nulls the module singleton and re-opens the same
|
|
992
|
+
// file). Rows written before AND after the boundary must all be queryable.
|
|
993
|
+
it('recording continues across a simulated restart (reinit of the same DB)', () => {
|
|
994
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
995
|
+
initHistory(stateDir, 30)
|
|
996
|
+
recordOutbound({ chat_id: '9', thread_id: null, message_ids: [100], texts: ['before restart'], ts: nowSec - 60 })
|
|
997
|
+
// Simulate a gateway restart: close + forget the singleton, then reinit
|
|
998
|
+
// against the SAME stateDir (fresh process, db=null → re-open).
|
|
999
|
+
_resetForTests()
|
|
1000
|
+
initHistory(stateDir, 30)
|
|
1001
|
+
// Boot self-check must still pass against the existing, populated file.
|
|
1002
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
1003
|
+
recordOutbound({ chat_id: '9', thread_id: null, message_ids: [101], texts: ['after restart'], ts: nowSec })
|
|
1004
|
+
const rows = query({ chat_id: '9' })
|
|
1005
|
+
expect(rows.map((r) => r.message_id)).toEqual([100, 101])
|
|
1006
|
+
// The backstop suppression counter (the surface blinded by the incident)
|
|
1007
|
+
// must see BOTH the pre- and post-restart outbounds.
|
|
1008
|
+
expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(2)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
// The exact incident shape: a malformed send result yields an invalid
|
|
1012
|
+
// message_id. OLD behaviour: the NOT NULL PRIMARY KEY throws inside the tx and
|
|
1013
|
+
// the caller's `catch {}` swallows it — the row is lost AND invisible. NEW
|
|
1014
|
+
// behaviour: the invalid chunk is filtered + logged, valid chunks still land,
|
|
1015
|
+
// and no throw escapes to be swallowed.
|
|
1016
|
+
it('drops an invalid message_id chunk loudly but records the valid ones (no silent total loss)', () => {
|
|
1017
|
+
initHistory(stateDir, 30)
|
|
1018
|
+
const errs: string[] = []
|
|
1019
|
+
const orig = process.stderr.write.bind(process.stderr)
|
|
1020
|
+
// @ts-expect-error narrow test shim over the write overloads
|
|
1021
|
+
process.stderr.write = (chunk: string) => { errs.push(String(chunk)); return true }
|
|
1022
|
+
try {
|
|
1023
|
+
recordOutbound({
|
|
1024
|
+
chat_id: '9',
|
|
1025
|
+
thread_id: null,
|
|
1026
|
+
// chunk 0 is a malformed (undefined) id; chunk 1 is real.
|
|
1027
|
+
message_ids: [undefined as unknown as number, 200],
|
|
1028
|
+
texts: ['lost chunk', 'kept chunk'],
|
|
1029
|
+
ts: 3000,
|
|
1030
|
+
})
|
|
1031
|
+
} finally {
|
|
1032
|
+
process.stderr.write = orig
|
|
1033
|
+
}
|
|
1034
|
+
// The valid chunk is recorded (delivery accounting is NOT silently zeroed).
|
|
1035
|
+
const rows = query({ chat_id: '9' })
|
|
1036
|
+
expect(rows.map((r) => r.message_id)).toEqual([200])
|
|
1037
|
+
// The drop was surfaced loudly, not swallowed.
|
|
1038
|
+
expect(errs.join('')).toMatch(/invalid message_id/)
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
it('recordOutbound with an all-invalid id set no-ops without throwing', () => {
|
|
1042
|
+
initHistory(stateDir, 30)
|
|
1043
|
+
expect(() =>
|
|
1044
|
+
recordOutbound({
|
|
1045
|
+
chat_id: '9',
|
|
1046
|
+
thread_id: null,
|
|
1047
|
+
message_ids: [NaN, null as unknown as number],
|
|
1048
|
+
texts: ['a', 'b'],
|
|
1049
|
+
}),
|
|
1050
|
+
).not.toThrow()
|
|
1051
|
+
expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(0)
|
|
1052
|
+
})
|
|
1053
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the model-facing local-time primitives in
|
|
3
|
+
* telegram-plugin/shared/local-time.ts.
|
|
4
|
+
*
|
|
5
|
+
* switchroom #tz-fix: `fmtLocalStamp` + `resolveEnvTimezone` are the single
|
|
6
|
+
* source of truth for the LOCAL am/pm timestamps the model now sees on inbound
|
|
7
|
+
* `<channel ts="…">` tags, `forwarded_date`, and the get_recent_messages
|
|
8
|
+
* buffer — replacing the UTC ISO strings that competed with the local-time
|
|
9
|
+
* hint. These pin the deterministic outcome: local am/pm, NEVER a UTC string.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
14
|
+
|
|
15
|
+
// 2025-06-15T14:26:40Z — a fixed instant so the local rendering is deterministic.
|
|
16
|
+
const MS = 1750000000 * 1000
|
|
17
|
+
|
|
18
|
+
describe('resolveEnvTimezone', () => {
|
|
19
|
+
it('follows the SWITCHROOM_TIMEZONE → TZ → UTC cascade', () => {
|
|
20
|
+
expect(resolveEnvTimezone({ SWITCHROOM_TIMEZONE: 'Australia/Melbourne', TZ: 'America/New_York' })).toBe(
|
|
21
|
+
'Australia/Melbourne',
|
|
22
|
+
)
|
|
23
|
+
expect(resolveEnvTimezone({ TZ: 'America/New_York' })).toBe('America/New_York')
|
|
24
|
+
expect(resolveEnvTimezone({})).toBe('UTC')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('fmtLocalStamp', () => {
|
|
29
|
+
it('renders LOCAL am/pm with weekday, ISO date, and zone abbrev — no UTC', () => {
|
|
30
|
+
const out = fmtLocalStamp(MS, 'Australia/Melbourne')
|
|
31
|
+
// e.g. "Sunday 2025-06-16 12:26 AM AEST"
|
|
32
|
+
expect(out).toMatch(/^[A-Za-z]+ \d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM) [A-Za-z]{2,5}$/)
|
|
33
|
+
expect(out).not.toContain('UTC')
|
|
34
|
+
expect(out.endsWith('Z')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reflects the requested zone (different wall clock for a different tz)', () => {
|
|
38
|
+
const melbourne = fmtLocalStamp(MS, 'Australia/Melbourne')
|
|
39
|
+
const newYork = fmtLocalStamp(MS, 'America/New_York')
|
|
40
|
+
expect(melbourne).not.toBe(newYork)
|
|
41
|
+
expect(newYork).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is total — an invalid IANA zone degrades to am/pm, never throws', () => {
|
|
45
|
+
// Must not throw out of the inbound path on a misconfigured agent.
|
|
46
|
+
const out = fmtLocalStamp(MS, 'Not/ARealZone')
|
|
47
|
+
expect(typeof out).toBe('string')
|
|
48
|
+
expect(out.length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('UTC zone still renders am/pm (24h/"UTC-suffix" form is gone)', () => {
|
|
52
|
+
const out = fmtLocalStamp(MS, 'UTC')
|
|
53
|
+
expect(out).toMatch(/ (?:AM|PM) /)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tracks DST — Australia/Melbourne is AEDT in Jan (summer) and AEST in Jul (winter)', () => {
|
|
57
|
+
// Southern-hemisphere DST: daylight time is the Dec–Mar summer.
|
|
58
|
+
const summer = fmtLocalStamp(Date.UTC(2026, 0, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jan
|
|
59
|
+
const winter = fmtLocalStamp(Date.UTC(2026, 6, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jul
|
|
60
|
+
expect(summer).toContain('AEDT')
|
|
61
|
+
expect(winter).toContain('AEST')
|
|
62
|
+
// Both stay am/pm and UTC-free across the transition.
|
|
63
|
+
for (const s of [summer, winter]) {
|
|
64
|
+
expect(s).toMatch(/ (?:AM|PM) /)
|
|
65
|
+
expect(s).not.toContain('UTC')
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|