@mondaydotcomorg/z2h-cli 0.33.0 → 0.33.2
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/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/backend/client.d.ts +32 -2
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +8 -5
- package/dist/backend/format-sandbox-run.d.ts +4 -0
- package/dist/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/backend/format-sandbox-run.js +49 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +12 -6
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +41 -15
- package/dist/commands/deploy.js +29 -16
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/esm/backend/client.d.ts +32 -2
- package/dist/esm/backend/client.d.ts.map +1 -1
- package/dist/esm/backend/client.mjs +8 -5
- package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
- package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/esm/backend/format-sandbox-run.mjs +47 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +12 -6
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +42 -16
- package/dist/esm/commands/deploy.mjs +29 -16
- package/dist/esm/index.mjs +9 -4
- package/dist/esm/util/broker/app.d.ts +2 -0
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +20 -7
- package/dist/index.js +9 -4
- package/dist/util/broker/app.d.ts +2 -0
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +20 -7
- package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
- package/handler-api/backend-runner/backend-runner.types.ts +3 -0
- package/handler-api/backend-runner/channels/channel-map.ts +12 -3
- package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
- package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
- package/package.json +2 -2
- package/src/backend/__tests__/client.test.ts +17 -13
- package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
- package/src/backend/client.ts +27 -10
- package/src/backend/format-sandbox-run.ts +51 -0
- package/src/commands/__tests__/backend-deploy.test.ts +42 -21
- package/src/commands/__tests__/backend-invoke.test.ts +125 -0
- package/src/commands/__tests__/deploy.test.ts +53 -0
- package/src/commands/backend.ts +57 -18
- package/src/commands/deploy.ts +31 -17
- package/src/index.ts +13 -5
- package/src/util/broker/app.ts +59 -50
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { backendBaseUrl, declareIntegration,
|
|
3
|
+
import { backendBaseUrl, declareIntegration, listAppIntegrations, removeIntegration, runSandbox } from '../client';
|
|
4
4
|
|
|
5
5
|
vi.mock('../../util/auth/auth-store', () => ({
|
|
6
6
|
readZthAuth: vi.fn().mockResolvedValue({ token: 'jwt-token' }),
|
|
@@ -21,6 +21,8 @@ const respond = (init: { ok?: boolean; status?: number; statusText?: string; bod
|
|
|
21
21
|
|
|
22
22
|
const lastCall = () => (fetch as Mock).mock.calls[0];
|
|
23
23
|
|
|
24
|
+
const SANDBOX_BODY = { code: 'function handler() {}', input: null };
|
|
25
|
+
|
|
24
26
|
beforeEach(() => {
|
|
25
27
|
vi.stubGlobal('fetch', respond({ body: '{"result":1,"durationMs":5}' }));
|
|
26
28
|
});
|
|
@@ -42,13 +44,15 @@ describe('backendBaseUrl', () => {
|
|
|
42
44
|
});
|
|
43
45
|
|
|
44
46
|
describe('where each call goes', () => {
|
|
45
|
-
it('sends
|
|
46
|
-
|
|
47
|
+
it('sends a sandbox run to the runner, authenticated, with the code in the body', async () => {
|
|
48
|
+
const body = { code: 'function handler() {}', input: { n: 1 }, handlerName: 'get_items' };
|
|
49
|
+
await runSandbox('mf-reports', body);
|
|
47
50
|
|
|
48
51
|
const [url, opts] = lastCall();
|
|
49
|
-
expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/
|
|
52
|
+
expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/sandbox/run');
|
|
53
|
+
expect(opts.method).toBe('POST');
|
|
50
54
|
expect(opts.headers).toMatchObject({ Authorization: 'Bearer jwt-token', 'x-route': 'prod' });
|
|
51
|
-
expect(JSON.parse(opts.body)).toEqual(
|
|
55
|
+
expect(JSON.parse(opts.body)).toEqual(body);
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
// Integrations live in the platform's registry, which the broker owns — not the runner.
|
|
@@ -72,9 +76,9 @@ describe('where each call goes', () => {
|
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
it('escapes an app name rather than letting it alter the path', async () => {
|
|
75
|
-
await
|
|
79
|
+
await runSandbox('mf-a/../b', SANDBOX_BODY);
|
|
76
80
|
|
|
77
|
-
expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/
|
|
81
|
+
expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/sandbox/run');
|
|
78
82
|
});
|
|
79
83
|
});
|
|
80
84
|
|
|
@@ -85,27 +89,27 @@ describe('where each call goes', () => {
|
|
|
85
89
|
*/
|
|
86
90
|
describe('what the author is told when it fails', () => {
|
|
87
91
|
it('uses the server’s own message when there is one', async () => {
|
|
88
|
-
vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"
|
|
92
|
+
vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
|
|
89
93
|
|
|
90
|
-
await expect(
|
|
94
|
+
await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow('app "x" is not registered');
|
|
91
95
|
});
|
|
92
96
|
|
|
93
97
|
it('falls back to the status plus a snippet when the body is HTML', async () => {
|
|
94
98
|
vi.stubGlobal('fetch', respond({ ok: false, status: 502, statusText: 'Bad Gateway', body: '<html>nginx</html>' }));
|
|
95
99
|
|
|
96
|
-
await expect(
|
|
100
|
+
await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
|
|
97
101
|
});
|
|
98
102
|
|
|
99
103
|
it('collapses whitespace in that snippet so the message stays one line', async () => {
|
|
100
104
|
vi.stubGlobal('fetch', respond({ ok: false, status: 500, body: '<html>\n a\n\n b\n</html>' }));
|
|
101
105
|
|
|
102
|
-
await expect(
|
|
106
|
+
await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/<html> a b <\/html>/);
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
it('names the URL it could not reach when the request never lands', async () => {
|
|
106
110
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
|
107
111
|
|
|
108
|
-
await expect(
|
|
112
|
+
await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(
|
|
109
113
|
/cannot reach https:\/\/zth-backend-runner.*ECONNREFUSED/
|
|
110
114
|
);
|
|
111
115
|
});
|
|
@@ -113,7 +117,7 @@ describe('what the author is told when it fails', () => {
|
|
|
113
117
|
it('reports a 200 whose body is not JSON rather than returning nothing', async () => {
|
|
114
118
|
vi.stubGlobal('fetch', respond({ body: '<html>login</html>' }));
|
|
115
119
|
|
|
116
|
-
await expect(
|
|
120
|
+
await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/non-JSON response/);
|
|
117
121
|
});
|
|
118
122
|
|
|
119
123
|
// DELETE returns 204 with no body, which is success, not a parse failure.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatSandboxRun } from '../format-sandbox-run';
|
|
4
|
+
|
|
5
|
+
describe('formatSandboxRun', () => {
|
|
6
|
+
it('shows each call with its SQL on one line, params, and row count, then the result', () => {
|
|
7
|
+
const text = formatSandboxRun({
|
|
8
|
+
ok: true,
|
|
9
|
+
calls: [
|
|
10
|
+
{
|
|
11
|
+
method: 'snowflake.query',
|
|
12
|
+
args: ['SELECT region\n FROM accounts\n WHERE d >= :since', { since: '2026-01-01' }],
|
|
13
|
+
durationMs: 812.4,
|
|
14
|
+
ok: true,
|
|
15
|
+
result: [{ r: 1 }, { r: 2 }, { r: 3 }, { r: 4 }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
result: { regions: [] },
|
|
19
|
+
durationMs: 1200,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(text).toContain('sandbox run ✓ 1.2s');
|
|
23
|
+
expect(text).toContain('✓ snowflake.query 812ms 4 rows');
|
|
24
|
+
expect(text).toContain('SELECT region FROM accounts WHERE d >= :since');
|
|
25
|
+
expect(text).toContain('params: {"since":"2026-01-01"}');
|
|
26
|
+
expect(text).toContain('result:\n{\n "regions": []\n}');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('ends with the error instead of a result on failure', () => {
|
|
30
|
+
const text = formatSandboxRun({
|
|
31
|
+
ok: false,
|
|
32
|
+
error: { status: 504, code: 'isolate_timeout', message: 'Script execution timed out.' },
|
|
33
|
+
calls: [],
|
|
34
|
+
result: null,
|
|
35
|
+
durationMs: 5000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(text).toContain('sandbox run ✗ 5.0s');
|
|
39
|
+
expect(text).toContain('error: 504 isolate_timeout — Script execution timed out.');
|
|
40
|
+
expect(text).not.toContain('result:');
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/backend/client.ts
CHANGED
|
@@ -59,20 +59,37 @@ async function request<T>(method: string, baseUrl: string, path: string, body?:
|
|
|
59
59
|
return (parsed ?? ({} as T)) as T;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export interface
|
|
62
|
+
export interface SandboxRunRequest {
|
|
63
|
+
code: string;
|
|
64
|
+
input: unknown;
|
|
65
|
+
handlerName?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One channel call the handler made, as the runner traced it. */
|
|
69
|
+
export interface SandboxCall {
|
|
70
|
+
method: string;
|
|
71
|
+
args: unknown[];
|
|
72
|
+
durationMs: number;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
error?: { status: number; code?: string; message: string };
|
|
75
|
+
result?: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SandboxRunResponse {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
error?: { status: number; code?: string; message: string };
|
|
81
|
+
calls: SandboxCall[];
|
|
63
82
|
result: unknown;
|
|
64
83
|
durationMs: number;
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{ input }
|
|
75
|
-
);
|
|
86
|
+
/**
|
|
87
|
+
* Runs handler source from the working tree on the runner's sandbox route —
|
|
88
|
+
* against the app's real channels, without deploying it. A handler failure
|
|
89
|
+
* comes back as `ok: false` with the trace; only a refused request throws.
|
|
90
|
+
*/
|
|
91
|
+
export function runSandbox(appName: string, body: SandboxRunRequest): Promise<SandboxRunResponse> {
|
|
92
|
+
return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/sandbox/run`, body);
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
export interface AppIntegrationsResponse {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SandboxCall, SandboxRunResponse } from './client';
|
|
2
|
+
|
|
3
|
+
const MAX_SQL_CHARS = 400;
|
|
4
|
+
|
|
5
|
+
const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
|
|
6
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
7
|
+
const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max)}…` : text);
|
|
8
|
+
|
|
9
|
+
/** The first arg is the SQL / GraphQL text for query channels — shown on its own line so it can be read. */
|
|
10
|
+
function describeArgs(args: unknown[]): string[] {
|
|
11
|
+
const [first, ...rest] = args;
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
if (typeof first === 'string') {
|
|
14
|
+
lines.push(clip(oneLine(first), MAX_SQL_CHARS));
|
|
15
|
+
} else if (first !== undefined) {
|
|
16
|
+
lines.push(clip(JSON.stringify(first), MAX_SQL_CHARS));
|
|
17
|
+
}
|
|
18
|
+
const params = rest.filter(arg => arg !== undefined && arg !== null);
|
|
19
|
+
if (params.length > 0) {
|
|
20
|
+
lines.push(`params: ${clip(params.map(p => JSON.stringify(p)).join(', '), MAX_SQL_CHARS)}`);
|
|
21
|
+
}
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function describeCall(call: SandboxCall): string[] {
|
|
26
|
+
const rows = Array.isArray(call.result) ? ` ${call.result.length} rows` : '';
|
|
27
|
+
const head = ` ${call.ok ? '✓' : '✗'} ${call.method} ${Math.round(call.durationMs)}ms${rows}`;
|
|
28
|
+
const detail = describeArgs(call.args);
|
|
29
|
+
if (call.error) {
|
|
30
|
+
const code = call.error.code ? ` ${call.error.code}` : '';
|
|
31
|
+
detail.push(`${call.error.status}${code}: ${call.error.message}`);
|
|
32
|
+
}
|
|
33
|
+
return [head, ...detail.map(line => ` ${line}`)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
|
|
37
|
+
export function formatSandboxRun(res: SandboxRunResponse): string {
|
|
38
|
+
const lines = [`[z2h-cli] sandbox run ${res.ok ? '✓' : '✗'} ${seconds(res.durationMs)}`, ''];
|
|
39
|
+
|
|
40
|
+
lines.push(res.calls.length > 0 ? 'calls:' : 'calls: none');
|
|
41
|
+
res.calls.forEach(call => lines.push(...describeCall(call)));
|
|
42
|
+
|
|
43
|
+
lines.push('');
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
lines.push('result:', JSON.stringify(res.result, null, 2));
|
|
46
|
+
} else if (res.error) {
|
|
47
|
+
const code = res.error.code ? ` ${res.error.code}` : '';
|
|
48
|
+
lines.push(`error: ${res.error.status}${code} — ${res.error.message}`);
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
@@ -63,7 +63,29 @@ describe('deployBackend (live)', () => {
|
|
|
63
63
|
await rm(tmp, { recursive: true, force: true });
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it('skips upload when
|
|
66
|
+
it('skips upload and resets usage.json when there is no handlers directory', async () => {
|
|
67
|
+
// hasHandlers is false here, so readHandlers is never reached — no mock needed.
|
|
68
|
+
await expect(
|
|
69
|
+
deployBackend({
|
|
70
|
+
appDir: tmp,
|
|
71
|
+
appName: 'mf-my-app',
|
|
72
|
+
mfKeyPrefix: 'mf-my-app/1',
|
|
73
|
+
version: 1,
|
|
74
|
+
deployedBy: 'test-user',
|
|
75
|
+
isPreview: false,
|
|
76
|
+
})
|
|
77
|
+
).resolves.toEqual({ integrations: [], handlerNames: [], apiUsages: {} });
|
|
78
|
+
expect(mockSend).not.toHaveBeenCalled();
|
|
79
|
+
await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
|
|
80
|
+
appName: 'mf-my-app',
|
|
81
|
+
deployedBy: 'test-user',
|
|
82
|
+
apiUsages: {},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('skips upload and resets usage.json when backend/handlers exists but has no handler sources', async () => {
|
|
87
|
+
await ensureDir(path.join(tmp, 'backend', 'handlers'));
|
|
88
|
+
await writeJson(path.join(tmp, 'usage.json'), { apiUsages: { deleted_handler: {} }, keepMe: 1 });
|
|
67
89
|
readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
|
|
68
90
|
|
|
69
91
|
await expect(
|
|
@@ -75,8 +97,14 @@ describe('deployBackend (live)', () => {
|
|
|
75
97
|
deployedBy: 'test-user',
|
|
76
98
|
isPreview: false,
|
|
77
99
|
})
|
|
78
|
-
).resolves.toEqual([]);
|
|
100
|
+
).resolves.toEqual({ integrations: [], handlerNames: [], apiUsages: {} });
|
|
79
101
|
expect(mockSend).not.toHaveBeenCalled();
|
|
102
|
+
await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
|
|
103
|
+
keepMe: 1,
|
|
104
|
+
appName: 'mf-my-app',
|
|
105
|
+
deployedBy: 'test-user',
|
|
106
|
+
apiUsages: {},
|
|
107
|
+
});
|
|
80
108
|
});
|
|
81
109
|
|
|
82
110
|
it('transpiles and uploads handlers when backend/handlers exists', async () => {
|
|
@@ -98,11 +126,16 @@ describe('deployBackend (live)', () => {
|
|
|
98
126
|
deployedBy: 'test-user',
|
|
99
127
|
isPreview: false,
|
|
100
128
|
})
|
|
101
|
-
).resolves.toEqual(
|
|
129
|
+
).resolves.toEqual({
|
|
130
|
+
integrations: [],
|
|
131
|
+
handlerNames: ['call_snowflake'],
|
|
132
|
+
apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
|
|
133
|
+
});
|
|
102
134
|
expect(mockSend).toHaveBeenCalled();
|
|
103
135
|
const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
|
|
104
136
|
expect(keys.some(k => k.includes('/backend-1/'))).toBe(true);
|
|
105
|
-
// usage.json stays local-only
|
|
137
|
+
// usage.json stays local-only. meta.json is still uploaded as the S3 fallback,
|
|
138
|
+
// used only for a row whose `apps.handler_names` column is still null.
|
|
106
139
|
expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
|
|
107
140
|
expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
|
|
108
141
|
|
|
@@ -191,22 +224,6 @@ describe('deployBackend (preview)', () => {
|
|
|
191
224
|
await rm(tmp, { recursive: true, force: true });
|
|
192
225
|
});
|
|
193
226
|
|
|
194
|
-
it('skips upload when the handlers directory holds nothing', async () => {
|
|
195
|
-
readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
|
|
196
|
-
|
|
197
|
-
await expect(
|
|
198
|
-
deployBackend({
|
|
199
|
-
appDir: tmp,
|
|
200
|
-
appName: 'mf-zth-preview-abc12345-my-app',
|
|
201
|
-
mfKeyPrefix: 'previews/mf-zth-preview-abc12345-my-app/MF',
|
|
202
|
-
version: 1,
|
|
203
|
-
deployedBy: 'test-user',
|
|
204
|
-
isPreview: true,
|
|
205
|
-
})
|
|
206
|
-
).resolves.toEqual([]);
|
|
207
|
-
expect(mockSend).not.toHaveBeenCalled();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
227
|
it('uploads handlers under the MS prefix for preview', async () => {
|
|
211
228
|
await ensureDir(path.join(tmp, 'backend', 'handlers'));
|
|
212
229
|
await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
|
|
@@ -225,7 +242,11 @@ describe('deployBackend (preview)', () => {
|
|
|
225
242
|
deployedBy: 'test-user',
|
|
226
243
|
isPreview: true,
|
|
227
244
|
})
|
|
228
|
-
).resolves.toEqual(
|
|
245
|
+
).resolves.toEqual({
|
|
246
|
+
integrations: ['monday'],
|
|
247
|
+
handlerNames: ['call_snowflake'],
|
|
248
|
+
apiUsages: { call_snowflake: { monday: { query: [{ query: 'x' }] } } },
|
|
249
|
+
});
|
|
229
250
|
expect(mockSend).toHaveBeenCalled();
|
|
230
251
|
const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
|
|
231
252
|
expect(keys.some(k => k.includes('/MS/'))).toBe(true);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { backendInvokeCommand } from '../backend';
|
|
7
|
+
import { runSandbox } from '../../backend/client';
|
|
8
|
+
import { output } from '../../util/logger';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../util/logger', () => ({
|
|
11
|
+
info: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
output: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../../util/tracker', () => ({
|
|
16
|
+
trackEvent: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
trackDuration: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('../../util/s3/client', () => ({ getS3Client: vi.fn() }));
|
|
20
|
+
vi.mock('../../backend/client', () => ({ runSandbox: vi.fn() }));
|
|
21
|
+
vi.mock('../../util/app-name', async importOriginal => ({
|
|
22
|
+
...(await importOriginal<typeof import('../../util/app-name')>()),
|
|
23
|
+
getCwdAppName: () => 'sales-kpis',
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const runSandboxMock = vi.mocked(runSandbox);
|
|
27
|
+
const outputMock = vi.mocked(output);
|
|
28
|
+
|
|
29
|
+
const OK = { ok: true, calls: [], result: { n: 1 }, durationMs: 12 };
|
|
30
|
+
|
|
31
|
+
describe('backendInvokeCommand', () => {
|
|
32
|
+
let tmp: string;
|
|
33
|
+
|
|
34
|
+
const writeHandler = async (name: string, source: string) => {
|
|
35
|
+
const dir = path.join(tmp, 'backend', 'handlers');
|
|
36
|
+
await ensureDir(dir);
|
|
37
|
+
await writeFile(path.join(dir, `${name}.js`), source);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-backend-invoke-'));
|
|
42
|
+
runSandboxMock.mockReset();
|
|
43
|
+
runSandboxMock.mockResolvedValue(OK);
|
|
44
|
+
outputMock.mockClear();
|
|
45
|
+
process.exitCode = undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
await rm(tmp, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sends the handler file from the working tree', async () => {
|
|
54
|
+
const source = 'async function handler(ctx) { return ctx.api.v1.monday.query("query { me { id } }"); }';
|
|
55
|
+
await writeHandler('get_kpis', source);
|
|
56
|
+
|
|
57
|
+
await backendInvokeCommand('get_kpis', { input: '{"since":"2026-01-01"}' }, tmp);
|
|
58
|
+
|
|
59
|
+
expect(runSandboxMock).toHaveBeenCalledWith('mf-sales-kpis', {
|
|
60
|
+
code: source,
|
|
61
|
+
input: { since: '2026-01-01' },
|
|
62
|
+
handlerName: 'get_kpis',
|
|
63
|
+
});
|
|
64
|
+
expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('sandbox run ✓'), OK);
|
|
65
|
+
expect(process.exitCode).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('runs --code instead of the file', async () => {
|
|
69
|
+
await backendInvokeCommand('scratch', { code: 'function handler() { return 1; }' }, tmp);
|
|
70
|
+
|
|
71
|
+
expect(runSandboxMock.mock.calls[0][1]).toMatchObject({ code: 'function handler() { return 1; }', input: null });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('fails locally, without a request, when the source breaks an isolate rule', async () => {
|
|
75
|
+
await writeHandler('get_kpis', "import x from 'y';\nfunction handler() {}");
|
|
76
|
+
|
|
77
|
+
await expect(backendInvokeCommand('get_kpis', {}, tmp)).rejects.toThrow();
|
|
78
|
+
expect(runSandboxMock).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('names the available handlers when the one asked for does not exist', async () => {
|
|
82
|
+
await writeHandler('get_kpis', 'function handler() {}');
|
|
83
|
+
await writeHandler('list_items', 'function handler() {}');
|
|
84
|
+
|
|
85
|
+
await expect(backendInvokeCommand('get_kpi', {}, tmp)).rejects.toThrow(
|
|
86
|
+
'no handler "get_kpi" in backend/handlers/ (available: get_kpis, list_items)'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects --input that is not JSON', async () => {
|
|
91
|
+
await expect(backendInvokeCommand('get_kpis', { input: '{bad' }, tmp)).rejects.toThrow(
|
|
92
|
+
/--input must be valid JSON/
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('prints the trace and exits 1 when the handler fails', async () => {
|
|
97
|
+
await writeHandler('get_kpis', 'async function handler(ctx) { return ctx.api.v1.snowflake.query("SELECT ar"); }');
|
|
98
|
+
const failed = {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
|
|
101
|
+
calls: [
|
|
102
|
+
{
|
|
103
|
+
method: 'snowflake.query',
|
|
104
|
+
args: ['SELECT ar'],
|
|
105
|
+
durationMs: 640,
|
|
106
|
+
ok: false,
|
|
107
|
+
error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
result: null,
|
|
111
|
+
durationMs: 900,
|
|
112
|
+
};
|
|
113
|
+
runSandboxMock.mockResolvedValue(failed);
|
|
114
|
+
|
|
115
|
+
await backendInvokeCommand('get_kpis', {}, tmp);
|
|
116
|
+
|
|
117
|
+
const [human, data] = outputMock.mock.calls[0];
|
|
118
|
+
expect(data).toBe(failed);
|
|
119
|
+
expect(human).toContain('✗ snowflake.query 640ms');
|
|
120
|
+
expect(human).toContain('SELECT ar');
|
|
121
|
+
expect(human).toContain("400 snowflake_query_error: invalid identifier 'AR'");
|
|
122
|
+
expect(human).toContain("error: 400 snowflake_query_error — invalid identifier 'AR'");
|
|
123
|
+
expect(process.exitCode).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -7,7 +7,9 @@ import { deployCommand } from '../deploy';
|
|
|
7
7
|
import { DeployError, reasonOf } from '../../util/errors';
|
|
8
8
|
import { buildPreviewSlug } from '../../util/app-name';
|
|
9
9
|
import { setOutputMode } from '../../util/logger';
|
|
10
|
+
import * as loggerModule from '../../util/logger';
|
|
10
11
|
import { buildCommand } from '../build';
|
|
12
|
+
import * as backendCmd from '../backend';
|
|
11
13
|
import { uploadDir } from '../../util/s3/upload';
|
|
12
14
|
import { setupS3Client } from '../../util/s3/client';
|
|
13
15
|
import { openInBrowser } from '../../util/open-browser';
|
|
@@ -20,6 +22,15 @@ vi.mock('../build', () => ({
|
|
|
20
22
|
buildCommand: vi.fn(),
|
|
21
23
|
}));
|
|
22
24
|
|
|
25
|
+
// Wraps the real `deployBackend` so most tests exercise it unchanged (no
|
|
26
|
+
// backend/handlers dir in the tmp fixture, so it resolves trivially), while
|
|
27
|
+
// letting individual tests override it to prove the upload/register ordering
|
|
28
|
+
// and passthrough of real handlerNames/apiUsages.
|
|
29
|
+
vi.mock('../backend', async importOriginal => {
|
|
30
|
+
const actual = await importOriginal<typeof import('../backend')>();
|
|
31
|
+
return { ...actual, deployBackend: vi.fn(actual.deployBackend) };
|
|
32
|
+
});
|
|
33
|
+
|
|
23
34
|
vi.mock('../../util/s3/upload', () => ({
|
|
24
35
|
uploadDir: vi.fn().mockResolvedValue([]),
|
|
25
36
|
}));
|
|
@@ -97,6 +108,7 @@ describe('deploy --preview', () => {
|
|
|
97
108
|
buildDir: path.join(tmp, '.zth', 'build'),
|
|
98
109
|
assetManifest: ASSET_MANIFEST,
|
|
99
110
|
});
|
|
111
|
+
vi.mocked(backendCmd.deployBackend).mockClear();
|
|
100
112
|
vi.mocked(uploadDir).mockClear();
|
|
101
113
|
vi.mocked(setupS3Client).mockClear();
|
|
102
114
|
vi.mocked(openInBrowser).mockClear();
|
|
@@ -162,6 +174,8 @@ describe('deploy --preview', () => {
|
|
|
162
174
|
entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 3, gitRemote: '' }),
|
|
163
175
|
gitRemote: '',
|
|
164
176
|
integrations: [],
|
|
177
|
+
handlerNames: [],
|
|
178
|
+
apiUsages: {},
|
|
165
179
|
masterAppName: 'mf-my-app',
|
|
166
180
|
});
|
|
167
181
|
});
|
|
@@ -180,6 +194,8 @@ describe('deploy --preview', () => {
|
|
|
180
194
|
entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 1, gitRemote: '' }),
|
|
181
195
|
gitRemote: '',
|
|
182
196
|
integrations: [],
|
|
197
|
+
handlerNames: [],
|
|
198
|
+
apiUsages: {},
|
|
183
199
|
masterAppName: 'mf-my-app',
|
|
184
200
|
});
|
|
185
201
|
});
|
|
@@ -274,6 +290,43 @@ describe('deploy --preview', () => {
|
|
|
274
290
|
|
|
275
291
|
writeSpy.mockRestore();
|
|
276
292
|
});
|
|
293
|
+
|
|
294
|
+
it('never uploads the frontend or registers when deployBackend fails', async () => {
|
|
295
|
+
vi.mocked(backendCmd.deployBackend).mockRejectedValueOnce(new Error('backend upload exploded'));
|
|
296
|
+
|
|
297
|
+
await expect(deployCommand({ preview: true })).rejects.toThrow('backend upload exploded');
|
|
298
|
+
|
|
299
|
+
expect(uploadDir).not.toHaveBeenCalled();
|
|
300
|
+
expect(brokerApp.registerOrUpdateApp).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('warns that a retry will repair it when the frontend upload fails after the backend already deployed', async () => {
|
|
304
|
+
vi.mocked(uploadDir).mockRejectedValueOnce(new Error('S3 exploded'));
|
|
305
|
+
const warnSpy = vi.spyOn(loggerModule, 'warn').mockImplementation(() => {});
|
|
306
|
+
|
|
307
|
+
await expect(deployCommand({ preview: true })).rejects.toThrow('S3 exploded');
|
|
308
|
+
|
|
309
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('re-run "z2h-cli deploy --preview"'));
|
|
310
|
+
warnSpy.mockRestore();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('forwards the real handlerNames and apiUsages deployBackend reports to registerOrUpdateApp', async () => {
|
|
314
|
+
vi.mocked(backendCmd.deployBackend).mockResolvedValueOnce({
|
|
315
|
+
integrations: ['snowflake'],
|
|
316
|
+
handlerNames: ['call_snowflake'],
|
|
317
|
+
apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await deployCommand({ preview: true });
|
|
321
|
+
|
|
322
|
+
expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith(
|
|
323
|
+
expect.objectContaining({
|
|
324
|
+
integrations: ['snowflake'],
|
|
325
|
+
handlerNames: ['call_snowflake'],
|
|
326
|
+
apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
|
|
327
|
+
})
|
|
328
|
+
);
|
|
329
|
+
});
|
|
277
330
|
});
|
|
278
331
|
|
|
279
332
|
describe('deploy failure reasons', () => {
|