import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; import { buildApp } from "./build.js"; import { notion, type DataSourceHandle, type SingleSourceDatabaseHandle, } from "../notion-as-code/index.js"; import { finishMetadataRecording, startMetadataRecording } from "../notion-as-code/recorder.js"; describe("Notion-as-Code database shorthand", () => { it("keeps provisioning and sync identity equivalent to an explicit source", () => { const recorder = startMetadataRecording(); const schema = { Name: { resourceId: "title", type: "title" }, Count: { resourceId: "count", type: "number" }, } as const; const database = notion.database("issues", { dataSourceResourceId: "issues-source", name: "Issues", schema, }); expectTypeOf(database).toExtend>(); expectTypeOf(database.dataSource.schema.Name.type).toEqualTypeOf<"title">(); expectTypeOf(database.dataSource.schema.Name.resourceId).toEqualTypeOf<"title">(); expectTypeOf( database.dataSource.database.config.schema.Count.type, ).toEqualTypeOf<"number">(); database.dataSource.addPage({ resourceId: "issue" }); const shorthand = finishMetadataRecording(recorder).intents; const explicitRecorder = startMetadataRecording(); const explicit = notion.database("issues", { name: "Issues", datasources: { Issues: { resourceId: "issues-source", schema, }, }, }); expectTypeOf(explicit.datasources.Issues).toExtend>(); expectTypeOf(explicit.datasources.Issues.schema.Name.type).toEqualTypeOf<"title">(); expectTypeOf(explicit.datasources.Issues.schema.Name.resourceId).toEqualTypeOf<"title">(); explicit.datasources.Issues.addPage({ resourceId: "issue" }); // @ts-expect-error Explicit databases do not guarantee a single source. void explicit.dataSource; expect(shorthand).toEqual(finishMetadataRecording(explicitRecorder).intents); expect(database.dataSource.database.key).toBe("issues-source"); expect(shorthand[1]).toEqual({ type: "page", resourceId: "issue", parent: { type: "resourceId", resourceId: "issues-source" }, }); }); it("preserves parent binding and keyed source types through child helpers", () => { const recorder = startMetadataRecording(); const page = notion.page({ resourceId: "page" }); const team = notion.teamspace({ resourceId: "team", name: "Team", accessLevel: "private" }); const schema = { Title: { resourceId: "child-title", type: "title" }, } as const; for (const parent of [page, team]) { const database = parent.addDatabase(`${parent.resourceId}-db`, { dataSourceResourceId: `${parent.resourceId}-source`, name: "Child", schema, }); expectTypeOf(database.dataSource).toExtend>(); expectTypeOf(database.dataSource.schema.Title.type).toEqualTypeOf<"title">(); expectTypeOf( database.dataSource.schema.Title.resourceId, ).toEqualTypeOf<"child-title">(); } const intents = finishMetadataRecording(recorder).intents; expect( intents.filter((intent) => intent.type === "database").map((intent) => intent.parent), ).toEqual([ { type: "resourceId", resourceId: "page" }, { type: "resourceId", resourceId: "team" }, ]); }); it("keeps keyed child inference and rejects empty child databases", () => { const recorder = startMetadataRecording(); const page = notion.page({ resourceId: "child-page" }); const schema = { Name: { type: "title", resourceId: "child-name" }, "Issue ID": { type: "text", resourceId: "child-issue-id" }, } as const; const database = page.addDatabase("child-multi-database", { datasources: { Issues: { resourceId: "child-issues-source", schema, }, }, }); expectTypeOf(database.datasources.Issues).toExtend>(); expectTypeOf( database.datasources.Issues.schema.Name.resourceId, ).toEqualTypeOf<"child-name">(); const invalidChildDeclarations = () => { // @ts-expect-error Child databases need a source or a view. page.addDatabase("child-empty-datasources", { datasources: {} }); // @ts-expect-error Child databases need a source or a view. page.addDatabase("child-empty-arguments", {}); // @ts-expect-error Empty child sources and views are invalid. page.addDatabase("child-empty-with-views", { datasources: {}, views: [] }); }; void invalidChildDeclarations; expect(() => page.addDatabase("child-runtime-empty", { datasources: {}, views: [] } as never), ).toThrow(); const intents = finishMetadataRecording(recorder).intents; expect(intents).toHaveLength(2); expect(intents).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "database", resourceId: "child-multi-database", parent: { type: "resourceId", resourceId: "child-page" }, dataSources: [ { resourceId: "child-issues-source", name: "Issues", properties: [ { resourceId: "child-name", name: "Name", type: "title" }, { resourceId: "child-issue-id", name: "Issue ID", type: "text" }, ], }, ], }), ]), ); }); }); const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url)); const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url)); const { version: SDK_VERSION } = JSON.parse( fs.readFileSync(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8"), ) as { version: string }; const builtDirectories: string[] = []; function fixture(name: string): string { const root = path.join(FIXTURES, name); builtDirectories.push(root); return root; } function example(name: string): string { const root = path.join(EXAMPLES, name); builtDirectories.push(root); return root; } afterEach(async () => { for (const root of builtDirectories.splice(0)) { for (const artifact of [".notion", "dist"]) { await fs.promises.rm(path.join(root, artifact), { recursive: true, force: true }); } } }); describe("buildApp", () => { it.each([ [{}, true], [{ required: true }, true], [{ required: false }, false], ])("emits an explicit required flag for %j without secret values", async (flag, required) => { const root = fixture("custom-block"); const packagePath = path.join(root, "package.json"); const declaration = { name: "BUILD_ONLY_TEST_KEY", title: "Test key", description: "Create a read-only key.", ...flag, }; await fs.promises.mkdir(path.join(root, "node_modules", "@notionhq"), { recursive: true }); await fs.promises.symlink( path.resolve(root, "../../.."), path.join(root, "node_modules", "@notionhq", "apps"), "dir", ); await fs.promises.writeFile( packagePath, JSON.stringify({ notion: { secrets: [declaration] } }), ); try { const { manifest, manifestPath } = await buildApp(root); expect(manifest.secrets).toEqual([{ ...declaration, required }]); expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8")).secrets).toEqual([ { ...declaration, required }, ]); } finally { await fs.promises.rm(packagePath); await fs.promises.rm(path.join(root, "node_modules"), { recursive: true, force: true }); } }); it("emits workflow and block metadata without adding blocks to the Worker", async () => { const root = fixture("custom-block"); const { manifest, bundlePath } = await buildApp(root); expect(manifest.capabilities).toEqual([ { type: "workflow", key: "hello", config: { name: "Hello", triggers: [] } }, { type: "custom_block", key: "hello", config: { source: { type: "project", path: "./blocks/hello", command: "npx vite build", output: "dist", }, manifest: { version: 1, slashCommand: "hello", dataSources: {} }, }, }, ]); const worker = await fs.promises.readFile(bundlePath, "utf8"); expect(worker).not.toMatch(/custom_block|customBlock|react|createRoot|NotionCustomBlock/); expect(fs.existsSync(path.join(root, ".notion/blocks.js"))).toBe(false); }); it("records resources imported only by a custom block and emits default bindings", async () => { const root = fixture("custom-block-notion-as-code"); const { manifest } = await buildApp(root); expect(manifest.capabilities).toEqual([ { type: "custom_block", key: "board", config: expect.objectContaining({ defaultBindings: { tasks: { dataSourceResourceId: "tasks-source", propertyResourceIdsByKey: { Name: "task-name", Status: "task-status", Due: "task-due", }, }, }, manifest: expect.objectContaining({ dataSources: { tasks: expect.objectContaining({ name: "Tasks", description: "Work items shown on the board", }), }, }), }), }, ]); const provisioning = JSON.parse( await fs.promises.readFile(path.join(root, "dist", "provisioning.json"), "utf8"), ); expect(provisioning.intents).toEqual([ expect.objectContaining({ type: "database", resourceId: "tasks-db", dataSources: [ expect.objectContaining({ resourceId: "tasks-source", properties: expect.arrayContaining([ expect.objectContaining({ resourceId: "task-status" }), ]), }), ], }), { type: "view", databaseResourceId: "tasks-db", view: { type: "custom", resourceId: "tasks-board-view", name: "Board", dataSourceResourceId: "tasks-source", customBlockKey: "board", }, }, ]); }); it("validates custom block keys in APP.md against discovered capabilities", async () => { const root = fixture("custom-block-notion-as-code"); const appMarkdownPath = path.join(root, "APP.md"); try { await fs.promises.writeFile(appMarkdownPath, ''); await buildApp(root); const provisioning = JSON.parse( await fs.promises.readFile(path.join(root, "dist", "provisioning.json"), "utf8"), ); expect(provisioning.intents).toContainEqual( expect.objectContaining({ type: "page", resourceId: "__notion_apps_app_md__", content: '', }), ); await fs.promises.writeFile( appMarkdownPath, '', ); await expect(buildApp(root)).rejects.toThrow( 'references undeclared custom block key "missing"', ); } finally { await fs.promises.rm(appMarkdownPath, { force: true }); } }); it("names the block file when path is missing", async () => { await expect(buildApp(fixture("custom-block-missing-path"))).rejects.toThrow( /hello.ts.*non-empty path/, ); }); it("rejects a block file that exports a different capability", async () => { await expect(buildApp(fixture("custom-block-wrong-tag"))).rejects.toThrow( /hello.ts.*customBlock/, ); }); it("emits symbolic access requirements for every NaC resource kind", async () => { const { manifest } = await buildApp(fixture("workflow-access")); expect(manifest.capabilities).toEqual([ { type: "workflow", key: "access", config: { name: "Access", description: "Declares all workflow access resource kinds", triggers: [{ type: "notion.page.created" }], access: { home: { type: "page", resourceId: "home", level: "view" }, records: { type: "database", resourceId: "records", level: "edit" }, fullRecords: { type: "database", resourceId: "records", level: "fullAccess", }, source: { type: "dataSource", resourceId: "records-source", level: "comment", }, agent: { type: "customAgent", resourceId: "agent", level: "call" }, }, }, }, ]); }); it("builds a sync manifest with a Notion-as-Code meetings database", async () => { const { manifest } = await buildApp(example("sync")); expect(manifest).toEqual( expect.objectContaining({ $schema: "notion:apps-manifest:v1", sdkVersion: SDK_VERSION, xldbs: [], pacers: [], }), ); expect(manifest.databases).toHaveLength(2); expect(manifest.databases).toEqual( expect.arrayContaining([ expect.objectContaining({ key: "issues", config: expect.objectContaining({ initialTitle: "GitHub Issues", schema: expect.objectContaining({ properties: expect.objectContaining({ Name: { type: "title" }, "GitHub ID": { type: "text" }, Closed: { type: "checkbox" }, }), }), }), }), expect.objectContaining({ key: "homebase-meetings-db-source", config: expect.objectContaining({ initialTitle: "Meetings", schema: expect.objectContaining({ properties: expect.objectContaining({ Name: { type: "title" }, "Meeting ID": { type: "text" }, "Meeting date": { type: "date" }, "Prep complete": { type: "checkbox" }, }), }), }), }), ]), ); expect(manifest.capabilities).toHaveLength(2); expect(manifest.capabilities).toEqual( expect.arrayContaining([ { type: "sync", key: "issues", config: { databaseKey: "issues", primaryKeyProperty: "GitHub ID", mode: "incremental", schedule: { type: "interval", intervalMs: 3_600_000 }, }, }, { type: "sync", key: "notionAsCodeIssues", config: { databaseKey: "homebase-meetings-db-source", primaryKeyProperty: "Meeting ID", mode: "incremental", schedule: { type: "interval", intervalMs: 3_600_000 }, }, }, ]), ); }); it("emits a separate Notion-as-Code provisioning artifact", async () => { const projectRoot = fixture("notion-as-code"); const { manifest } = await buildApp(projectRoot); expect(manifest.databases).toEqual([ { key: "issues-source", config: { initialTitle: "GitHub Issues", schema: { properties: { Name: { type: "title" }, "GitHub ID": { type: "text" }, Closed: { type: "checkbox" }, }, }, }, }, ]); expect(manifest.capabilities).toEqual([ { type: "sync", key: "issues", config: { databaseKey: "issues-source", primaryKeyProperty: "GitHub ID", }, }, ]); expect( JSON.parse( await fs.promises.readFile( path.join(projectRoot, "dist", "provisioning.json"), "utf8", ), ), ).toEqual({ $schema: "notion:apps-provisioning:v1", version: 1, intents: [ { type: "teamspace", resourceId: "github-app-teamspace", name: "GitHub app data", accessLevel: "open", }, { type: "page", resourceId: "github-app-private-page", parent: { type: "resourceId", resourceId: "__notion_apps_workspace__", }, properties: { title: [["GitHub app home"]] }, }, { type: "database", resourceId: "github-app-private-database", parent: { type: "resourceId", resourceId: "__notion_apps_workspace__", }, dataSources: [], views: [ { resourceId: "github-app-private-view", type: "table", dataSourceResourceId: "issues-source", }, ], }, { type: "database", resourceId: "issues-db", parent: { type: "resourceId", resourceId: "github-app-teamspace" }, dataSources: [ { resourceId: "issues-source", name: "GitHub Issues", properties: [ { resourceId: "issues-name", name: "Name", type: "title" }, { resourceId: "issues-github-id", name: "GitHub ID", type: "text" }, { resourceId: "issues-closed", name: "Closed", type: "checkbox" }, ], }, ], }, ], }); }); it.each([ { frontmatter: "icon: 🕵️‍♀️", icon: { type: "emoji", emoji: "🕵️‍♀️" }, }, { frontmatter: "icon:\n type: notion_icon\n description: rocket\n color: blue", icon: { type: "notion_icon", description: "rocket", color: "blue" }, }, ])("emits APP.md frontmatter as the page icon: $frontmatter", async ({ frontmatter, icon }) => { const root = await fs.promises.mkdtemp(path.join(FIXTURES, "app-md-icon-")); try { await fs.promises.cp( path.join(FIXTURES, "notion-as-code", "src"), path.join(root, "src"), { recursive: true }, ); await fs.promises.writeFile( path.join(root, "APP.md"), `---\n${frontmatter}\n---\n# App guide\n`, ); await buildApp(root); const provisioning = JSON.parse( await fs.promises.readFile(path.join(root, "dist", "provisioning.json"), "utf8"), ); expect(provisioning.intents.at(-1)).toEqual({ type: "page", resourceId: "__notion_apps_app_md__", parent: { type: "resourceId", resourceId: "__notion_apps_workspace__" }, properties: { title: [["APP.md"]] }, icon, content: "# App guide\n", }); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); it("emits APP.md with its icon, stable identity, and unchanged Markdown resource references", async () => { const root = await fs.promises.mkdtemp(path.join(FIXTURES, "app-md-")); try { await fs.promises.cp( path.join(FIXTURES, "notion-as-code", "src"), path.join(root, "src"), { recursive: true, }, ); const markdown = '# App guide\n\n\nSee Home.\n\n\nIssues\n'; await fs.promises.writeFile( path.join(root, "APP.md"), `---\nicon:\n type: emoji\n emoji: "📘"\n---\n${markdown}`, ); await buildApp(root); const provisioningPath = path.join(root, "dist", "provisioning.json"); const provisioning = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8")); expect(provisioning.intents.at(-1)).toEqual({ type: "page", resourceId: "__notion_apps_app_md__", parent: { type: "resourceId", resourceId: "__notion_apps_workspace__" }, properties: { title: [["APP.md"]] }, icon: { type: "emoji", emoji: "📘" }, content: markdown, }); await fs.promises.rm(path.join(root, "APP.md")); await buildApp(root); const withoutAppPage = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8")); expect(withoutAppPage.intents).not.toEqual( expect.arrayContaining([ expect.objectContaining({ resourceId: "__notion_apps_app_md__" }), ]), ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); it("removes provisioning when a rebuild no longer declares Notion resources", async () => { const root = await fs.promises.mkdtemp(path.join(FIXTURES, "provisioning-rebuild-")); try { await fs.promises.cp( path.join(FIXTURES, "notion-as-code", "src"), path.join(root, "src"), { recursive: true, }, ); await buildApp(root); const provisioningPath = path.join(root, "dist", "provisioning.json"); const provisioning = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8")); expect(provisioning.intents).toEqual( expect.arrayContaining([expect.objectContaining({ resourceId: "issues-db" })]), ); await fs.promises.rm(path.join(root, "src"), { recursive: true }); await fs.promises.cp(path.join(EXAMPLES, "workflow", "src"), path.join(root, "src"), { recursive: true, }); const { manifest } = await buildApp(root); expect(manifest.capabilities).toEqual([ expect.objectContaining({ type: "workflow", key: "onPageCreated" }), expect.objectContaining({ type: "workflow", key: "prepareReleases" }), expect.objectContaining({ type: "workflow", key: "shipReleases" }), ]); await expect(fs.promises.readFile(provisioningPath)).rejects.toMatchObject({ code: "ENOENT", }); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); it("builds configured events into JSON-safe trigger declarations", async () => { const { manifest } = await buildApp(fixture("configured-events")); expect(manifest.capabilities).toEqual([ { type: "workflow", key: "tasks", config: { name: "Tasks", description: "Watches task events", triggers: [ { type: "recurrence", frequency: "day", interval: 1, start: "2026-09-23T09:00:00", timeZone: "UTC", }, { type: "notion.page.created", dataSourceResourceId: "tasks-source" }, { type: "notion.page.updated", dataSourceResourceId: "tasks-source", propertyResourceIds: ["status"], includePageContent: false, }, ], }, }, ]); }); it("builds a workflow bundle and manifest", async () => { const { manifest, bundlePath, manifestPath } = await buildApp(example("workflow")); expect(manifest).toEqual({ $schema: "notion:apps-manifest:v1", sdkVersion: SDK_VERSION, databases: [], xldbs: [], pacers: [], capabilities: [ { type: "workflow", key: "onPageCreated", config: { name: "Log New Pages", description: "Logs every page created in the workspace", triggers: [{ type: "notion.page.created" }], }, }, { type: "workflow", key: "prepareReleases", config: { name: "Prepare releases", description: "Prepares the supplied releases.", triggers: [ { type: "workflow.manual", inputJsonSchema: { type: "object", additionalProperties: false, required: ["releases"], properties: { releases: { type: "array", items: { type: "object", additionalProperties: false, required: ["version", "dryRun"], properties: { version: { type: "string" }, dryRun: { anyOf: [ { type: "boolean" }, { type: "null" }, ], }, }, }, }, }, }, }, ], outputJsonSchema: { type: "object", additionalProperties: false, required: ["prepared"], properties: { prepared: { type: "integer" } }, }, }, }, { type: "workflow", key: "shipReleases", config: { name: "Ship releases", description: "Starts another workflow to prepare releases.", triggers: [ { type: "workflow.manual", inputJsonSchema: { type: "object", additionalProperties: false, required: ["versions"], properties: { versions: { type: "array", items: { type: "string" } }, }, }, }, ], }, }, ], }); expect(path.basename(bundlePath)).toBe("worker.js"); expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toEqual(manifest); }); it("records a workflow's verify handler in the manifest", async () => { const { manifestPath } = await buildApp(fixture("webhook-verify")); expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toMatchObject({ capabilities: [ { type: "workflow", key: "onIncoming", config: { name: "Incoming Webhook", description: "Verifies and processes an incoming webhook", triggers: [{ type: "webhooks.webhook", hasVerify: true }], }, }, ], }); }); it("collects relation databases and a shared pacer once", async () => { const { manifest } = await buildApp(fixture("shared-primary-key")); expect(manifest.databases).toEqual([ { key: "issues", config: { schema: { properties: { Name: { type: "title" }, "GitHub ID": { type: "text" }, Project: { type: "relation", config: { twoWay: false }, relatedDatabaseKey: "projects", }, }, }, }, }, { key: "projects", config: { schema: { properties: { Name: { type: "title" }, "Project ID": { type: "text" }, }, }, }, }, ]); expect(manifest.pacers).toEqual([ { key: "issues-api", config: { allowedRequests: 10, intervalMs: 1_000 }, }, ]); expect(manifest.capabilities).toEqual([ { type: "sync", key: "closed", config: { databaseKey: "issues", primaryKeyProperty: "GitHub ID", }, }, { type: "sync", key: "open", config: { databaseKey: "issues", primaryKeyProperty: "GitHub ID", }, }, ]); }); it("rejects syncs that disagree on a shared database primary key", async () => { await expect(buildApp(fixture("conflicting-primary-key"))).rejects.toThrow( /Syncs for database "issues" disagree on its primary key/, ); }); it("rejects distinct database objects with the same key", async () => { await expect(buildApp(fixture("duplicate-database-name"))).rejects.toThrow( /Two distinct databases use the key "shared"/, ); }); it("rejects a workflow file whose default export is not a workflow", async () => { await expect(buildApp(fixture("wrong-tag"))).rejects.toThrow( /notWorkflow\.ts.*default export is not a workflow/, ); }); it("rejects a workflow file with no default export", async () => { await expect(buildApp(fixture("no-default-export"))).rejects.toThrow( /"default" is not exported by .*nothing\.ts/, ); }); it("rejects a project with no workflows", async () => { await expect(buildApp(fixture("empty"))).rejects.toThrow(/No capabilities found/); }); }); describe("custom page app home", () => { it("emits a childless custom page with the stable APP.md identity", async () => { const root = fixture("custom-page-app-home"); await buildApp(root); const provisioning = JSON.parse( await fs.promises.readFile(path.join(root, "dist", "provisioning.json"), "utf8"), ); expect(provisioning.intents.at(-1)).toEqual({ type: "page", resourceId: "__notion_apps_app_md__", parent: { type: "resourceId", resourceId: "__notion_apps_workspace__" }, properties: { title: [["APP.md"]] }, icon: { type: "emoji", emoji: "📊" }, customBlockKey: "board", }); }); it("rejects an unknown home page custom block key", async () => { const root = await fs.promises.mkdtemp(path.join(FIXTURES, "custom-page-unknown-")); try { await fs.promises.cp( path.join(FIXTURES, "custom-page-app-home", "src"), path.join(root, "src"), { recursive: true }, ); await fs.promises.writeFile( path.join(root, "APP.md"), "---\ncustom_block: missing\n---\n", ); await expect(buildApp(root)).rejects.toThrow( 'APP.md custom_block "missing" does not match any file in src/customBlocks.', ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); });