@textcortex/zenocode 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/build-branded-opencode.mjs +41 -1
- package/scripts/build-branded-opencode.test.mjs +36 -1
- package/scripts/opencode-version.mjs +8 -1
- package/scripts/run-zenocode.test.mjs +3 -3
- package/scripts/runtime-smoke.mjs +223 -0
- package/scripts/runtime-smoke.test.mjs +81 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ npm install -g @textcortex/zenocode@latest
|
|
|
48
48
|
|
|
49
49
|
The terminal runtime never checks for, offers, or installs upstream OpenCode updates: the startup update check, the in-app "Update Available" prompt, and the runtime's `/global/upgrade` API are all removed at build time. Running `zenocode upgrade` prints the managed update command above.
|
|
50
50
|
|
|
51
|
-
Runtime package versions include a Zenocode revision suffix (for example, `1.18.10-zenocode.
|
|
51
|
+
Runtime package versions include a Zenocode revision suffix (for example, `1.18.10-zenocode.3`). Increment that revision when the Zenocode source overlay changes without an upstream version bump, so an immutable npm runtime release can always be replaced deliberately.
|
|
52
52
|
|
|
53
53
|
## Models
|
|
54
54
|
|
package/package.json
CHANGED
|
@@ -7,10 +7,12 @@ import path from 'node:path';
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import {
|
|
10
|
+
openCodeBunVersion,
|
|
10
11
|
openCodeRef,
|
|
11
12
|
openCodeVersion,
|
|
12
13
|
zenocodeRuntimeVersion,
|
|
13
14
|
} from './opencode-version.mjs';
|
|
15
|
+
import { smokeTestRuntimeBinary } from './runtime-smoke.mjs';
|
|
14
16
|
import { applyZenocodeSourceBranding } from './source-branding.mjs';
|
|
15
17
|
|
|
16
18
|
const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
|
|
@@ -229,6 +231,29 @@ async function runAndCapture(command, args, options = {}) {
|
|
|
229
231
|
});
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Fail unless the runtime is compiled with the Bun release that both Zenocode
|
|
236
|
+
* and the upstream checkout pin. Another Bun release can compile a runtime that
|
|
237
|
+
* passes `--version` but crashes on the first prompt.
|
|
238
|
+
*/
|
|
239
|
+
export function assertPinnedBunToolchain({ installedVersion, packageManager }) {
|
|
240
|
+
const expected = `bun@${openCodeBunVersion}`;
|
|
241
|
+
if (packageManager !== expected) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`upstream OpenCode pins ${packageManager || 'no packageManager'}, but ` +
|
|
244
|
+
`opencode-version.mjs pins ${expected}. Update openCodeBunVersion ` +
|
|
245
|
+
'together with openCodeVersion.'
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const installed = installedVersion.trim();
|
|
249
|
+
if (installed !== openCodeBunVersion) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Bun ${installed} is installed, but the Zenocode runtime must be built ` +
|
|
252
|
+
`with Bun ${openCodeBunVersion}.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
232
257
|
async function requireCommand(command, args = ['--version']) {
|
|
233
258
|
try {
|
|
234
259
|
await run(command, args, { stdio: 'ignore' });
|
|
@@ -807,8 +832,21 @@ async function main() {
|
|
|
807
832
|
cloneArgs.push(forkUrl, checkoutDir);
|
|
808
833
|
await run(gitCommand, cloneArgs);
|
|
809
834
|
|
|
835
|
+
const upstreamPackage = JSON.parse(
|
|
836
|
+
await fs.readFile(path.join(checkoutDir, 'package.json'), 'utf-8')
|
|
837
|
+
);
|
|
838
|
+
const { stdout: installedBunVersion } = await runAndCapture(bunCommand, [
|
|
839
|
+
'--version',
|
|
840
|
+
]);
|
|
841
|
+
assertPinnedBunToolchain({
|
|
842
|
+
installedVersion: installedBunVersion,
|
|
843
|
+
packageManager: upstreamPackage.packageManager,
|
|
844
|
+
});
|
|
845
|
+
|
|
810
846
|
await applyZenocodeSourceBranding(checkoutDir);
|
|
811
|
-
await run(bunCommand, ['install'], {
|
|
847
|
+
await run(bunCommand, ['install', '--frozen-lockfile'], {
|
|
848
|
+
cwd: checkoutDir,
|
|
849
|
+
});
|
|
812
850
|
await run(
|
|
813
851
|
bunCommand,
|
|
814
852
|
['./packages/opencode/script/build.ts', ...buildArgs],
|
|
@@ -872,6 +910,8 @@ async function main() {
|
|
|
872
910
|
if (process.platform !== 'win32') {
|
|
873
911
|
await fs.chmod(localBinaryPath, 0o755);
|
|
874
912
|
}
|
|
913
|
+
await smokeTestRuntimeBinary(localBinaryPath);
|
|
914
|
+
console.log(`Runtime smoke test passed: ${localBinaryPath}`);
|
|
875
915
|
|
|
876
916
|
console.log('\nZenocode branded build complete.');
|
|
877
917
|
console.log(`Branded binary: ${localBinaryPath}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import {
|
|
4
|
+
assertPinnedBunToolchain,
|
|
4
5
|
buildPublishCommandArgs,
|
|
5
6
|
buildWrapperBinMap,
|
|
6
7
|
binaryFilenameForPackage,
|
|
@@ -15,12 +16,13 @@ import {
|
|
|
15
16
|
trustedPublishingRepository,
|
|
16
17
|
zenocodeRuntimeVersion,
|
|
17
18
|
} from './build-branded-opencode.mjs';
|
|
19
|
+
import { openCodeBunVersion } from './opencode-version.mjs';
|
|
18
20
|
|
|
19
21
|
test('branded OpenCode build defaults to the latest stable upstream release', () => {
|
|
20
22
|
assert.equal(defaultOpenCodeForkUrl, 'https://github.com/sst/opencode');
|
|
21
23
|
assert.equal(defaultOpenCodeRef, 'v1.18.10');
|
|
22
24
|
assert.equal(defaultOpenCodeVersion, '1.18.10');
|
|
23
|
-
assert.equal(zenocodeRuntimeVersion, '1.18.10-zenocode.
|
|
25
|
+
assert.equal(zenocodeRuntimeVersion, '1.18.10-zenocode.3');
|
|
24
26
|
assert.deepEqual(defaultOpenCodeBuildArgs, []);
|
|
25
27
|
});
|
|
26
28
|
|
|
@@ -127,3 +129,36 @@ test('selectLatestTarball returns the sorted newest tarball', () => {
|
|
|
127
129
|
);
|
|
128
130
|
assert.equal(selectLatestTarball(['README.md']), null);
|
|
129
131
|
});
|
|
132
|
+
|
|
133
|
+
test('runtime builds use the Bun release that upstream pins', () => {
|
|
134
|
+
assert.equal(openCodeBunVersion, '1.3.14');
|
|
135
|
+
assert.doesNotThrow(() =>
|
|
136
|
+
assertPinnedBunToolchain({
|
|
137
|
+
installedVersion: '1.3.14\n',
|
|
138
|
+
packageManager: 'bun@1.3.14',
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('runtime builds refuse a Bun release other than the pinned one', () => {
|
|
144
|
+
// Bun 1.4.2 compiled a runtime that crashed on every prompt (0.1.15, 0.1.16).
|
|
145
|
+
assert.throws(
|
|
146
|
+
() =>
|
|
147
|
+
assertPinnedBunToolchain({
|
|
148
|
+
installedVersion: '1.4.2',
|
|
149
|
+
packageManager: 'bun@1.3.14',
|
|
150
|
+
}),
|
|
151
|
+
/Bun 1\.4\.2 is installed, but the Zenocode runtime must be built with Bun 1\.3\.14/
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('runtime builds refuse an upstream checkout that pins a different Bun', () => {
|
|
156
|
+
assert.throws(
|
|
157
|
+
() =>
|
|
158
|
+
assertPinnedBunToolchain({
|
|
159
|
+
installedVersion: '1.3.14',
|
|
160
|
+
packageManager: 'bun@1.3.20',
|
|
161
|
+
}),
|
|
162
|
+
/upstream OpenCode pins bun@1\.3\.20[\s\S]*openCodeBunVersion/
|
|
163
|
+
);
|
|
164
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export const openCodeVersion = '1.18.10';
|
|
2
2
|
export const openCodeRef = `v${openCodeVersion}`;
|
|
3
|
+
/**
|
|
4
|
+
* Bun release that compiles the runtime. It must equal the `packageManager`
|
|
5
|
+
* pin of the upstream OpenCode release above, and the build fails otherwise:
|
|
6
|
+
* a newer Bun compiled a runtime that crashed on every prompt while
|
|
7
|
+
* `--version` still worked. Change it together with `openCodeVersion`.
|
|
8
|
+
*/
|
|
9
|
+
export const openCodeBunVersion = '1.3.14';
|
|
3
10
|
export const zenocodeRuntimePackageName = '@textcortex/zenocode-ai';
|
|
4
|
-
export const zenocodeRuntimeRevision =
|
|
11
|
+
export const zenocodeRuntimeRevision = 3;
|
|
5
12
|
export const zenocodeRuntimeVersion = `${openCodeVersion}-zenocode.${zenocodeRuntimeRevision}`;
|
|
6
13
|
export const zenocodeRuntimePackageSpec = `${zenocodeRuntimePackageName}@${zenocodeRuntimeVersion}`;
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
test('Zenocode launches the pinned TextCortex-owned runtime by default', () => {
|
|
31
31
|
assert.equal(
|
|
32
32
|
resolveLaunchPackage(),
|
|
33
|
-
'@textcortex/zenocode-ai@1.18.10-zenocode.
|
|
33
|
+
'@textcortex/zenocode-ai@1.18.10-zenocode.3'
|
|
34
34
|
);
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -316,13 +316,13 @@ test('buildPackageLauncherInvocation launches the owned runtime package unchange
|
|
|
316
316
|
const invocation = buildPackageLauncherInvocation({
|
|
317
317
|
runner: {
|
|
318
318
|
command: 'pnpm',
|
|
319
|
-
args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.
|
|
319
|
+
args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.3', '--version'],
|
|
320
320
|
},
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
assert.deepEqual(invocation, {
|
|
324
324
|
command: 'pnpm',
|
|
325
|
-
args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.
|
|
325
|
+
args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.3', '--version'],
|
|
326
326
|
});
|
|
327
327
|
});
|
|
328
328
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildOpenCodeConfig,
|
|
13
|
+
buildOpenCodeTuiConfig,
|
|
14
|
+
} from './run-zenocode.mjs';
|
|
15
|
+
|
|
16
|
+
const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
|
|
17
|
+
const providerID = 'textcortex';
|
|
18
|
+
const smokeModel = 'zenocode-smoke';
|
|
19
|
+
const providerPathPrefix = '/internal/v2/fastapi/zenocode/v1';
|
|
20
|
+
|
|
21
|
+
/** Text the mock provider streams back; a working runtime prints it. */
|
|
22
|
+
export const smokeReply = 'zenocode-runtime-smoke-ok';
|
|
23
|
+
|
|
24
|
+
function chatCompletionChunk(delta, finishReason = null) {
|
|
25
|
+
return {
|
|
26
|
+
id: 'chatcmpl-zenocode-smoke',
|
|
27
|
+
object: 'chat.completion.chunk',
|
|
28
|
+
created: 0,
|
|
29
|
+
model: smokeModel,
|
|
30
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serve the Zenocode backend's OpenAI-compatible chat endpoint on loopback,
|
|
36
|
+
* streaming {@link smokeReply} for every completion request.
|
|
37
|
+
*/
|
|
38
|
+
async function startMockProvider() {
|
|
39
|
+
const state = { providerRequests: 0, unexpectedRequests: [] };
|
|
40
|
+
const server = http.createServer((request, response) => {
|
|
41
|
+
request.resume();
|
|
42
|
+
request.on('end', () => {
|
|
43
|
+
if (
|
|
44
|
+
request.method !== 'POST' ||
|
|
45
|
+
request.url !== `${providerPathPrefix}/chat/completions`
|
|
46
|
+
) {
|
|
47
|
+
state.unexpectedRequests.push(`${request.method} ${request.url}`);
|
|
48
|
+
response.writeHead(404).end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
state.providerRequests += 1;
|
|
52
|
+
response.writeHead(200, {
|
|
53
|
+
'content-type': 'text/event-stream',
|
|
54
|
+
'cache-control': 'no-cache',
|
|
55
|
+
});
|
|
56
|
+
const events = [
|
|
57
|
+
chatCompletionChunk({ role: 'assistant', content: smokeReply }),
|
|
58
|
+
{
|
|
59
|
+
...chatCompletionChunk({}, 'stop'),
|
|
60
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
for (const event of events) {
|
|
64
|
+
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
65
|
+
}
|
|
66
|
+
response.end('data: [DONE]\n\n');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
70
|
+
const { port } = server.address();
|
|
71
|
+
return {
|
|
72
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
73
|
+
state,
|
|
74
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Write the model catalog and configs the launcher would generate. */
|
|
79
|
+
async function writeRuntimeFiles(dir, baseUrl) {
|
|
80
|
+
const modelsPath = path.join(dir, 'models.json');
|
|
81
|
+
const configPath = path.join(dir, 'zenocode.jsonc');
|
|
82
|
+
const tuiConfigPath = path.join(dir, 'tui.json');
|
|
83
|
+
const catalog = {
|
|
84
|
+
[providerID]: {
|
|
85
|
+
id: providerID,
|
|
86
|
+
name: 'TextCortex',
|
|
87
|
+
api: new URL(providerPathPrefix, baseUrl).toString(),
|
|
88
|
+
env: ['TEXTCORTEX_API_KEY'],
|
|
89
|
+
models: {
|
|
90
|
+
[smokeModel]: {
|
|
91
|
+
id: smokeModel,
|
|
92
|
+
name: 'Zenocode smoke model',
|
|
93
|
+
attachment: false,
|
|
94
|
+
reasoning: false,
|
|
95
|
+
temperature: true,
|
|
96
|
+
tool_call: true,
|
|
97
|
+
limit: { context: 100000, output: 4096 },
|
|
98
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
99
|
+
options: {},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const config = buildOpenCodeConfig({
|
|
105
|
+
baseUrl,
|
|
106
|
+
providerID,
|
|
107
|
+
model: smokeModel,
|
|
108
|
+
smallModel: smokeModel,
|
|
109
|
+
});
|
|
110
|
+
await fs.writeFile(modelsPath, JSON.stringify(catalog), 'utf-8');
|
|
111
|
+
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8');
|
|
112
|
+
await fs.writeFile(
|
|
113
|
+
tuiConfigPath,
|
|
114
|
+
JSON.stringify(buildOpenCodeTuiConfig()),
|
|
115
|
+
'utf-8'
|
|
116
|
+
);
|
|
117
|
+
return { modelsPath, configPath, tuiConfigPath };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prove a compiled runtime can answer a prompt, not just print `--version`.
|
|
122
|
+
*
|
|
123
|
+
* Runs `<binary> run <prompt>` against a loopback mock of the Zenocode
|
|
124
|
+
* provider, in a throwaway HOME so no machine state leaks in. Resolves with the
|
|
125
|
+
* runtime output; rejects if the runtime crashes, hangs past `timeoutMs`, or
|
|
126
|
+
* exits without printing the streamed provider reply.
|
|
127
|
+
*/
|
|
128
|
+
export async function smokeTestRuntimeBinary(
|
|
129
|
+
binaryPath,
|
|
130
|
+
{ timeoutMs = 120_000 } = {}
|
|
131
|
+
) {
|
|
132
|
+
const provider = await startMockProvider();
|
|
133
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-smoke-'));
|
|
134
|
+
try {
|
|
135
|
+
const home = path.join(root, 'home');
|
|
136
|
+
const workdir = path.join(root, 'work');
|
|
137
|
+
await fs.mkdir(home, { recursive: true });
|
|
138
|
+
await fs.mkdir(workdir, { recursive: true });
|
|
139
|
+
const files = await writeRuntimeFiles(root, provider.baseUrl);
|
|
140
|
+
|
|
141
|
+
const child = spawn(binaryPath, ['run', 'Reply with the smoke token.'], {
|
|
142
|
+
cwd: workdir,
|
|
143
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
144
|
+
env: {
|
|
145
|
+
PATH: process.env.PATH,
|
|
146
|
+
SYSTEMROOT: process.env.SYSTEMROOT,
|
|
147
|
+
HOME: home,
|
|
148
|
+
USERPROFILE: home,
|
|
149
|
+
XDG_CONFIG_HOME: path.join(home, '.config'),
|
|
150
|
+
XDG_DATA_HOME: path.join(home, '.local', 'share'),
|
|
151
|
+
XDG_CACHE_HOME: path.join(home, '.cache'),
|
|
152
|
+
XDG_STATE_HOME: path.join(home, '.local', 'state'),
|
|
153
|
+
OPENCODE_MODELS_PATH: files.modelsPath,
|
|
154
|
+
OPENCODE_CONFIG: files.configPath,
|
|
155
|
+
OPENCODE_TUI_CONFIG: files.tuiConfigPath,
|
|
156
|
+
OPENCODE_DISABLE_AUTOUPDATE: 'true',
|
|
157
|
+
TEXTCORTEX_API_KEY: 'zenocode-smoke',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
let output = '';
|
|
161
|
+
child.stdout.on('data', (chunk) => (output += chunk.toString('utf-8')));
|
|
162
|
+
child.stderr.on('data', (chunk) => (output += chunk.toString('utf-8')));
|
|
163
|
+
|
|
164
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
child.kill('SIGKILL');
|
|
167
|
+
resolve({ timedOut: true });
|
|
168
|
+
}, timeoutMs);
|
|
169
|
+
child.on('error', (error) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
reject(error);
|
|
172
|
+
});
|
|
173
|
+
child.on('exit', (code) => {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve({ code });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const details = [
|
|
180
|
+
`provider requests: ${provider.state.providerRequests}`,
|
|
181
|
+
provider.state.unexpectedRequests.length
|
|
182
|
+
? `unexpected requests: ${provider.state.unexpectedRequests.join(', ')}`
|
|
183
|
+
: null,
|
|
184
|
+
`runtime output:\n${output.slice(-4000)}`,
|
|
185
|
+
]
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.join('\n');
|
|
188
|
+
if (outcome.timedOut) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Runtime smoke test did not finish within ${timeoutMs} ms.\n${details}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (outcome.code !== 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Runtime smoke test exited with code ${outcome.code}.\n${details}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (!output.includes(smokeReply)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Runtime smoke test did not print the provider reply.\n${details}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return { output, providerRequests: provider.state.providerRequests };
|
|
204
|
+
} finally {
|
|
205
|
+
await provider.close();
|
|
206
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === currentFilePath) {
|
|
211
|
+
const binaryPath = process.argv[2];
|
|
212
|
+
if (!binaryPath) {
|
|
213
|
+
console.error('Usage: node runtime-smoke.mjs <runtime-binary>');
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
216
|
+
smokeTestRuntimeBinary(path.resolve(binaryPath)).then(
|
|
217
|
+
() => console.log(`Runtime smoke test passed: ${binaryPath}`),
|
|
218
|
+
(error) => {
|
|
219
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { smokeReply, smokeTestRuntimeBinary } from './runtime-smoke.mjs';
|
|
8
|
+
|
|
9
|
+
/** Write an executable Node script that stands in for the runtime binary. */
|
|
10
|
+
async function writeFakeRuntime(body) {
|
|
11
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-smoke-test-'));
|
|
12
|
+
const filePath = path.join(dir, 'fake-runtime.mjs');
|
|
13
|
+
await fs.writeFile(filePath, `#!/usr/bin/env node\n${body}\n`, 'utf-8');
|
|
14
|
+
await fs.chmod(filePath, 0o755);
|
|
15
|
+
return filePath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Behaves like a working runtime: reads the generated config and model catalog,
|
|
19
|
+
// streams one chat completion from the configured provider, and prints it.
|
|
20
|
+
const workingRuntime = `
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
const config = JSON.parse(fs.readFileSync(process.env.OPENCODE_CONFIG, 'utf-8'));
|
|
23
|
+
const catalog = JSON.parse(fs.readFileSync(process.env.OPENCODE_MODELS_PATH, 'utf-8'));
|
|
24
|
+
const [providerID, model] = config.model.split('/');
|
|
25
|
+
if (!catalog[providerID]?.models?.[model]) process.exit(3);
|
|
26
|
+
const response = await fetch(config.provider[providerID].options.baseURL + '/chat/completions', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: 'Bearer ' + process.env.TEXTCORTEX_API_KEY,
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ model, stream: true, messages: [{ role: 'user', content: process.argv.at(-1) }] }),
|
|
33
|
+
});
|
|
34
|
+
let text = '';
|
|
35
|
+
for (const line of (await response.text()).split('\\n')) {
|
|
36
|
+
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
|
37
|
+
text += JSON.parse(line.slice(6)).choices[0]?.delta?.content ?? '';
|
|
38
|
+
}
|
|
39
|
+
console.log(text);
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
test('runtime smoke passes when the runtime streams a provider reply', async () => {
|
|
43
|
+
const binary = await writeFakeRuntime(workingRuntime);
|
|
44
|
+
|
|
45
|
+
const result = await smokeTestRuntimeBinary(binary);
|
|
46
|
+
|
|
47
|
+
assert.match(result.output, new RegExp(smokeReply));
|
|
48
|
+
assert.ok(result.providerRequests >= 1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('runtime smoke fails when the runtime crashes before the provider call', async () => {
|
|
52
|
+
// The Bun 1.4.2 build failed exactly like this: `--version` worked, but the
|
|
53
|
+
// first prompt died while building the system prompt.
|
|
54
|
+
const binary = await writeFakeRuntime(`
|
|
55
|
+
console.error('Error: { "name": "UnknownError", "message": "Unexpected server error." }');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
smokeTestRuntimeBinary(binary),
|
|
61
|
+
/exited with code 1[\s\S]*Unexpected server error/
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('runtime smoke fails when the runtime exits cleanly without a reply', async () => {
|
|
66
|
+
const binary = await writeFakeRuntime(`console.log('nothing to see');`);
|
|
67
|
+
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
smokeTestRuntimeBinary(binary),
|
|
70
|
+
/did not print the provider reply/
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('runtime smoke fails when the runtime hangs', async () => {
|
|
75
|
+
const binary = await writeFakeRuntime(`setInterval(() => {}, 1000);`);
|
|
76
|
+
|
|
77
|
+
await assert.rejects(
|
|
78
|
+
smokeTestRuntimeBinary(binary, { timeoutMs: 1500 }),
|
|
79
|
+
/did not finish within 1500 ms/
|
|
80
|
+
);
|
|
81
|
+
});
|