@mondaydotcomorg/z2h-cli 0.27.1 → 0.28.0

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 (116) hide show
  1. package/dist/backend/__tests__/ast.test.d.ts +2 -0
  2. package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
  3. package/dist/backend/__tests__/parse.test.d.ts +2 -0
  4. package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
  5. package/dist/backend/__tests__/validate.test.d.ts +2 -0
  6. package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
  7. package/dist/backend/ast.d.ts +41 -0
  8. package/dist/backend/ast.d.ts.map +1 -0
  9. package/dist/backend/ast.js +152 -0
  10. package/dist/backend/detect-integrations.d.ts +8 -4
  11. package/dist/backend/detect-integrations.d.ts.map +1 -1
  12. package/dist/backend/detect-integrations.js +12 -15
  13. package/dist/backend/handler-declaration.d.ts +17 -0
  14. package/dist/backend/handler-declaration.d.ts.map +1 -0
  15. package/dist/backend/handler-declaration.js +83 -0
  16. package/dist/backend/handlers.d.ts +2 -0
  17. package/dist/backend/handlers.d.ts.map +1 -1
  18. package/dist/backend/handlers.js +7 -36
  19. package/dist/backend/messages.d.ts +10 -0
  20. package/dist/backend/messages.d.ts.map +1 -0
  21. package/dist/backend/messages.js +14 -0
  22. package/dist/backend/parse.d.ts +11 -0
  23. package/dist/backend/parse.d.ts.map +1 -0
  24. package/dist/backend/parse.js +66 -0
  25. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  26. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  27. package/dist/backend/rules/index.d.ts +5 -0
  28. package/dist/backend/rules/index.d.ts.map +1 -0
  29. package/dist/backend/rules/index.js +8 -0
  30. package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
  31. package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  32. package/dist/backend/rules/snowflake-static-sql.js +75 -0
  33. package/dist/backend/rules/types.d.ts +29 -0
  34. package/dist/backend/rules/types.d.ts.map +1 -0
  35. package/dist/backend/rules/types.js +1 -0
  36. package/dist/backend/validate.d.ts +23 -0
  37. package/dist/backend/validate.d.ts.map +1 -0
  38. package/dist/backend/validate.js +64 -0
  39. package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
  40. package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  41. package/dist/commands/backend.d.ts +6 -0
  42. package/dist/commands/backend.d.ts.map +1 -1
  43. package/dist/commands/backend.js +22 -2
  44. package/dist/constants.d.ts +1 -2
  45. package/dist/constants.d.ts.map +1 -1
  46. package/dist/constants.js +10 -6
  47. package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
  48. package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
  49. package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
  50. package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
  51. package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
  52. package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
  53. package/dist/esm/backend/ast.d.ts +41 -0
  54. package/dist/esm/backend/ast.d.ts.map +1 -0
  55. package/dist/esm/backend/ast.mjs +146 -0
  56. package/dist/esm/backend/detect-integrations.d.ts +8 -4
  57. package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
  58. package/dist/esm/backend/detect-integrations.mjs +12 -15
  59. package/dist/esm/backend/handler-declaration.d.ts +17 -0
  60. package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
  61. package/dist/esm/backend/handler-declaration.mjs +78 -0
  62. package/dist/esm/backend/handlers.d.ts +2 -0
  63. package/dist/esm/backend/handlers.d.ts.map +1 -1
  64. package/dist/esm/backend/handlers.mjs +7 -36
  65. package/dist/esm/backend/messages.d.ts +10 -0
  66. package/dist/esm/backend/messages.d.ts.map +1 -0
  67. package/dist/esm/backend/messages.mjs +12 -0
  68. package/dist/esm/backend/parse.d.ts +11 -0
  69. package/dist/esm/backend/parse.d.ts.map +1 -0
  70. package/dist/esm/backend/parse.mjs +64 -0
  71. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  72. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  73. package/dist/esm/backend/rules/index.d.ts +5 -0
  74. package/dist/esm/backend/rules/index.d.ts.map +1 -0
  75. package/dist/esm/backend/rules/index.mjs +6 -0
  76. package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
  77. package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  78. package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
  79. package/dist/esm/backend/rules/types.d.ts +29 -0
  80. package/dist/esm/backend/rules/types.d.ts.map +1 -0
  81. package/dist/esm/backend/rules/types.mjs +1 -0
  82. package/dist/esm/backend/validate.d.ts +23 -0
  83. package/dist/esm/backend/validate.d.ts.map +1 -0
  84. package/dist/esm/backend/validate.mjs +62 -0
  85. package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
  86. package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  87. package/dist/esm/commands/backend.d.ts +6 -0
  88. package/dist/esm/commands/backend.d.ts.map +1 -1
  89. package/dist/esm/commands/backend.mjs +23 -4
  90. package/dist/esm/constants.d.ts +1 -2
  91. package/dist/esm/constants.d.ts.map +1 -1
  92. package/dist/esm/constants.mjs +3 -6
  93. package/dist/esm/index.mjs +6 -2
  94. package/dist/index.js +5 -1
  95. package/package.json +4 -2
  96. package/src/backend/__tests__/ast.test.ts +53 -0
  97. package/src/backend/__tests__/detect-integrations.test.ts +12 -30
  98. package/src/backend/__tests__/handlers.test.ts +21 -34
  99. package/src/backend/__tests__/parse.test.ts +25 -0
  100. package/src/backend/__tests__/validate.test.ts +246 -0
  101. package/src/backend/ast.ts +204 -0
  102. package/src/backend/detect-integrations.ts +12 -14
  103. package/src/backend/handler-declaration.ts +104 -0
  104. package/src/backend/handlers.ts +9 -44
  105. package/src/backend/messages.ts +16 -0
  106. package/src/backend/parse.ts +82 -0
  107. package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
  108. package/src/backend/rules/index.ts +7 -0
  109. package/src/backend/rules/snowflake-static-sql.ts +78 -0
  110. package/src/backend/rules/types.ts +30 -0
  111. package/src/backend/validate.ts +73 -0
  112. package/src/commands/__tests__/backend-deploy.test.ts +5 -3
  113. package/src/commands/__tests__/backend-validate.test.ts +71 -0
  114. package/src/commands/backend.ts +23 -3
  115. package/src/constants.ts +5 -6
  116. package/src/index.ts +9 -2
