@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.
Files changed (53) hide show
  1. package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  2. package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  3. package/dist/backend/client.d.ts +32 -2
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +8 -5
  6. package/dist/backend/format-sandbox-run.d.ts +4 -0
  7. package/dist/backend/format-sandbox-run.d.ts.map +1 -0
  8. package/dist/backend/format-sandbox-run.js +49 -0
  9. package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
  10. package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  11. package/dist/commands/backend.d.ts +12 -6
  12. package/dist/commands/backend.d.ts.map +1 -1
  13. package/dist/commands/backend.js +41 -15
  14. package/dist/commands/deploy.js +29 -16
  15. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  16. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  17. package/dist/esm/backend/client.d.ts +32 -2
  18. package/dist/esm/backend/client.d.ts.map +1 -1
  19. package/dist/esm/backend/client.mjs +8 -5
  20. package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
  21. package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
  22. package/dist/esm/backend/format-sandbox-run.mjs +47 -0
  23. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
  24. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  25. package/dist/esm/commands/backend.d.ts +12 -6
  26. package/dist/esm/commands/backend.d.ts.map +1 -1
  27. package/dist/esm/commands/backend.mjs +42 -16
  28. package/dist/esm/commands/deploy.mjs +29 -16
  29. package/dist/esm/index.mjs +9 -4
  30. package/dist/esm/util/broker/app.d.ts +2 -0
  31. package/dist/esm/util/broker/app.d.ts.map +1 -1
  32. package/dist/esm/util/broker/app.mjs +20 -7
  33. package/dist/index.js +9 -4
  34. package/dist/util/broker/app.d.ts +2 -0
  35. package/dist/util/broker/app.d.ts.map +1 -1
  36. package/dist/util/broker/app.js +20 -7
  37. package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
  38. package/handler-api/backend-runner/backend-runner.types.ts +3 -0
  39. package/handler-api/backend-runner/channels/channel-map.ts +12 -3
  40. package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
  41. package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
  42. package/package.json +2 -2
  43. package/src/backend/__tests__/client.test.ts +17 -13
  44. package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
  45. package/src/backend/client.ts +27 -10
  46. package/src/backend/format-sandbox-run.ts +51 -0
  47. package/src/commands/__tests__/backend-deploy.test.ts +42 -21
  48. package/src/commands/__tests__/backend-invoke.test.ts +125 -0
  49. package/src/commands/__tests__/deploy.test.ts +53 -0
  50. package/src/commands/backend.ts +57 -18
  51. package/src/commands/deploy.ts +31 -17
  52. package/src/index.ts +13 -5
  53. 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, invokeBackend, listAppIntegrations, removeIntegration } from '../client';
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 an invoke to the runner, authenticated', async () => {
46
- await invokeBackend('mf-reports', 'get_items', { n: 1 });
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/invoke/get_items');
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({ input: { n: 1 } });
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 invokeBackend('mf-a/../b', 'get_items', null);
79
+ await runSandbox('mf-a/../b', SANDBOX_BODY);
76
80
 
77
- expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/invoke/get_items');
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":"No backend deployed for \\"x\\""}' }));
92
+ vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
89
93
 
90
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow('No backend deployed for "x"');
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(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
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(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/<html> a b <\/html>/);
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(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(
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(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/non-JSON response/);
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
+ });
@@ -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 InvokeResponse {
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
- export function invokeBackend(appName: string, handlerName: string, input: unknown): Promise<InvokeResponse> {
68
- // The runner resolves the caller from the request or 401s, so an anonymous
69
- // invoke can never reach a handler.
70
- return request(
71
- 'POST',
72
- backendBaseUrl(),
73
- `/z2h-apps/${encodeURIComponent(appName)}/invoke/${encodeURIComponent(handlerName)}`,
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 the handlers directory holds nothing', async () => {
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 — the runner's S3 discovery still comes from meta.json.
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(['monday']);
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', () => {