@mondaydotcomorg/z2h-cli 0.27.0 → 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.
- package/dist/backend/__tests__/ast.test.d.ts +2 -0
- package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/backend/ast.d.ts +41 -0
- package/dist/backend/ast.d.ts.map +1 -0
- package/dist/backend/ast.js +152 -0
- package/dist/backend/detect-integrations.d.ts +8 -4
- package/dist/backend/detect-integrations.d.ts.map +1 -1
- package/dist/backend/detect-integrations.js +12 -15
- package/dist/backend/handler-declaration.d.ts +17 -0
- package/dist/backend/handler-declaration.d.ts.map +1 -0
- package/dist/backend/handler-declaration.js +83 -0
- package/dist/backend/handlers.d.ts +2 -0
- package/dist/backend/handlers.d.ts.map +1 -1
- package/dist/backend/handlers.js +7 -36
- package/dist/backend/messages.d.ts +10 -0
- package/dist/backend/messages.d.ts.map +1 -0
- package/dist/backend/messages.js +14 -0
- package/dist/backend/parse.d.ts +11 -0
- package/dist/backend/parse.d.ts.map +1 -0
- package/dist/backend/parse.js +66 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/backend/rules/index.d.ts +5 -0
- package/dist/backend/rules/index.d.ts.map +1 -0
- package/dist/backend/rules/index.js +8 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/backend/rules/snowflake-static-sql.js +75 -0
- package/dist/backend/rules/types.d.ts +29 -0
- package/dist/backend/rules/types.d.ts.map +1 -0
- package/dist/backend/rules/types.js +1 -0
- package/dist/backend/validate.d.ts +23 -0
- package/dist/backend/validate.d.ts.map +1 -0
- package/dist/backend/validate.js +64 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -0
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +22 -2
- package/dist/constants.d.ts +1 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -6
- package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/esm/backend/ast.d.ts +41 -0
- package/dist/esm/backend/ast.d.ts.map +1 -0
- package/dist/esm/backend/ast.mjs +146 -0
- package/dist/esm/backend/detect-integrations.d.ts +8 -4
- package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
- package/dist/esm/backend/detect-integrations.mjs +12 -15
- package/dist/esm/backend/handler-declaration.d.ts +17 -0
- package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
- package/dist/esm/backend/handler-declaration.mjs +78 -0
- package/dist/esm/backend/handlers.d.ts +2 -0
- package/dist/esm/backend/handlers.d.ts.map +1 -1
- package/dist/esm/backend/handlers.mjs +7 -36
- package/dist/esm/backend/messages.d.ts +10 -0
- package/dist/esm/backend/messages.d.ts.map +1 -0
- package/dist/esm/backend/messages.mjs +12 -0
- package/dist/esm/backend/parse.d.ts +11 -0
- package/dist/esm/backend/parse.d.ts.map +1 -0
- package/dist/esm/backend/parse.mjs +64 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.d.ts +5 -0
- package/dist/esm/backend/rules/index.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.mjs +6 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
- package/dist/esm/backend/rules/types.d.ts +29 -0
- package/dist/esm/backend/rules/types.d.ts.map +1 -0
- package/dist/esm/backend/rules/types.mjs +1 -0
- package/dist/esm/backend/validate.d.ts +23 -0
- package/dist/esm/backend/validate.d.ts.map +1 -0
- package/dist/esm/backend/validate.mjs +62 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -0
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +23 -4
- package/dist/esm/constants.d.ts +1 -2
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +3 -6
- package/dist/esm/index.mjs +6 -2
- package/dist/index.js +5 -1
- package/package.json +5 -3
- package/src/backend/__tests__/ast.test.ts +53 -0
- package/src/backend/__tests__/detect-integrations.test.ts +12 -30
- package/src/backend/__tests__/handlers.test.ts +21 -34
- package/src/backend/__tests__/parse.test.ts +25 -0
- package/src/backend/__tests__/validate.test.ts +246 -0
- package/src/backend/ast.ts +204 -0
- package/src/backend/detect-integrations.ts +12 -14
- package/src/backend/handler-declaration.ts +104 -0
- package/src/backend/handlers.ts +9 -44
- package/src/backend/messages.ts +16 -0
- package/src/backend/parse.ts +82 -0
- package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
- package/src/backend/rules/index.ts +7 -0
- package/src/backend/rules/snowflake-static-sql.ts +78 -0
- package/src/backend/rules/types.ts +30 -0
- package/src/backend/validate.ts +73 -0
- package/src/commands/__tests__/backend-deploy.test.ts +5 -3
- package/src/commands/__tests__/backend-validate.test.ts +71 -0
- package/src/commands/backend.ts +23 -3
- package/src/constants.ts +5 -6
- package/src/index.ts +9 -2
package/dist/esm/constants.mjs
CHANGED
|
@@ -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,
|
|
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 };
|
package/dist/esm/index.mjs
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -51,8 +51,10 @@
|
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@aws-sdk/client-s3": "^3.706.0",
|
|
54
|
-
"@mondaydotcomorg/z2h-dashboard-templates": "^0.3.
|
|
55
|
-
"@mondaydotcomorg/z2h-shared-utils": "^0.
|
|
54
|
+
"@mondaydotcomorg/z2h-dashboard-templates": "^0.3.4",
|
|
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 {
|
|
2
|
+
import { detectIntegrations } from '../detect-integrations';
|
|
3
3
|
|
|
4
|
-
describe('
|
|
5
|
-
it('returns [] when
|
|
6
|
-
expect(
|
|
4
|
+
describe('detectIntegrations', () => {
|
|
5
|
+
it('returns [] when no domains are touched', () => {
|
|
6
|
+
expect(detectIntegrations([])).toEqual([]);
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
it('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
});
|