@@ -1,3 +1,5 @@
1
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from '@mondaydotcomorg/z2h-shared-utils';
2
+
1
3
  /**
2
4
  * Cross-file constants. Narrowly-used literals (templates, prefixes used in
3
5
  * one place) stay local to their file to keep this index small.
@@ -55,11 +57,6 @@ const DATAVIZ_SKILLS_PREFIX = 'dataviz/';
55
57
  // upfront for friendlier errors.
56
58
  const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
57
59
  const PREVIEW_APP_NAME_PREFIX = 'zth-preview-';
58
- // ─────────── Declared (OAuth-gated) backend integrations ─────
59
- // Closed set the CLI detects from handler sources and the broker validates on
60
- // register/update. Always-on channels (`db`, `llm`, `snowflake`) are not listed —
61
- // the runner builds those regardless. Shared via `@mondaydotcomorg/z2h-cli/constants`.
62
- const DECLARED_INTEGRATIONS = ['monday'];
63
60
  // ─────────── Broker origin + npm registry (shared §3 contract) ─────
64
61
  // The bigbrain-zth broker fronts both the STS credential route and the npm
65
62
  // proxy. `Z2H_BROKER_BASE_URL` overrides the origin for tests/staging.
@@ -101,4 +98,4 @@ function brokerNpmRegistryUrl() {
101
98
  return `${brokerBaseUrl()}${NPM_BROKER_PATH}`;
102
99
  }
103
100
 
104
- export { APP_NAME_PATTERN, BROKER_NPM_SCOPE, BUCKET_READ_DNS_NAME, BUCKET_READ_DNS_URL, BUCKET_WRITE_NAME, BUCKET_WRITE_URL, DATAVIZ_SKILLS_PREFIX, DECLARED_INTEGRATIONS, DEFAULT_BROKER_BASE_URL, GITIGNORE_ENTRIES, MANIFEST_KEY, MANIFEST_READ_URL, MAX_PORT, MIN_PORT, NPM_BROKER_PATH, PREVIEW_APP_NAME_PREFIX, PUBLIC_NPM_REGISTRY_URL, REGION, S3_PREVIEWS_FOLDER, S3_SKILLS_BUCKET_NAME, SHADOW_DEV_DEPS, SHADOW_DIR_NAME, SHADOW_SCRIPTS, Z2H_NPM_TOKEN_ENV, brokerBaseUrl, brokerNpmRegistryUrl, routingHeaders };
101
+ export { APP_NAME_PATTERN, BROKER_NPM_SCOPE, BUCKET_READ_DNS_NAME, BUCKET_READ_DNS_URL, BUCKET_WRITE_NAME, BUCKET_WRITE_URL, DATAVIZ_SKILLS_PREFIX, DEFAULT_BROKER_BASE_URL, GITIGNORE_ENTRIES, MANIFEST_KEY, MANIFEST_READ_URL, MAX_PORT, MIN_PORT, NPM_BROKER_PATH, PREVIEW_APP_NAME_PREFIX, PUBLIC_NPM_REGISTRY_URL, REGION, S3_PREVIEWS_FOLDER, S3_SKILLS_BUCKET_NAME, SHADOW_DEV_DEPS, SHADOW_DIR_NAME, SHADOW_SCRIPTS, Z2H_NPM_TOKEN_ENV, brokerBaseUrl, brokerNpmRegistryUrl, routingHeaders };
@@ -17,7 +17,7 @@ import { generateZ2HTokenCommand } from './commands/generate-z2h-token.mjs';
17
17
  import { migrateCommand } from './commands/migrate.mjs';
18
18
  import { tagCommand } from './commands/tag.mjs';
19
19
  import { editCommand } from './commands/edit.mjs';
20
- import { backendInvokeCommand } from './commands/backend.mjs';
20
+ import { backendValidateCommand, backendInvokeCommand } from './commands/backend.mjs';
21
21
  import { doctorCommand } from './commands/doctor.mjs';
22
22
  import { fail, errorLog, setOutputMode, getOutputMode } from './util/logger.mjs';
23
23
  import { runCommand } from './util/run-command.mjs';
@@ -170,7 +170,11 @@ async function main() {
170
170
  .action(runCommand({ data: cwdApp }, (opts) => deleteCommand(opts)));
171
171
  const backend = program
172
172
  .command('backend')
173
- .description("Inspect the app's backend: invoke a deployed handler by name");
173
+ .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
174
+ backend
175
+ .command('validate')
176
+ .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
177
+ .action(runCommand({ data: cwdApp }, () => backendValidateCommand()));
174
178
  backend
175
179
  .command('invoke <handlerName>')
176
180
  .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
package/dist/index.js CHANGED
@@ -176,7 +176,11 @@ async function main() {
176
176
  .action(util_runCommand.runCommand({ data: cwdApp }, (opts) => commands_delete.deleteCommand(opts)));
177
177
  const backend = program
178
178
  .command('backend')
179
- .description("Inspect the app's backend: invoke a deployed handler by name");
179
+ .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
180
+ backend
181
+ .command('validate')
182
+ .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
183
+ .action(util_runCommand.runCommand({ data: cwdApp }, () => commands_backend.backendValidateCommand()));
180
184
  backend
181
185
  .command('invoke <handlerName>')
182
186
  .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -52,7 +52,9 @@
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-s3": "^3.706.0",
54
54
  "@mondaydotcomorg/z2h-dashboard-templates": "^0.3.4",
55
- "@mondaydotcomorg/z2h-shared-utils": "^0.5.0",
55
+ "@mondaydotcomorg/z2h-shared-utils": "^0.6.0",
56
+ "acorn": "^8.14.0",
57
+ "acorn-walk": "^8.3.4",
56
58
  "chokidar": "^3.6.0",
57
59
  "commander": "^12.1.0",
58
60
  "cors": "^2.8.6",
@@ -0,0 +1,53 @@
1
+ import { parse, type ExpressionStatement, type Program } from 'acorn';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { collectBindings, createPathResolver } from '../ast';
4
+
5
+ const program = (source: string): Program => parse(source, { ecmaVersion: 'latest', sourceType: 'script' });
6
+
7
+ /** Resolves the expression in the program's last statement. */
8
+ const resolveLast = (source: string, contextParam?: string) => {
9
+ const p = program(source);
10
+ const last = p.body[p.body.length - 1] as ExpressionStatement;
11
+ return createPathResolver(collectBindings(p), contextParam).pathOf(last.expression);
12
+ };
13
+
14
+ describe('createPathResolver', () => {
15
+ it('returns the literal chain when nothing is aliased', () => {
16
+ expect(resolveLast('ctx.api.v1.db.get;')).toBe('ctx.api.v1.db.get');
17
+ });
18
+
19
+ it('treats computed string literals as static keys', () => {
20
+ expect(resolveLast('ctx.api.v1["db"].get;')).toBe('ctx.api.v1.db.get');
21
+ expect(resolveLast("const { ['db']: store } = ctx.api.v1; store.get;")).toBe('ctx.api.v1.db.get');
22
+ });
23
+
24
+ it('returns undefined for dynamic links and non-identifier roots', () => {
25
+ expect(resolveLast('ctx.api.v1[db].get;')).toBeUndefined();
26
+ expect(resolveLast('fn().db;')).toBeUndefined();
27
+ expect(resolveLast('"x";')).toBeUndefined();
28
+ });
29
+
30
+ it('rewrites the handler context parameter to ctx', () => {
31
+ expect(resolveLast('context.api.v1.db.get;', 'context')).toBe('ctx.api.v1.db.get');
32
+ expect(resolveLast('const sf = context.api.v1.snowflake; sf.query;', 'context')).toBe('ctx.api.v1.snowflake.query');
33
+ });
34
+
35
+ it('follows const aliases, including destructuring and chains', () => {
36
+ expect(resolveLast('const sf = ctx.api.v1.snowflake; sf.query;')).toBe('ctx.api.v1.snowflake.query');
37
+ expect(resolveLast('const { v1 } = ctx.api; v1.db;')).toBe('ctx.api.v1.db');
38
+ expect(resolveLast('const { v1: { db: store } } = ctx.api; store.get;')).toBe('ctx.api.v1.db.get');
39
+ expect(resolveLast('const a = ctx.api; const b = a.v1; const c = b.db; c;')).toBe('ctx.api.v1.db');
40
+ });
41
+
42
+ it('leaves unpinnable names as written', () => {
43
+ expect(resolveLast('let sf = ctx.api.v1.snowflake; sf.query;')).toBe('sf.query');
44
+ expect(resolveLast('const sf = ctx.api.v1.snowflake; sf = 1; sf.query;')).toBe('sf.query');
45
+ expect(resolveLast('const sf = ctx.api.v1.snowflake; function f(sf) {} sf.query;')).toBe('sf.query');
46
+ expect(resolveLast('const sf = ctx.api.v1.snowflake; { const sf = 1; } sf.query;')).toBe('sf.query');
47
+ expect(resolveLast('const [sf] = ctx.api; sf.query;')).toBe('sf.query');
48
+ });
49
+
50
+ it('does not loop on circular aliases', () => {
51
+ expect(resolveLast('const a = b.x; const b = a.y; a.z;')).toBe('a.y.x.z');
52
+ });
53
+ });
@@ -1,42 +1,24 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { detectIntegrationsFromHandlers } from '../detect-integrations';
2
+ import { detectIntegrations } from '../detect-integrations';
3
3
 
