@jc_stack/ez-agents 0.1.0-beta.12
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/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { ControlStore } from '../src/control-state.js'
|
|
7
|
+
import { parseOwnerArgs } from '../src/owner-args.js'
|
|
8
|
+
|
|
9
|
+
const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void) => Promise<void>): Promise<void> => {
|
|
10
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'ez-control-test-'))
|
|
11
|
+
let now = Date.parse('2026-01-01T00:00:00.000Z')
|
|
12
|
+
try {
|
|
13
|
+
await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds })
|
|
14
|
+
} finally {
|
|
15
|
+
await rm(directory, { recursive: true, force: true })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('does not pair the first sender automatically', async () => fixture(async (store) => {
|
|
20
|
+
assert.equal(await store.requestPairing(101, 101), 'requested')
|
|
21
|
+
const state = await store.status()
|
|
22
|
+
assert.equal(state.owner, null)
|
|
23
|
+
assert.equal(state.pending.length, 1)
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
test('approves only an active explicit pairing request', async () => fixture(async (store) => {
|
|
27
|
+
await store.requestPairing(101, 101)
|
|
28
|
+
const owner = await store.approveOwner(101)
|
|
29
|
+
assert.equal(owner.telegramUserId, 101)
|
|
30
|
+
assert.equal(owner.telegramChatId, 101)
|
|
31
|
+
await assert.rejects(store.approveOwner(202), /already paired/)
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
test('cannot approve a telegram id that did not request pairing', async () => fixture(async (store) => {
|
|
35
|
+
await store.requestPairing(101, 101)
|
|
36
|
+
await assert.rejects(store.approveOwner(202), /No active pairing request/)
|
|
37
|
+
assert.equal((await store.status()).owner, null)
|
|
38
|
+
assert.equal((await store.status()).pending[0]?.telegramUserId, 101)
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
test('expires pairing requests before an owner can approve them', async () => fixture(async (store, advance) => {
|
|
42
|
+
await store.requestPairing(101, 101)
|
|
43
|
+
advance(60_001)
|
|
44
|
+
await assert.rejects(store.approveOwner(101), /No active pairing request/)
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
test('supports local owner recovery by revocation', async () => fixture(async (store) => {
|
|
48
|
+
await store.requestPairing(101, 101)
|
|
49
|
+
await store.approveOwner(101)
|
|
50
|
+
assert.equal(await store.revokeOwner(), true)
|
|
51
|
+
assert.equal((await store.status()).owner, null)
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
test('owner CLI treats pnpm -- as a separator, not a command', () => {
|
|
55
|
+
assert.deepEqual(parseOwnerArgs(['--', 'status']), { command: 'status', value: undefined })
|
|
56
|
+
assert.deepEqual(parseOwnerArgs(['--', 'approve', '101']), { command: 'approve', value: '101' })
|
|
57
|
+
assert.deepEqual(parseOwnerArgs(['revoke']), { command: 'revoke', value: undefined })
|
|
58
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { DESKTOP_UNAVAILABLE, desktopJobPrompt, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
|
|
8
|
+
import { executorKey, nativeSessionId, startExecutorJob } from '../src/executor.js'
|
|
9
|
+
import { initialPreset, isPreset, readModels } from '../src/ai.js'
|
|
10
|
+
|
|
11
|
+
const fakeClient = (script: Array<Record<string, unknown>>): DesktopClient & { calls: string[] } => {
|
|
12
|
+
const calls: string[] = []
|
|
13
|
+
const events = new EventEmitter()
|
|
14
|
+
const client: DesktopClient & { calls: string[] } = {
|
|
15
|
+
calls,
|
|
16
|
+
request: async (method) => {
|
|
17
|
+
calls.push(method)
|
|
18
|
+
const next = script.shift()
|
|
19
|
+
if (!next) throw new Error(`unexpected ${method}`)
|
|
20
|
+
if (next.error) throw new Error(String(next.error))
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
for (const note of (next.notify as Record<string, unknown>[] | undefined) || []) events.emit('message', note)
|
|
23
|
+
}, 0)
|
|
24
|
+
return (next.result || {}) as Record<string, unknown>
|
|
25
|
+
},
|
|
26
|
+
notify: (method) => { calls.push(method) },
|
|
27
|
+
wait: (match) => new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => reject(new Error(DESKTOP_UNAVAILABLE)), 1000)
|
|
29
|
+
const onMessage = (message: Record<string, unknown>) => {
|
|
30
|
+
if (!match(message)) return
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
events.off('message', onMessage)
|
|
33
|
+
resolve(message)
|
|
34
|
+
}
|
|
35
|
+
events.on('message', onMessage)
|
|
36
|
+
}),
|
|
37
|
+
close: () => events.removeAllListeners(),
|
|
38
|
+
}
|
|
39
|
+
return client
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('desktop prompt carries run identity and tool paths, never a bot token', () => {
|
|
43
|
+
const prompt = desktopJobPrompt('r_gui', ['hello'], undefined, '/tmp/bin', '/tmp/control')
|
|
44
|
+
assert.match(prompt, /EZ_RUN_ID=r_gui/)
|
|
45
|
+
assert.match(prompt, /EZ_CONTROL_DIR=\/tmp\/control/)
|
|
46
|
+
assert.match(prompt, /PATH=\/tmp\/bin:\$PATH/)
|
|
47
|
+
assert.match(prompt, /ezenciel-agents-message/)
|
|
48
|
+
assert.ok(!prompt.includes('TELEGRAM_BOT_TOKEN'))
|
|
49
|
+
assert.ok(!prompt.includes('token'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('codex-gui is a distinct preset and catalog entry', async () => {
|
|
53
|
+
assert.equal(executorKey('codex-gui'), 'codex-gui')
|
|
54
|
+
assert.equal(initialPreset('codex-gui').cli, 'codex-gui')
|
|
55
|
+
assert.notEqual(initialPreset('codex-gui').cli, initialPreset('codex').cli)
|
|
56
|
+
assert.equal(isPreset({ id: 'x', name: 'Desktop', cli: 'codex-gui' }), true)
|
|
57
|
+
const home = await mkdtemp(path.join(tmpdir(), 'ez-gui-catalog-'))
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(path.join(home, '.codex'))
|
|
60
|
+
await writeFile(path.join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
|
|
61
|
+
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list', supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
62
|
+
] }))
|
|
63
|
+
const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui')
|
|
64
|
+
assert.ok(models.some((model) => model.cli === 'codex' && model.model === 'fixture-model'))
|
|
65
|
+
assert.ok(models.some((model) => model.cli === 'codex-gui' && model.model === 'fixture-model'))
|
|
66
|
+
} finally { await rm(home, { recursive: true, force: true }) }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('a dedicated desktop turn emits the thread id and waits for completion', async () => {
|
|
70
|
+
const client = fakeClient([
|
|
71
|
+
{ result: {} },
|
|
72
|
+
{ result: { thread: { id: 'thr_hello' } } },
|
|
73
|
+
{ result: {} },
|
|
74
|
+
{ result: { turn: { id: 'turn_1' } }, notify: [{ method: 'turn/completed', params: { turn: { id: 'turn_1', status: 'completed' } } }] },
|
|
75
|
+
])
|
|
76
|
+
const lines: string[] = []
|
|
77
|
+
const code = await runDesktopTurn({
|
|
78
|
+
workspace: '/tmp/mind', controlDir: '/tmp/control', binDir: '/tmp/bin', runId: 'r_1', prompt: 'hello',
|
|
79
|
+
}, { connect: async () => client, emit: (line) => lines.push(line) })
|
|
80
|
+
assert.equal(code, 0)
|
|
81
|
+
assert.deepEqual(client.calls, ['initialize', 'initialized', 'thread/start', 'thread/name/set', 'turn/start'])
|
|
82
|
+
assert.equal(nativeSessionId('codex-gui', lines[0]), 'thr_hello')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a UUID-shaped desktop thread is resumed instead of starting another task', async () => {
|
|
86
|
+
const threadId = '01a07b96-2681-72f2-ae6c-6f77fe823a8c'
|
|
87
|
+
const client = fakeClient([
|
|
88
|
+
{ result: {} },
|
|
89
|
+
{ result: { thread: { id: threadId } } },
|
|
90
|
+
{ result: { turn: { id: 'turn_3' } }, notify: [{ method: 'turn/completed', params: { turn: { id: 'turn_3', status: 'completed' } } }] },
|
|
91
|
+
])
|
|
92
|
+
const code = await runDesktopTurn({
|
|
93
|
+
workspace: '/tmp/mind', controlDir: '/tmp/control', binDir: '/tmp/bin', runId: 'r_3', prompt: 'hello again',
|
|
94
|
+
sessionId: threadId, isResume: true,
|
|
95
|
+
}, { connect: async () => client, emit: () => {} })
|
|
96
|
+
assert.equal(code, 0)
|
|
97
|
+
assert.ok(client.calls.includes('thread/resume'))
|
|
98
|
+
assert.ok(!client.calls.includes('thread/start'))
|
|
99
|
+
assert.equal(nativeSessionId('codex-gui', JSON.stringify({ type: 'thread.started', thread_id: threadId })), threadId)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('desktop plugin access matches CLI on new and resumed turns without granting other roots',async()=>{
|
|
103
|
+
for(const isResume of [false,true])for(const toolsHome of [undefined,'/tmp/agent-tools']) {
|
|
104
|
+
const client=fakeClient([{result:{}},{result:{thread:{id:'thr_plugins'}}},...(!isResume?[{result:{}}]:[]),{result:{turn:{id:'turn_plugins'}},notify:[{method:'turn/completed',params:{turn:{id:'turn_plugins',status:'completed'}}}]}]);
|
|
105
|
+
const original=client.request;let policy:unknown;
|
|
106
|
+
client.request=async(method,params)=>{if(method==='turn/start')policy=(params as any).sandboxPolicy;return original(method,params)};
|
|
107
|
+
assert.equal(await runDesktopTurn({workspace:'/tmp/mind',controlDir:'/tmp/control',binDir:'/tmp/bin',toolsHome,runId:'r_plugins',prompt:'check',isResume,sessionId:isResume?'thr_plugins':undefined},{connect:async()=>client,emit:()=>{}}),0);
|
|
108
|
+
assert.deepEqual(policy,{type:'workspaceWrite',writableRoots:['/tmp/control',...(toolsHome?[toolsHome]:[])],networkAccess:Boolean(toolsHome)});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('stop interrupts the in-flight desktop turn before releasing', async () => {
|
|
113
|
+
const abort = new AbortController()
|
|
114
|
+
const client = fakeClient([
|
|
115
|
+
{ result: {} },
|
|
116
|
+
{ result: { thread: { id: 'thr_stop' } } },
|
|
117
|
+
{ result: { turn: { id: 'turn_2' } } },
|
|
118
|
+
{ result: {}, notify: [{ method: 'turn/completed', params: { turn: { id: 'turn_2', status: 'interrupted' } } }] },
|
|
119
|
+
])
|
|
120
|
+
const original = client.request
|
|
121
|
+
client.request = async (method, params) => {
|
|
122
|
+
const result = await original(method, params)
|
|
123
|
+
if (method === 'turn/start') queueMicrotask(() => abort.abort())
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
const code = await runDesktopTurn({
|
|
127
|
+
workspace: '/tmp/mind', controlDir: '/tmp/control', binDir: '/tmp/bin', runId: 'r_2', prompt: 'hello',
|
|
128
|
+
sessionId: 'thr_stop', isResume: true,
|
|
129
|
+
}, { connect: async () => client, emit: () => {}, signal: abort.signal })
|
|
130
|
+
assert.equal(code, 130)
|
|
131
|
+
assert.ok(client.calls.includes('thread/resume'))
|
|
132
|
+
assert.ok(client.calls.includes('turn/interrupt'))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('an unavailable desktop fails closed without spawning Codex CLI', async () => {
|
|
136
|
+
const home = await mkdtemp(path.join(tmpdir(), 'ez-gui-off-'))
|
|
137
|
+
const priorHome = process.env.HOME
|
|
138
|
+
const priorPath = process.env.PATH
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(path.join(home, 'bin'))
|
|
141
|
+
await writeFile(path.join(home, 'bin/codex'), `#!${process.execPath}\nconsole.error('CLI fallback');\nprocess.exit(0)\n`, { mode: 0o700 })
|
|
142
|
+
process.env.HOME = home
|
|
143
|
+
process.env.PATH = path.join(home, 'bin')
|
|
144
|
+
const job = await startExecutorJob(['hello'], {
|
|
145
|
+
workspace: home, controlDir: home, binDir: path.join(home, 'bin'), cli: 'codex-gui', runId: 'r_off', timeoutMs: 4000,
|
|
146
|
+
})
|
|
147
|
+
let stderr = ''
|
|
148
|
+
job.child.stderr?.on('data', (chunk: string) => { stderr += chunk })
|
|
149
|
+
const code = await new Promise<number | null>((resolve) => job.child.once('close', resolve))
|
|
150
|
+
await job.cleanup()
|
|
151
|
+
assert.notEqual(code, 0)
|
|
152
|
+
assert.match(stderr, /unavailable/i)
|
|
153
|
+
assert.ok(!stderr.includes('CLI fallback'))
|
|
154
|
+
} finally {
|
|
155
|
+
if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome
|
|
156
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
157
|
+
await rm(home, { recursive: true, force: true })
|
|
158
|
+
}
|
|
159
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { RunStore } from '../src/runs.js'
|
|
7
|
+
import { recoverInterruptedRuns } from '../docker/recovery.js'
|
|
8
|
+
|
|
9
|
+
test('exclusive Docker restart fails interrupted runs even when a container PID has been reused, without replaying queued work', async () => {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-container-recovery-'))
|
|
11
|
+
try {
|
|
12
|
+
const store = new RunStore(dir)
|
|
13
|
+
const prior = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] })
|
|
14
|
+
const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] })
|
|
15
|
+
await store.patch(prior.id, { status: 'running', pid: process.pid })
|
|
16
|
+
await recoverInterruptedRuns(dir)
|
|
17
|
+
assert.equal((await store.get(prior.id))?.status, 'failed')
|
|
18
|
+
assert.equal((await store.get(queued.id))?.status, 'queued')
|
|
19
|
+
const ended = (await store.get(prior.id))?.endedAt
|
|
20
|
+
await recoverInterruptedRuns(dir)
|
|
21
|
+
assert.equal((await store.get(prior.id))?.endedAt, ended)
|
|
22
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
23
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createServer } from 'node:http'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { once } from 'node:events'
|
|
8
|
+
import { EventSources, eventRunId, batchReady, type SourceEvent } from '../src/event-sources.js'
|
|
9
|
+
import { createRelay } from '../src/index.js'
|
|
10
|
+
import { ControlStore } from '../src/control-state.js'
|
|
11
|
+
import { RunStore } from '../src/runs.js'
|
|
12
|
+
import { initialPreset } from '../src/ai.js'
|
|
13
|
+
import { executorJobPrompt, executorJobEnv } from '../src/executor.js'
|
|
14
|
+
|
|
15
|
+
const until = async (check: () => Promise<boolean>) => {
|
|
16
|
+
for (let i = 0; i < 200; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 10)) }
|
|
17
|
+
throw new Error('Test timed out')
|
|
18
|
+
}
|
|
19
|
+
const event = (id: string, conversationId = 'chat-a'): SourceEvent => ({ id, conversationId, text: 'Untrusted correspondence', receivedAt: Date.now() - 3000 })
|
|
20
|
+
async function fixture(t: test.TestContext) {
|
|
21
|
+
const dir = await mkdtemp('/tmp/ez-source-')
|
|
22
|
+
const socketPath = join(dir, 'provider.sock')
|
|
23
|
+
let rows: SourceEvent[] = [], enabled = true, offline = false
|
|
24
|
+
const server = createServer(async (req, res) => {
|
|
25
|
+
let body = ''; for await (const c of req) body += c
|
|
26
|
+
const { command, args } = JSON.parse(body)
|
|
27
|
+
if (offline) { res.statusCode = 503; res.end('{}'); return }
|
|
28
|
+
const data = command === 'events-head' ? { cursor: rows.length }
|
|
29
|
+
: command === 'events-check' ? { events: enabled ? rows.filter(e => args.ids.includes(e.id)) : [] }
|
|
30
|
+
: { cursor: rows.length, events: enabled ? rows.filter(e => Number(e.id) > args.after) : [] }
|
|
31
|
+
res.end(JSON.stringify({ ok: true, data }))
|
|
32
|
+
})
|
|
33
|
+
await new Promise<void>(r => server.listen(socketPath, r))
|
|
34
|
+
const control = new ControlStore(dir, 1000), runs = new RunStore(dir), sources = new EventSources(dir)
|
|
35
|
+
await control.requestPairing(101, 101); await control.approveOwner(101)
|
|
36
|
+
const owner = (await control.status()).owner!
|
|
37
|
+
const launches: { texts: string[]; options: any }[] = [], children: ReturnType<typeof spawn>[] = []
|
|
38
|
+
const relay = createRelay({ controlDir: dir, workspace: dir, telegramBotToken: 'fixture', executorTimeoutMs: 1000, pairingTtlMs: 1000, executorCli: 'grok' }, async (texts, options) => {
|
|
39
|
+
assert.ok(children.every(c => c.exitCode !== null || c.signalCode !== null), 'Only one writer')
|
|
40
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true }); await once(child, 'spawn')
|
|
41
|
+
launches.push({ texts, options }); children.push(child)
|
|
42
|
+
return { child, cleanup: async () => {}, stdout: '' }
|
|
43
|
+
})
|
|
44
|
+
relay.bot.api.config.use(async () => ({ ok: true, result: true }) as never)
|
|
45
|
+
t.after(async () => {
|
|
46
|
+
await relay.stop()
|
|
47
|
+
await until(async () => children.every(c => c.exitCode !== null || c.signalCode !== null))
|
|
48
|
+
await until(async () => (await runs.list()).every(r => r.status !== 'running' || r.pid === process.pid))
|
|
49
|
+
server.closeAllConnections(); await new Promise<void>(r => server.close(() => r())); await rm(dir, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
return { dir, socketPath, owner, control, runs, sources, relay, launches, children,
|
|
52
|
+
setRows: (value: SourceEvent[]) => { rows = value }, setEnabled: (v: boolean) => { enabled = v }, setOffline: (v: boolean) => { offline = v } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('registered events use the single writer lane, fresh sessions, and durable deduplication', async t => {
|
|
56
|
+
const f = await fixture(t)
|
|
57
|
+
await f.sources.register('fixture', f.socketPath, f.owner)
|
|
58
|
+
f.setRows([event('1'), event('2')])
|
|
59
|
+
await f.relay.drainSources(); await f.relay.drainSources()
|
|
60
|
+
assert.equal(f.launches.length, 1)
|
|
61
|
+
assert.equal((await f.runs.list()).length, 1)
|
|
62
|
+
assert.equal(f.launches[0].texts.length, 2)
|
|
63
|
+
assert.equal(f.launches[0].options.eventSource, 'fixture')
|
|
64
|
+
assert.equal(f.launches[0].options.isResume, false)
|
|
65
|
+
assert.equal(f.launches[0].options.onSession, undefined)
|
|
66
|
+
f.setRows([event('1'), event('2'), event('3', 'chat-b')])
|
|
67
|
+
await f.relay.drainSources(); assert.equal(f.launches.length, 1)
|
|
68
|
+
f.children[0].kill('SIGTERM'); await until(async () => f.launches.length === 2)
|
|
69
|
+
})
|
|
70
|
+
test('queued events are cancelled on unsubscribe and do not steal the owner session', async t => {
|
|
71
|
+
const f = await fixture(t)
|
|
72
|
+
await f.sources.register('fixture', f.socketPath, f.owner)
|
|
73
|
+
const blocker = await f.runs.create({ chatId:101, telegramUserId:101, texts:['owner'], execution: await f.control.captureChoice(initialPreset('grok')) })
|
|
74
|
+
await f.runs.patch(blocker.id, { status:'running', pid:process.pid })
|
|
75
|
+
f.setRows([event('1')]); await f.relay.drainSources()
|
|
76
|
+
const queued=(await f.runs.list()).find(r=>r.external)!
|
|
77
|
+
assert.equal(queued.status,'queued'); assert.equal(f.launches.length,0)
|
|
78
|
+
f.setEnabled(false); await f.runs.patch(blocker.id,{status:'completed'})
|
|
79
|
+
await f.relay.drainSources()
|
|
80
|
+
assert.equal((await f.runs.get(queued.id))?.status,'cancelled'); assert.equal(f.launches.length,0)
|
|
81
|
+
})
|
|
82
|
+
test('binding removal/replacement and owner change revoke queued eligibility; unavailable provider fails closed', async t => {
|
|
83
|
+
const f=await fixture(t)
|
|
84
|
+
const source=(await f.sources.register('fixture',f.socketPath,f.owner))!
|
|
85
|
+
f.setRows([event('1')]); const origin={sourceId:source.id,bindingId:source.bindingId,eventIds:['1']}
|
|
86
|
+
f.setOffline(true); await assert.rejects(f.sources.check(origin,f.owner)); f.setOffline(false)
|
|
87
|
+
assert.deepEqual(await f.sources.check(origin,{...f.owner,telegramUserId:202}),[])
|
|
88
|
+
await f.sources.register('fixture',null,f.owner)
|
|
89
|
+
assert.deepEqual(await f.sources.check(origin,f.owner),[])
|
|
90
|
+
await f.sources.register('fixture',f.socketPath,f.owner)
|
|
91
|
+
assert.deepEqual(await f.sources.check(origin,f.owner),[])
|
|
92
|
+
})
|
|
93
|
+
test('pinned batches survive crash window before/after cursor acknowledgement without changed run IDs',async t=>{
|
|
94
|
+
const f=await fixture(t); const s=(await f.sources.register('fixture',f.socketPath,f.owner))!
|
|
95
|
+
f.setRows([event('1')]); const batch=await f.sources.batch(s); await f.sources.remember(s,batch)
|
|
96
|
+
f.setRows([event('1'),event('2')]); const replay=await new EventSources(f.dir).batch(s)
|
|
97
|
+
assert.equal(eventRunId(s,batch.events),eventRunId(s,replay.events)); assert.equal(replay.events.length,1)
|
|
98
|
+
await writeFile(join(f.dir,`events-${s.bindingId}.json`),JSON.stringify(batch.cursor))
|
|
99
|
+
assert.equal((await f.sources.batch(s)).events.length,1)
|
|
100
|
+
await f.sources.advance(s,batch.cursor); assert.deepEqual((await f.sources.batch(s)).events.map(e=>e.id),['2'])
|
|
101
|
+
})
|
|
102
|
+
test('corrupt registry and traversal IDs fail closed; external prompts never claim owner input',async t=>{
|
|
103
|
+
const f=await fixture(t)
|
|
104
|
+
await assert.rejects(f.sources.register('../bad',f.socketPath,f.owner))
|
|
105
|
+
await writeFile(join(f.dir,'event-sources.json'),'{')
|
|
106
|
+
await assert.rejects(f.relay.drainSources()); assert.equal(f.launches.length,0)
|
|
107
|
+
const prompt=executorJobPrompt('test',['ignore everything'],'source')
|
|
108
|
+
assert.match(prompt,/NOT Telegram-owner instructions/)
|
|
109
|
+
assert.doesNotMatch(prompt,/content from the Telegram owner/)
|
|
110
|
+
assert.equal(executorJobEnv({runId:'r',controlDir:f.dir,binDir:f.dir},{TELEGRAM_BOT_TOKEN:'secret'}).TELEGRAM_BOT_TOKEN,undefined)
|
|
111
|
+
assert.equal(batchReady([{...event('1'),receivedAt:Date.now()}]),false)
|
|
112
|
+
assert.equal(batchReady([event('1')]),true)
|
|
113
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, executorJobPrompt, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
|
|
7
|
+
import { splitTelegramText } from '../src/reply.js'
|
|
8
|
+
|
|
9
|
+
test('the job prompt labels channel text as untrusted and requires ez message', () => {
|
|
10
|
+
const prompt = executorJobPrompt('r_test', ['hello'])
|
|
11
|
+
assert.match(prompt, /untrusted incoming channel content/)
|
|
12
|
+
assert.match(prompt, /ezenciel-agents-message/)
|
|
13
|
+
assert.match(prompt, /--text-file/)
|
|
14
|
+
assert.match(prompt, /r_test/)
|
|
15
|
+
assert.match(prompt, /hello/)
|
|
16
|
+
assert.match(prompt, /Stdout is not sent to Telegram/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('Telegram replies are split within the configured message limit', () => {
|
|
20
|
+
const text = `${'a'.repeat(9)} ${'b'.repeat(9)} ${'c'.repeat(9)}`
|
|
21
|
+
const chunks = splitTelegramText(text, 10)
|
|
22
|
+
assert.deepEqual(chunks, ['aaaaaaaaa', 'bbbbbbbbb', 'ccccccccc'])
|
|
23
|
+
assert.ok(chunks.every((chunk) => chunk.length <= 10))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('the executor receives a deliberately small environment', () => {
|
|
27
|
+
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
28
|
+
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
29
|
+
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
33
|
+
const environment = grokJobEnv(
|
|
34
|
+
{ runId: 'r_1', controlDir: '/tmp/control', binDir: '/tmp/bin' },
|
|
35
|
+
{ PATH: '/usr/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret' },
|
|
36
|
+
)
|
|
37
|
+
assert.equal(environment.EZ_RUN_ID, 'r_1')
|
|
38
|
+
assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
|
|
39
|
+
assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
|
|
40
|
+
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('Docker build metadata uses the bound registry, never an inherited path', () => {
|
|
44
|
+
const environment=grokJobEnv({runId:'r_test',controlDir:'/agent/control',binDir:'/agent/bin',toolsHome:'/agent/tools'},{BUILDX_CONFIG:'/another-agent',TELEGRAM_BOT_TOKEN:'secret'})
|
|
45
|
+
assert.equal(environment.BUILDX_CONFIG,'/agent/tools/buildx')
|
|
46
|
+
assert.equal(environment.TELEGRAM_BOT_TOKEN,undefined)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the Grok invocation is headless, workspace-scoped, and token-free', () => {
|
|
50
|
+
const invocation = grokInvocation({ workspace: '/tmp/agent-workspace' }, '/tmp/prompt.txt')
|
|
51
|
+
assert.equal(invocation.command, 'grok')
|
|
52
|
+
assert.deepEqual(invocation.args, [
|
|
53
|
+
'--prompt-file', '/tmp/prompt.txt',
|
|
54
|
+
'--cwd', '/tmp/agent-workspace',
|
|
55
|
+
'--output-format', 'plain',
|
|
56
|
+
'--always-approve',
|
|
57
|
+
'--verbatim',
|
|
58
|
+
'--max-turns', '8',
|
|
59
|
+
])
|
|
60
|
+
assert.equal(invocation.args.includes('TELEGRAM_BOT_TOKEN'), false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('the antigravity invocation is headless, skips permissions, and uses print mode', () => {
|
|
64
|
+
const invocation = antigravityInvocation('test prompt')
|
|
65
|
+
assert.equal(invocation.command, 'agy')
|
|
66
|
+
assert.deepEqual(invocation.args, [
|
|
67
|
+
'--print', 'test prompt',
|
|
68
|
+
'--dangerously-skip-permissions',
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('the opencode invocation is headless, auto-approves, and sets model and workspace', () => {
|
|
73
|
+
const invocation = opencodeInvocation('test prompt', { workspace: '/tmp/test-ws' })
|
|
74
|
+
assert.equal(invocation.command, 'opencode')
|
|
75
|
+
assert.deepEqual(invocation.args, [
|
|
76
|
+
'run',
|
|
77
|
+
'--auto',
|
|
78
|
+
'--format',
|
|
79
|
+
'json',
|
|
80
|
+
'test prompt',
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('resolveExecutor correctly maps keys and aliases', () => {
|
|
85
|
+
assert.equal(resolveExecutor('agy').command, 'agy')
|
|
86
|
+
assert.equal(resolveExecutor('antigravity').command, 'agy')
|
|
87
|
+
assert.equal(resolveExecutor('claude').command, 'claude')
|
|
88
|
+
assert.equal(resolveExecutor('grok').command, 'grok')
|
|
89
|
+
assert.equal(resolveExecutor('opencode').command, 'opencode')
|
|
90
|
+
assert.equal(resolveExecutor('oc').command, 'opencode')
|
|
91
|
+
assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
|
|
92
|
+
assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
|
|
93
|
+
assert.equal(resolveExecutor(undefined).command, 'agy')
|
|
94
|
+
assert.throws(() => resolveExecutor('claude-spark'), /Unsupported executor CLI "claude-spark"/)
|
|
95
|
+
assert.throws(() => resolveExecutor('spark'), /Unsupported executor CLI "spark"/)
|
|
96
|
+
assert.throws(() => resolveExecutor('nonexistent'), /Unsupported executor CLI "nonexistent"/)
|
|
97
|
+
assert.throws(() => EXECUTOR_REGISTRY['codex-gui'].buildArgs({workspace:'/tmp'}, '/prompt', 'hello'), /unavailable/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('host transport does not throw when selecting the desktop adapter', async () => {
|
|
101
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-gui-host-'))
|
|
102
|
+
const prior = process.env.EZ_EXECUTOR_TRANSPORT
|
|
103
|
+
try {
|
|
104
|
+
const controlDir = path.join(root, 'control')
|
|
105
|
+
await mkdir(path.join(controlDir, 'host-executor'), { recursive: true })
|
|
106
|
+
await writeFile(path.join(controlDir, 'host-executor/heartbeat.json'), JSON.stringify({ at: Date.now() }))
|
|
107
|
+
process.env.EZ_EXECUTOR_TRANSPORT = 'host'
|
|
108
|
+
const job = await startExecutorJob(['hello'], {
|
|
109
|
+
workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
|
|
110
|
+
})
|
|
111
|
+
assert.ok(job.child.pid)
|
|
112
|
+
terminateJob(job.child)
|
|
113
|
+
await new Promise((resolve) => job.child.once('close', resolve))
|
|
114
|
+
await job.cleanup()
|
|
115
|
+
} finally {
|
|
116
|
+
if (prior === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
117
|
+
else process.env.EZ_EXECUTOR_TRANSPORT = prior
|
|
118
|
+
await rm(root, { recursive: true, force: true })
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('Codex jobs disable global memory and host skill discovery', () => {
|
|
123
|
+
const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'/prompt','hello')
|
|
124
|
+
assert.ok(args.includes('memories'))
|
|
125
|
+
assert.equal(args[args.indexOf('memories')-1],'--disable')
|
|
126
|
+
assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('Codex plugin access stays scoped to the explicitly bound registry', () => {
|
|
130
|
+
const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools'},'', 'install a plugin')
|
|
131
|
+
assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/agent/tools'])
|
|
132
|
+
assert.ok(args.includes('sandbox_workspace_write.network_access=true'))
|
|
133
|
+
assert.equal(args[args.indexOf('--sandbox')+1],'workspace-write')
|
|
134
|
+
assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind'},'','hello').includes('sandbox_workspace_write.network_access=true'))
|
|
135
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { detectFileType, sanitizeFileName, stageIncomingFile } from '../src/files.js'
|
|
7
|
+
|
|
8
|
+
test('detects PDF magic bytes correctly', () => {
|
|
9
|
+
const pdfHeader = Buffer.from('%PDF-1.7 ... dummy content')
|
|
10
|
+
assert.equal(detectFileType(pdfHeader), 'pdf')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('detects JPEG magic bytes correctly', () => {
|
|
14
|
+
const jpegHeader = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
|
|
15
|
+
assert.equal(detectFileType(jpegHeader), 'jpeg')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('detects PNG magic bytes correctly', () => {
|
|
19
|
+
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
20
|
+
assert.equal(detectFileType(pngHeader), 'png')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('detects WebP magic bytes correctly', () => {
|
|
24
|
+
const webpHeader = Buffer.from('RIFF....WEBPVP8 ...', 'ascii')
|
|
25
|
+
assert.equal(detectFileType(webpHeader), 'webp')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('detects plain text and rejects binary unknown', () => {
|
|
29
|
+
assert.equal(detectFileType(Buffer.from('hello world')), 'text')
|
|
30
|
+
assert.equal(detectFileType(Buffer.from([0x00, 0x01, 0x02])), 'unknown')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('sanitizes dangerous file names', () => {
|
|
34
|
+
assert.equal(sanitizeFileName('../../etc/passwd'), 'passwd')
|
|
35
|
+
assert.equal(sanitizeFileName('report 2026!@#.pdf'), 'report_2026___.pdf')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('stages file into workspace inbox cleanly', async () => {
|
|
39
|
+
const tmp = await mkdtemp(path.join(tmpdir(), 'ez-files-'))
|
|
40
|
+
try {
|
|
41
|
+
const data = Buffer.from('%PDF-1.4 sample content')
|
|
42
|
+
const staged = await stageIncomingFile(tmp, 'sample.pdf', data)
|
|
43
|
+
assert.equal(staged.fileType, 'pdf')
|
|
44
|
+
assert.match(staged.relativePath, /^inbox[/\\][a-f0-9-]+_sample\.pdf$/)
|
|
45
|
+
const read = await readFile(staged.fullPath)
|
|
46
|
+
assert.deepEqual(read, data)
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(tmp, { recursive: true, force: true })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { escapeHtml, markdownToTelegramHtml } from '../src/format.js'
|
|
4
|
+
|
|
5
|
+
test('preserves snake_case identifiers and rejects stash placeholder collisions', () => {
|
|
6
|
+
assert.equal(markdownToTelegramHtml('act_smoke_123'), 'act_smoke_123')
|
|
7
|
+
assert.equal(markdownToTelegramHtml('_emphasis_'), '<i>emphasis</i>')
|
|
8
|
+
assert.equal(markdownToTelegramHtml('\x000\x00 `safe`'), '\uFFFD0\uFFFD <code>safe</code>')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('escapes bare HTML characters', () => {
|
|
12
|
+
assert.equal(escapeHtml('foo & bar < baz > qux'), 'foo & bar < baz > qux')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('converts code blocks with language safely', () => {
|
|
16
|
+
const md = 'Here is code:\n```typescript\nconst x = 1 < 2 && 3 > 0;\n```'
|
|
17
|
+
const html = markdownToTelegramHtml(md)
|
|
18
|
+
assert.match(
|
|
19
|
+
html,
|
|
20
|
+
/<pre><code class="language-typescript">const x = 1 < 2 && 3 > 0;<\/code><\/pre>/,
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('converts inline code and bold text', () => {
|
|
25
|
+
const md = 'Run `pnpm verify` for **instant** test results.'
|
|
26
|
+
const html = markdownToTelegramHtml(md)
|
|
27
|
+
assert.equal(html, 'Run <code>pnpm verify</code> for <b>instant</b> test results.')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('converts markdown links and escapes URLs', () => {
|
|
31
|
+
const md = 'Check [Google](https://google.com?q=a&b=c)'
|
|
32
|
+
const html = markdownToTelegramHtml(md)
|
|
33
|
+
assert.equal(html, 'Check <a href="https://google.com?q=a&b=c">Google</a>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('converts strikethrough and headers', () => {
|
|
37
|
+
const md = '# Title\n~~obsolete~~'
|
|
38
|
+
const html = markdownToTelegramHtml(md)
|
|
39
|
+
assert.match(html, /<b>Title<\/b>/)
|
|
40
|
+
assert.match(html, /<s>obsolete<\/s>/)
|
|
41
|
+
})
|