4
- describe('detectIntegrationsFromHandlers', () => {
5
- it('returns [] when handlers are empty', () => {
6
- expect(detectIntegrationsFromHandlers({})).toEqual([]);
4
+ describe('detectIntegrations', () => {
5
+ it('returns [] when no domains are touched', () => {
6
+ expect(detectIntegrations([])).toEqual([]);
7
7
  });
8
8
 
9
- it('returns [] when sources never touch ctx.api.v1.monday', () => {
10
- expect(
11
- detectIntegrationsFromHandlers({
12
- ping: 'async function handler(ctx) { return ctx.db.get("x"); }',
13
- q: 'async function handler(ctx) { return ctx.api.v1.snowflake.runQuery("select 1"); }',
14
- })
15
- ).toEqual([]);
9
+ it('ignores always-on channels', () => {
10
+ expect(detectIntegrations(['db', 'llm', 'snowflake'])).toEqual([]);
16
11
  });
17
12
 
18
- it('detects monday from ctx.api.v1.monday', () => {
19
- expect(
20
- detectIntegrationsFromHandlers({
21
- boards: 'async function handler(ctx) {\n return ctx.api.v1.monday.query("{ boards { id } }");\n}',
22
- })
23
- ).toEqual(['monday']);
13
+ it('ignores unknown domains', () => {
14
+ expect(detectIntegrations(['totallyMadeUp'])).toEqual([]);
24
15
  });
25
16
 
26
- it('dedupes across handlers', () => {
27
- expect(
28
- detectIntegrationsFromHandlers({
29
- a: 'ctx.api.v1.monday.query("x")',
30
- b: 'ctx.api.v1.monday.query("y"); ctx.api.v1.snowflake.runQuery("z")',
31
- })
32
- ).toEqual(['monday']);
17
+ it('keeps declared integrations', () => {
18
+ expect(detectIntegrations(['snowflake', 'monday', 'db'])).toEqual(['monday']);
33
19
  });
34
20
 
35
- it('ignores lookalike identifiers that are not ctx.api.v1.monday', () => {
36
- expect(
37
- detectIntegrationsFromHandlers({
38
- a: 'const monday = 1; api.v1.monday; ctx.api.v2.monday',
39
- })
40
- ).toEqual([]);
21
+ it('dedupes', () => {
22
+ expect(detectIntegrations(['monday', 'monday'])).toEqual(['monday']);
41
23
  });
42
24
  });
@@ -1,10 +1,15 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
4
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { readHandlers } from '../handlers';
7
7
 
8
+ vi.mock('../../util/tracker', () => ({
9
+ trackEvent: vi.fn().mockResolvedValue(undefined),
10
+ trackDuration: vi.fn(),
11
+ }));
12
+
8
13
  let appDir: string;
9
14
 
10
15
  const writeHandler = (name: string, source: string) =>
@@ -23,16 +28,14 @@ describe('readHandlers — discovery', () => {
23
28
  it('returns empty handlers when backend/handlers does not exist', async () => {
24
29
  const dir = await mkdtemp(path.join(os.tmpdir(), 'z2h-empty-'));
25
30
  try {
26
- const result = await readHandlers(dir);
27
- expect(result.handlers).toEqual({});
31
+ await expect(readHandlers(dir)).resolves.toEqual({ handlers: {}, apiDomains: [] });
28
32
  } finally {
29
33
  await rm(dir, { recursive: true, force: true });
30
34
  }
31
35
  });
32
36
 
33
37
  it('returns empty handlers when the directory is empty', async () => {
34
- const result = await readHandlers(appDir);
35
- expect(result.handlers).toEqual({});
38
+ await expect(readHandlers(appDir)).resolves.toEqual({ handlers: {}, apiDomains: [] });
36
39
  });
37
40
 
38
41
  it('uses the filename (without .js) as the handler name', async () => {
@@ -57,43 +60,27 @@ describe('readHandlers — output contract', () => {
57
60
  expect(handlers['my_handler']).toBe(source);
58
61
  });
59
62
 
60
- it.each([
61
- ['a function declaration', 'async function handler(ctx) { return 1; }'],
62
- ['a const arrow', 'const handler = async (ctx) => 1;'],
63
- ['a var assignment', 'var handler = async function(ctx) { return 1; };'],
64
- ])('accepts %s', async (_label, source) => {
65
- await writeHandler('h.js', source);
66
- await expect(readHandlers(appDir)).resolves.toMatchObject({ handlers: { h: expect.any(String) } });
63
+ it('merges the ctx.api.v1 domains touched across all handlers, sorted and unique', async () => {
64
+ await writeHandler('a.js', 'async function handler(ctx) { return ctx.api.v1.monday.query("x"); }');
65
+ await writeHandler(
66
+ 'b.js',
67
+ 'async function handler(ctx) { ctx.api.v1.db.get("k"); return ctx.api.v1.monday.query("y"); }'
68
+ );
69
+ const { apiDomains } = await readHandlers(appDir);
70
+ expect(apiDomains).toEqual(['db', 'monday']);
67
71
  });
68
72
  });
69
73
 
74
+ // Per-shape validation rules are covered in validate.test.ts; here we only prove
75
+ // that readHandlers runs the validator and names the offending file.
70
76
  describe('readHandlers — validation', () => {
71
- it('rejects a file that does not define handler', async () => {
72
- await writeHandler('bad.js', 'async function main(ctx) { return 1; }');
73
- await expect(readHandlers(appDir)).rejects.toThrow(/must define a handler/);
74
- });
75
-
76
- it('rejects import statements', async () => {
77
- await writeHandler('bad.js', "import { foo } from './utils';\nasync function handler(ctx) { return foo; }");
78
- await expect(readHandlers(appDir)).rejects.toThrow(/must not import/);
79
- });
80
-
81
- it('rejects require calls', async () => {
82
- await writeHandler('bad.js', "const foo = require('./utils');\nasync function handler(ctx) { return foo; }");
83
- await expect(readHandlers(appDir)).rejects.toThrow(/must not import/);
84
- });
85
-
86
77
  it('rejects .ts handler files with a clear message', async () => {
87
78
  await writeHandler('call_snowflake.ts', 'async function handler(ctx) { return 1; }');
88
79
  await expect(readHandlers(appDir)).rejects.toThrow(/TypeScript handlers are not yet supported/);
89
80
  });
90
81
 
91
- it.each([
92
- ['an exported function declaration', 'export async function handler(ctx) { return 1; }'],
93
- ['an exported const', 'export const handler = async (ctx) => 1;'],
94
- ['a default-exported function', 'export default async function handler(ctx) { return 1; }'],
95
- ])('rejects %s — the isolate has no module system', async (_label, source) => {
96
- await writeHandler('bad.js', source);
97
- await expect(readHandlers(appDir)).rejects.toThrow(/must not export handler/);
82
+ it('surfaces validation errors with the filename', async () => {
83
+ await writeHandler('bad.js', 'async function handler(ctx) {\n return ctx.input.(;\n}');
84
+ await expect(readHandlers(appDir)).rejects.toThrow(/^bad\.js:2:19: not valid JavaScript/);
98
85
  });
99
86
  });
@@ -0,0 +1,25 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { trackEvent } from '../../util/tracker';
3
+ import { parseOrThrow } from '../parse';
4
+
5
+ vi.mock('../../util/tracker', () => ({
6
+ trackEvent: vi.fn().mockResolvedValue(undefined),
7
+ trackDuration: vi.fn(),
8
+ }));
9
+
10
+ const track = vi.mocked(trackEvent);
11
+
12
+ describe('parseOrThrow — telemetry', () => {
13
+ afterEach(() => {
14
+ track.mockClear();
15
+ });
16
+
17
+ it('tracks the same message it throws', () => {
18
+ expect(() => parseOrThrow('h.js', 'async function handler(ctx) {\n return ctx.input.(;\n}')).toThrow(
19
+ 'h.js:2:19: not valid JavaScript — Unexpected token'
20
+ );
21
+ expect(track).toHaveBeenCalledWith('z2h_cli_handler_parse_error', {
22
+ error: 'h.js:2:19: not valid JavaScript — Unexpected token',
23
+ });
24
+ });
25
+ });
@@ -0,0 +1,246 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { warn } from '../../util/logger';
3
+ import { validateHandlerSource } from '../validate';
4
+
5
+ vi.mock('../../util/logger');
6
+ vi.mock('../../util/tracker', () => ({
7
+ trackEvent: vi.fn().mockResolvedValue(undefined),
8
+ trackDuration: vi.fn(),
9
+ }));
10
+ const warnMock = vi.mocked(warn);
11
+
12
+ afterEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ const validate = (source: string) => validateHandlerSource('h.js', source);
17
+
18
+ describe('validateHandlerSource — accepted shapes', () => {
19
+ it.each([
20
+ ['a function declaration', 'async function handler(ctx) { return 1; }'],
21
+ ['a const arrow', 'const handler = async (ctx) => 1;'],
22
+ ['a let function expression', 'let handler = async function (ctx) { return 1; };'],
23
+ ['a var assignment', 'var handler = async function(ctx) { return 1; };'],
24
+ ['a handler wrapped by a call', 'const handler = wrap(async (ctx) => 1); function wrap(f) { return f; }'],
25
+ ])('accepts %s', (_label, source) => {
26
+ expect(() => validate(source)).not.toThrow();
27
+ });
28
+
29
+ it('does not reject require() mentioned in a comment', () => {
30
+ expect(() => validate('// never call require() in here\nasync function handler(ctx) { return 1; }')).not.toThrow();
31
+ });
32
+
33
+ it('does not reject "import" at the start of a line inside a string', () => {
34
+ expect(() =>
35
+ validate('const help = `\nimport nothing here\n`;\nasync function handler(ctx) { return help; }')
36
+ ).not.toThrow();
37
+ });
38
+
39
+ it('accepts sloppy-mode-only syntax, because the isolate runs sloppy scripts', () => {
40
+ expect(() => validate('function handler(ctx) { with (ctx) { return input; } }')).not.toThrow();
41
+ });
42
+ });
43
+
44
+ describe('validateHandlerSource — handler declaration', () => {
45
+ it('rejects a file that does not define handler', () => {
46
+ expect(() => validate('async function main(ctx) { return 1; }')).toThrow(/must define a handler/);
47
+ });
48
+
49
+ it('rejects a handler declared only inside another function', () => {
50
+ expect(() => validate('function outer() { async function handler(ctx) { return 1; } }')).toThrow(
51
+ /must define a handler/
52
+ );
53
+ });
54
+
55
+ it.each([
56
+ ['a number', 'const handler = 42;'],
57
+ ['an object', 'const handler = { run() {} };'],
58
+ ['an array', 'const handler = [];'],
59
+ ['a template literal', 'const handler = `x`;'],
60
+ ['nothing (uninitialized)', 'let handler;'],
61
+ ])('rejects handler bound to %s', (_label, source) => {
62
+ expect(() => validate(source)).toThrow(/declares handler but it is not a function/);
63
+ });
64
+ });
65
+
66
+ describe('validateHandlerSource — module syntax (the isolate compiles plain scripts)', () => {
67
+ it.each([
68
+ ['an exported function declaration', 'export async function handler(ctx) { return 1; }'],
69
+ ['an exported const', 'export const handler = async (ctx) => 1;'],
70
+ ['a default-exported function', 'export default async function handler(ctx) { return 1; }'],
71
+ ['an export specifier', 'async function handler(ctx) { return 1; }\nexport { handler };'],
72
+ ['a default-exported identifier', 'async function handler(ctx) { return 1; }\nexport default handler;'],
73
+ ])('rejects %s', (_label, source) => {
74
+ expect(() => validate(source)).toThrow(/must not export handler/);
75
+ });
76
+
77
+ it.each([
78
+ ['an import statement', "import { foo } from './utils';\nasync function handler(ctx) { return foo; }"],
79
+ ['export * from', "export * from './utils';\nasync function handler(ctx) { return 1; }"],
80
+ ['export { x } from', "export { foo } from './utils';\nasync function handler(ctx) { return 1; }"],
81
+ ['a top-level require call', "const foo = require('./utils');\nasync function handler(ctx) { return foo; }"],
82
+ ['a nested require call', "async function handler(ctx) { const foo = require('./utils'); return foo; }"],
83
+ ['a dynamic import()', "async function handler(ctx) { const m = await import('./x'); return m; }"],
84
+ ])('rejects %s', (_label, source) => {
85
+ expect(() => validate(source)).toThrow(/must not import/);
86
+ });
87
+
88
+ it.each([
89
+ ['an export of a non-handler name', 'export const helper = 1;\nasync function handler(ctx) { return helper; }'],
90
+ ['an anonymous default export', 'export default async (ctx) => 1;'],
91
+ ])('rejects %s', (_label, source) => {
92
+ expect(() => validate(source)).toThrow(/must not use export/);
93
+ });
94
+
95
+ it.each([
96
+ ['top-level await', 'const cfg = await Promise.resolve(1);\nasync function handler(ctx) { return cfg; }'],
97
+ ['import.meta', 'async function handler(ctx) { return import.meta.url; }'],
98
+ ])('rejects %s', (_label, source) => {
99
+ expect(() => validate(source)).toThrow(/uses module-only syntax/);
100
+ });
101
+ });
102
+
103
+ describe('validateHandlerSource — syntax errors', () => {
104
+ it('reports file, line and column', () => {
105
+ expect(() => validate('async function handler(ctx) {\n return ctx.input.(;\n}')).toThrow(
106
+ /^h\.js:2:19: not valid JavaScript — Unexpected token$/
107
+ );
108
+ });
109
+
110
+ it('rejects a top-level return, which V8 rejects in scripts', () => {
111
+ expect(() => validate('return 1;\nasync function handler(ctx) { return 1; }')).toThrow(/not valid JavaScript/);
112
+ });
113
+ });
114
+
115
+ describe('validateHandlerSource — capability rules', () => {
116
+ it('runs the rule set once the handler is structurally valid', () => {
117
+ expect(() => validate('async function handler(ctx) { return ctx.api.v1.snowflake.query(ctx.input.sql); }')).toThrow(
118
+ /snowflake\.query must receive SQL written in the handler/
119
+ );
120
+ });
121
+
122
+ it('runs snowflake SQL rules through a renamed handler parameter', () => {
123
+ expect(() =>
124
+ validate('async function handler(context) { return context.api.v1.snowflake.query(context.input.sql); }')
125
+ ).toThrow(/snowflake\.query must receive SQL written in the handler/);
126
+ });
127
+
128
+ it('runs snowflake SQL rules through computed channel access', () => {
129
+ expect(() =>
130
+ validate('async function handler(ctx) { return ctx.api.v1["snowflake"].query(ctx.input.sql); }')
131
+ ).toThrow(/snowflake\.query must receive SQL written in the handler/);
132
+ });
133
+
134
+ it('runs snowflake SQL rules through a wrapped handler with a renamed parameter', () => {
135
+ expect(() =>
136
+ validate(
137
+ 'const handler = wrap(async (context) => context.api.v1.snowflake.query(context.input.sql)); function wrap(f) { return f; }'
138
+ )
139
+ ).toThrow(/snowflake\.query must receive SQL written in the handler/);
140
+ });
141
+
142
+ it('reports structural problems before rule violations', () => {
143
+ expect(() => validate('async function main(ctx) { return ctx.api.v1.snowflake.query(ctx.input.sql); }')).toThrow(
144
+ /must define a handler/
145
+ );
146
+ });
147
+
148
+ it('still returns api domains when every rule passes', () => {
149
+ expect(
150
+ validate('async function handler(ctx) { return ctx.api.v1.snowflake.query("select 1", { id: ctx.input.id }); }')
151
+ .apiDomains
152
+ ).toEqual(['snowflake']);
153
+ });
154
+ });
155
+
156
+ describe('validateHandlerSource — ctx.api.v1 domains', () => {
157
+ it('returns every domain touched, sorted and unique', () => {
158
+ const { apiDomains } = validate(
159
+ 'async function handler(ctx) { await ctx.api.v1.db.put("k", 1); ctx.api.v1.monday.query("x"); return ctx.api.v1.db.get("k"); }'
160
+ );
161
+ expect(apiDomains).toEqual(['db', 'monday']);
162
+ });
163
+
164
+ it('sees through optional chaining', () => {
165
+ expect(validate('async function handler(ctx) { return ctx?.api?.v1?.monday?.query("x"); }').apiDomains).toEqual([
166
+ 'monday',
167
+ ]);
168
+ });
169
+
170
+ it('ignores domains mentioned only in comments or strings', () => {
171
+ const { apiDomains } = validate(
172
+ '// TODO: use ctx.api.v1.monday later\nasync function handler(ctx) { return "ctx.api.v1.monday"; }'
173
+ );
174
+ expect(apiDomains).toEqual([]);
175
+ });
176
+
177
+ it('follows const aliases of the api object', () => {
178
+ expect(
179
+ validate('async function handler(ctx) { const { v1 } = ctx.api; return v1.monday.query("x"); }').apiDomains
180
+ ).toEqual(['monday']);
181
+ expect(
182
+ validate('const api = ctx.api.v1;\nasync function handler(ctx) { const m = api.monday; return m.query("x"); }')
183
+ .apiDomains
184
+ ).toEqual(['monday']);
185
+ });
186
+
187
+ it('sees string-literal computed access', () => {
188
+ expect(
189
+ validate("async function handler(ctx) { return ctx.api.v1['snowflake'].query('select 1'); }").apiDomains
190
+ ).toEqual(['snowflake']);
191
+ expect(validate('async function handler(ctx) { return ctx.api.v1["monday"].query("x"); }').apiDomains).toEqual([
192
+ 'monday',
193
+ ]);
194
+ });
195
+
196
+ it('sees domains through a renamed handler parameter', () => {
197
+ expect(validate('async function handler(context) { return context.api.v1.db.list(); }').apiDomains).toEqual(['db']);
198
+ expect(validate('const handler = async (context) => context.api.v1.monday.query("x");').apiDomains).toEqual([
199
+ 'monday',
200
+ ]);
201
+ expect(
202
+ validate('const handler = async function (context) { return context.api.v1.db.list(); };').apiDomains
203
+ ).toEqual(['db']);
204
+ expect(validate('async function* handler(context) { yield context.api.v1.db.list(); }').apiDomains).toEqual(['db']);
205
+ expect(
206
+ validate('const handler = async function* (context) { yield context.api.v1.db.list(); };').apiDomains
207
+ ).toEqual(['db']);
208
+ });
209
+
210
+ it('sees domains when the handler function is wrapped or has a default argument', () => {
211
+ expect(
212
+ validate('const handler = wrap(async (context) => context.api.v1.db.list()); function wrap(f) { return f; }')
213
+ .apiDomains
214
+ ).toEqual(['db']);
215
+ expect(validate('const handler = (0, async (context) => context.api.v1.monday.query("x"));').apiDomains).toEqual([
216
+ 'monday',
217
+ ]);
218
+ expect(validate('async function handler(context = {}) { return context.api.v1.db.list(); }').apiDomains).toEqual([
219
+ 'db',
220
+ ]);
221
+ });
222
+
223
+ it('ignores lookalikes that are not ctx.api.v1.<domain>', () => {
224
+ const { apiDomains } = validate(
225
+ 'async function handler(ctx) { const monday = 1; api.v1.monday; ctx.api.v2.monday; ctx.api.v1[monday]; return monday; }'
226
+ );
227
+ expect(apiDomains).toEqual([]);
228
+ });
229
+
230
+ it('does not warn for known domains', () => {
231
+ validate('async function handler(ctx) { return ctx.api.v1.snowflake.query("select 1"); }');
232
+ expect(warnMock).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('warns, but does not throw, for an unknown domain — this is DX feedback, not a security boundary', () => {
236
+ expect(() => validate('async function handler(ctx) { return ctx.api.v1.totallyMadeUp.get(); }')).not.toThrow();
237
+ expect(warnMock).toHaveBeenCalledWith(
238
+ expect.stringMatching(/ctx\.api\.v1\.totallyMadeUp is not a known capability/)
239
+ );
240
+ });
241
+
242
+ it('warns once per unknown domain, not once per usage', () => {
243
+ validate('async function handler(ctx) { ctx.api.v1.madeUp.get(); return ctx.api.v1.madeUp.post(); }');
244
+ expect(warnMock).toHaveBeenCalledTimes(1);
245
+ });
246
+ });