From 6f2285f8a5a0db804a7b465c971882d333514225 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 12 Nov 2026 21:39:49 +0100 Subject: [PATCH 1/4] test: render Go template conditionals in the smoke test The smoke test stripped `{{if .plugins.X}}` blocks with a regex, which cannot follow `and`/`not`, `else` branches or nesting, ignored trim markers and skipped `.sql` files. Add a small renderer for `if` chains on `.plugins`, `.appEnv` and `.skipExamples` that leaves every other action verbatim, and use it in the smoke test, with a `--skip-examples` flag to scaffold the way the Builder will. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- tools/smoke-test-template.ts | 45 ++--- tools/template-render.ts | 325 +++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 tools/template-render.ts diff --git a/tools/smoke-test-template.ts b/tools/smoke-test-template.ts index 889ec6c2a..e3a614411 100644 --- a/tools/smoke-test-template.ts +++ b/tools/smoke-test-template.ts @@ -10,9 +10,10 @@ * - {{.projectName}} → smoke-test-app * - {{.appDescription}} → Smoke test app * - {{.appEnv}} → (empty, which means the conditional block is omitted) + * - {{.skipExamples}} → the --skip-examples flag * * Usage: - * tsx tools/smoke-test-template.ts [--output-dir ] [--package-manager ] [--run] + * tsx tools/smoke-test-template.ts [--output-dir ] [--package-manager ] [--run] [--skip-examples] * * Options: * --output-dir Optional. Scratch directory for the scaffolded app. @@ -20,6 +21,8 @@ * --package-manager Optional. Package manager to scaffold (pnpm or npm). * Defaults to "pnpm". (short: -p) * --run Install, build with prebuild, and check HTTP startup. + * --skip-examples Scaffold as the Builder does (`apps init --skip-examples`): + * no example queries or example test. */ import { @@ -34,6 +37,10 @@ import { import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; +import { + renderTemplateConditionals, + type TemplateVars, +} from "./template-render"; import { runTemplateSmoke, selectSmokePackageManager, @@ -46,6 +53,7 @@ const { values } = parseArgs({ "output-dir": { type: "string", default: ".smoke-test/app" }, "package-manager": { type: "string", default: "pnpm", short: "p" }, run: { type: "boolean", default: false }, + "skip-examples": { type: "boolean", default: false }, }, strict: true, }); @@ -76,6 +84,13 @@ console.log(`✓ Copied template → ${outputDir}`); selectSmokePackageManager(SCRATCH_DIR, packageManager); +// Server-only scaffold: no plugins selected, so every plugin block is dropped. +const templateVars: TemplateVars = { + plugins: {}, + appEnv: "", + skipExamples: values["skip-examples"], +}; + // Render placeholders in key files const placeholders = { "{{.projectName}}": "smoke-test-app", @@ -86,7 +101,7 @@ const placeholders = { /** * Recursively process all files in the scaffold directory to: * 1. Replace simple placeholders ({{.projectName}}, {{.appDescription}}) - * 2. Remove Go template conditionals for plugins and appEnv + * 2. Render Go template conditionals on plugins, appEnv and skipExamples */ function processAllFiles(dir: string): void { const entries = readdirSync(dir, { withFileTypes: true }); @@ -111,6 +126,7 @@ function processAllFiles(dir: string): void { fullPath.endsWith(".ts") || fullPath.endsWith(".json") || fullPath.endsWith(".html") || + fullPath.endsWith(".sql") || fullPath.endsWith(".tmpl") || fullPath.endsWith(".yaml") || fullPath.endsWith(".yml")) @@ -126,26 +142,11 @@ function processAllFiles(dir: string): void { } } - // Remove Go template conditionals: {{- if .appEnv}} blocks - if (content.includes("{{") && content.includes(".appEnv")) { - const beforeLen = content.length; - content = content.replace( - /{{-?\s*if\s+\.appEnv\s*}}[\s\S]*?{{-?\s*end\s*}}/g, - "", - ); - if (beforeLen !== content.length) { - modified = true; - } - } - - // Remove Go template conditionals: {{- if .plugins.*}} blocks - if (content.includes("{{") && content.includes(".plugins.")) { - const beforeLen = content.length; - content = content.replace( - /{{-?\s*if\s+\.plugins\.[a-zA-Z0-9_-]+\s*-?\s*}}[\s\S]*?{{-?\s*end\s*-?\s*}}/g, - "", - ); - if (beforeLen !== content.length) { + // Render the {{if}} chains on plugins, appEnv and skipExamples + if (content.includes("{{")) { + const rendered = renderTemplateConditionals(content, templateVars); + if (rendered !== content) { + content = rendered; modified = true; } } diff --git a/tools/template-render.ts b/tools/template-render.ts new file mode 100644 index 000000000..c990a1171 --- /dev/null +++ b/tools/template-render.ts @@ -0,0 +1,325 @@ +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Renders the conditional blocks of the template's Go `text/template` syntax so + * the smoke test and template tests can scaffold the way `databricks apps init` + * does, without the Databricks CLI. + * + * Only `{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}` chains whose conditions + * read the variables in `RENDERED_ROOTS` are evaluated, with Go's trim markers + * and truthiness. Every other action (`{{.projectName}}`, `{{range}}`, an `if` + * on `$vars` or `.bundle`) is kept verbatim, while `if` chains nested inside it + * are still rendered. Conditions may use `not`, `and`, `or` and parentheses. + */ + +/** Template variables whose conditions are rendered; others are left as-is. */ +export const RENDERED_ROOTS = ["plugins", "appEnv", "skipExamples"] as const; + +export interface TemplateVars { + /** Selected plugins, as `apps init` passes them (`{{if .plugins.analytics}}`). */ + plugins?: Record; + appEnv?: string; + /** Builder mode: omit the example queries and example test. */ + skipExamples?: boolean; +} + +interface Text { + type: "text"; + value: string; +} + +interface Action { + type: "action"; + raw: string; + body: string; + trimLeft: boolean; + trimRight: boolean; + /** Set when the action is consumed by rendering, so its trim markers apply. */ + rendered?: boolean; +} + +type Token = Text | Action; + +interface Branch { + /** `undefined` for a plain `{{else}}`. */ + condition: string | undefined; + action: Action; + children: Node[]; +} + +interface Block { + type: "block"; + /** `if` blocks are a chain of branches; other blocks have exactly one. */ + keyword: string; + branches: Branch[]; + end: Action; +} + +type Node = Text | Action | Block; + +const ACTION = /{{(-\s)?([\s\S]*?)(\s-)?}}/g; +const BLOCK_KEYWORDS = new Set(["if", "range", "with", "define", "block"]); + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let last = 0; + for (const match of source.matchAll(ACTION)) { + if (match.index > last) { + tokens.push({ type: "text", value: source.slice(last, match.index) }); + } + tokens.push({ + type: "action", + raw: match[0], + body: match[2].trim(), + trimLeft: match[1] !== undefined, + trimRight: match[3] !== undefined, + }); + last = match.index + match[0].length; + } + if (last < source.length) { + tokens.push({ type: "text", value: source.slice(last) }); + } + return tokens; +} + +function keywordOf(body: string): string { + return body.split(/\s+/, 1)[0]; +} + +function parse(tokens: Token[]): Node[] { + let index = 0; + + function parseNodes(): { nodes: Node[]; stop?: Action } { + const nodes: Node[] = []; + while (index < tokens.length) { + const token = tokens[index++]; + if (token.type === "text") { + nodes.push(token); + continue; + } + const keyword = keywordOf(token.body); + if (keyword === "end" || keyword === "else") { + return { nodes, stop: token }; + } + if (!BLOCK_KEYWORDS.has(keyword)) { + nodes.push(token); + continue; + } + const branches: Branch[] = []; + let action = token; + let condition: string | undefined = token.body.slice(keyword.length); + for (;;) { + const { nodes: children, stop } = parseNodes(); + branches.push({ condition, action, children }); + if (!stop) throw new Error(`Unclosed {{${token.body}}}`); + if (keywordOf(stop.body) === "end") { + nodes.push({ type: "block", keyword, branches, end: stop }); + break; + } + // `{{else}}` or `{{else if cond}}` + const rest = stop.body.slice("else".length).trim(); + action = stop; + condition = rest.startsWith("if ") ? rest.slice(3) : undefined; + } + } + return { nodes }; + } + + const { nodes, stop } = parseNodes(); + if (stop) throw new Error(`Unexpected {{${stop.body}}}`); + return nodes; +} + +class Unrendered extends Error {} + +/** Splits a pipeline into its terms, keeping parenthesised groups and strings whole. */ +function terms(source: string): string[] { + const out: string[] = []; + let i = 0; + while (i < source.length) { + const char = source[i]; + if (/\s/.test(char)) { + i++; + continue; + } + let j = i; + if (char === "(") { + let depth = 0; + for (; j < source.length; j++) { + if (source[j] === "(") depth++; + if (source[j] === ")" && --depth === 0) break; + } + j++; + } else if (char === '"') { + j = source.indexOf('"', i + 1) + 1; + if (j === 0) throw new Unrendered(); + } else { + while (j < source.length && !/[\s()]/.test(source[j])) j++; + } + out.push(source.slice(i, j)); + i = j; + } + return out; +} + +/** + * Go's `text/template` truthiness. Objects count as true like the non-nil + * `*pluginVar` pointers `apps init` passes for selected plugins. + */ +function truthy(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; +} + +function evaluateTerm(term: string, vars: TemplateVars): unknown { + if (term.startsWith("(") && term.endsWith(")")) { + return evaluate(term.slice(1, -1), vars); + } + if (term.startsWith('"')) return term.slice(1, -1); + if (!term.startsWith(".")) throw new Unrendered(); + const path = term.slice(1).split("."); + if (!(RENDERED_ROOTS as readonly string[]).includes(path[0])) { + throw new Unrendered(); + } + let value: unknown = vars; + for (const key of path) { + value = + value && typeof value === "object" + ? (value as Record)[key] + : undefined; + } + return value; +} + +function evaluate(pipeline: string, vars: TemplateVars): unknown { + const [head, ...args] = terms(pipeline); + if (head === undefined) throw new Unrendered(); + const values = () => args.map((arg) => evaluateTerm(arg, vars)); + switch (head) { + case "not": + if (args.length !== 1) throw new Unrendered(); + return !truthy(evaluateTerm(args[0], vars)); + case "and": { + let value: unknown = true; + for (const arg of values()) if (!truthy((value = arg))) return arg; + return value; + } + case "or": { + let value: unknown = false; + for (const arg of values()) if (truthy((value = arg))) return arg; + return value; + } + default: + if (args.length) throw new Unrendered(); + return evaluateTerm(head, vars); + } +} + +/** The branch an `if` chain renders, `null` for none, or `undefined` if it can't be decided. */ +function chooseBranch( + block: Block, + vars: TemplateVars, +): Branch | null | undefined { + try { + // Evaluate every condition so a chain mixing rendered and other variables stays verbatim. + const results = block.branches.map( + (branch) => + branch.condition === undefined || + truthy(evaluate(branch.condition, vars)), + ); + const index = results.indexOf(true); + return index === -1 ? null : block.branches[index]; + } catch (error) { + if (error instanceof Unrendered) return undefined; + throw error; + } +} + +/** Marks the actions of every renderable `if` chain so their trim markers apply. */ +function mark(nodes: Node[], vars: TemplateVars): void { + for (const node of nodes) { + if (node.type !== "block") continue; + if (node.keyword === "if" && chooseBranch(node, vars) !== undefined) { + for (const branch of node.branches) branch.action.rendered = true; + node.end.rendered = true; + } + for (const branch of node.branches) mark(branch.children, vars); + } +} + +function applyTrims(tokens: Token[]): void { + tokens.forEach((token, i) => { + if (token.type !== "action" || !token.rendered) return; + const before = tokens[i - 1]; + const after = tokens[i + 1]; + if (token.trimLeft && before?.type === "text") { + before.value = before.value.trimEnd(); + } + if (token.trimRight && after?.type === "text") { + after.value = after.value.trimStart(); + } + }); +} + +function render(nodes: Node[], vars: TemplateVars): string { + let out = ""; + for (const node of nodes) { + if (node.type === "text") { + out += node.value; + } else if (node.type === "action") { + out += node.raw; + } else { + const chosen = + node.keyword === "if" ? chooseBranch(node, vars) : undefined; + if (chosen === undefined) { + for (const branch of node.branches) { + out += branch.action.raw + render(branch.children, vars); + } + out += node.end.raw; + } else if (chosen) { + out += render(chosen.children, vars); + } + } + } + return out; +} + +/** + * Renders the `if` chains of a template file that read `RENDERED_ROOTS`. + * Like `apps init`, callers should skip a file whose result is only whitespace. + */ +export function renderTemplateConditionals( + source: string, + vars: TemplateVars, +): string { + const tokens = tokenize(source); + const nodes = parse(tokens); + mark(nodes, vars); + applyTrims(tokens); + return render(nodes, vars); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + // Renders template files in place with the default `apps init` variables, e.g. + // so the raw template's own tests can run. Files that render empty are removed. + const files = process.argv.slice(2); + if (!files.length) { + throw new Error("Usage: tsx tools/template-render.ts ..."); + } + for (const file of files) { + const rendered = renderTemplateConditionals(readFileSync(file, "utf-8"), { + plugins: {}, + }); + if (rendered.trim()) writeFileSync(file, rendered); + else rmSync(file); + } +} From 41e4264e3d1aeac94db7354e75720e3e4f07efa7 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 12 Nov 2026 21:39:55 +0100 Subject: [PATCH 2/4] feat: leave example queries and test out with skipExamples The Builder deletes `config/queries/hello_world.sql`, `config/queries/mocked_sales.sql` and `server/example.test.ts` in nearly every run, and the test failed the template's own lint config first (LA-2388). Gate them on a new `.skipExamples` template variable, set by `databricks apps init --skip-examples` (CLI change pending). The check is `not .skipExamples`, so CLIs without the flag and every other user keep the files. With the flag, the Analytics page renders a query-free placeholder instead of reading the example queries. - Fix the example test's `require-await` errors so it passes the template lint config - Document queries and the testing kit in the template README - Render the example test before CI runs the raw template's tests - Add template tests for both modes Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .github/workflows/ci.yml | 24 +-- docs/docs/development/templates.md | 1 + template/README.md.tmpl | 43 ++++++ .../src/pages/analytics/AnalyticsPage.tsx | 20 +++ template/config/queries/hello_world.sql | 2 +- template/config/queries/mocked_sales.sql | 2 +- template/server/example.test.ts | 14 +- tools/template-render.test.ts | 145 ++++++++++++++++++ 8 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 tools/template-render.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4370677d..829f22722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,16 +288,6 @@ jobs: - name: Verify bundled SDKs in both template locks run: pnpm exec tsx tools/check-template-artifact.ts pr-template - # The scaffold's own vitest suite, run against the tarballs built above - # rather than the published versions template/package.json pins. This is - # the only CI check that exercises the template's example test, and the - # only one that consumes @databricks/appkit/testing the way a customer - # does — through a real npm install of a packed tarball. Before the zip, - # so a broken example fails the build instead of shipping. - - name: Run the template's own tests - working-directory: pr-template - run: npm test - # npm install above runs under the JFrog .npmrc (setup-jfrog-npm), which # bakes internal registry URLs into the regenerated locks. Rewrite them back # to public npm and fail-closed if any non-public registry remains, so the @@ -312,6 +302,20 @@ jobs: working-directory: pr-template run: zip -r "../appkit-template-${{ steps.version.outputs.version }}.zip" . -x 'node_modules/*' + # The scaffold's own vitest suite, run against the tarballs built above + # rather than the published versions template/package.json pins. This is + # the only CI check that exercises the template's example test, and the + # only one that consumes @databricks/appkit/testing the way a customer + # does — through a real npm install of a packed tarball. The example test + # is wrapped in a Go template conditional (`apps init --skip-examples` + # leaves it out), so render it first: after the zip, which must keep the + # conditional, and before the upload, so a broken example fails the build + # instead of shipping. + - name: Run the template's own tests + run: | + pnpm exec tsx tools/template-render.ts pr-template/server/example.test.ts + cd pr-template && npm test + - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/docs/development/templates.md b/docs/docs/development/templates.md index ac71a1617..d020da7a4 100644 --- a/docs/docs/development/templates.md +++ b/docs/docs/development/templates.md @@ -30,6 +30,7 @@ Files named with a `_` prefix are renamed to `.` prefix (e.g. `_gitignore` → ` | `.dotEnv.example` | Generated `.env.example` content with placeholders | | `.bundle.*` | Generated `databricks.yml` sections (variables, resources, target variables) | | `.appEnv` | Generated `app.yaml` env entries | +| `.skipExamples` | `true` with `databricks apps init --skip-examples`, used by app builders that replace the starter files. The template then leaves out its example queries (`config/queries/hello_world.sql`, `mocked_sales.sql`) and example test (`server/example.test.ts`), and the Analytics page reads no queries. CLIs without the flag omit the variable, so test it as `{{if not .skipExamples}}` to keep the examples by default. | ### Conditional content diff --git a/template/README.md.tmpl b/template/README.md.tmpl index c6564cb81..bfa330f07 100644 --- a/template/README.md.tmpl +++ b/template/README.md.tmpl @@ -152,6 +152,49 @@ There are a few commands to help you with code quality: {{or .packageManager "pnpm"}} run format {{or .packageManager "pnpm"}} run format:fix ``` +{{- if .plugins.analytics}} + +## SQL Queries + +The Analytics plugin runs the `.sql` files in `config/queries/`. The file name is the query key: + +- `config/queries/.sql` runs as the app's service principal, with one cache shared by all users +- `config/queries/.obo.sql` runs as the signed-in user, with a cache per user + +Declare each parameter in a `-- @param ` comment and reference it as `:name`: + +```sql +-- @param max_month INT +SELECT month, revenue +FROM my_catalog.my_schema.sales +WHERE month_num <= :max_month +ORDER BY month_num; +``` + +Read the result in the client with `useAnalyticsQuery`, or pass the query key to a chart: + +```tsx +import { AreaChart, useAnalyticsQuery } from '@databricks/appkit-ui/react'; +import { sql } from '@databricks/appkit-ui/js'; + +const params = { max_month: sql.number(6) }; +const { data, loading, error } = useAnalyticsQuery('sales', params); + +; +``` + +`{{or .packageManager "pnpm"}} run typegen` generates the result types of every query into `shared/appkit-types/`. See the [Analytics plugin documentation](https://developers.databricks.com/docs/appkit/v0/plugins/analytics). +{{- end}} + +## Testing + +`{{or .packageManager "pnpm"}} test` runs the [Vitest](https://vitest.dev/) suite. `@databricks/appkit/testing` tests plugins with no Databricks workspace, credentials or network: + +- `createTestApp({ plugins })` boots the app on an ephemeral port so you can call its routes over HTTP. Call `close()` when done. +- `createTestPluginContext()` attaches a plugin to a real context with faked edges, for unit tests without a server. +- `expectStream(source).toEmit(...)` asserts the ordered events of a streaming handler. + +See the [testing documentation](https://developers.databricks.com/docs/appkit/v0/plugins/testing). ## Deployment with Databricks Asset Bundles diff --git a/template/client/src/pages/analytics/AnalyticsPage.tsx b/template/client/src/pages/analytics/AnalyticsPage.tsx index e1ca7a9df..ddd3e6755 100644 --- a/template/client/src/pages/analytics/AnalyticsPage.tsx +++ b/template/client/src/pages/analytics/AnalyticsPage.tsx @@ -1,4 +1,23 @@ {{if .plugins.analytics -}} +{{if .skipExamples -}} +import { Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +export function AnalyticsPage() { + return ( +
+ + + Analytics + + + No queries yet. Add a SQL file such as config/queries/my_query.sql and read it with{' '} + {"useAnalyticsQuery('my_query')"}. + + +
+ ); +} +{{- else -}} import { useAnalyticsQuery, AreaChart, @@ -106,3 +125,4 @@ export function AnalyticsPage() { ); } {{- end}} +{{- end}} diff --git a/template/config/queries/hello_world.sql b/template/config/queries/hello_world.sql index a09ef265a..ae8f13159 100644 --- a/template/config/queries/hello_world.sql +++ b/template/config/queries/hello_world.sql @@ -1,4 +1,4 @@ -{{if .plugins.analytics -}} +{{if and .plugins.analytics (not .skipExamples) -}} -- @param message STRING SELECT :message AS value; {{- end}} diff --git a/template/config/queries/mocked_sales.sql b/template/config/queries/mocked_sales.sql index bab03dd7e..4d19a15c7 100644 --- a/template/config/queries/mocked_sales.sql +++ b/template/config/queries/mocked_sales.sql @@ -1,4 +1,4 @@ -{{if .plugins.analytics -}} +{{if and .plugins.analytics (not .skipExamples) -}} -- @param max_month_num INT WITH mock_data AS ( SELECT 'January' AS month, 1 AS month_num, 65000 AS revenue, 45000 AS expenses, 850 AS customers diff --git a/template/server/example.test.ts b/template/server/example.test.ts index 535f800fd..6ecb31680 100644 --- a/template/server/example.test.ts +++ b/template/server/example.test.ts @@ -1,3 +1,4 @@ +{{if not .skipExamples -}} import { Plugin, type PluginManifest, toPlugin } from '@databricks/appkit'; import { createTestApp, createTestPluginContext, expectStream } from '@databricks/appkit/testing'; import { describe, expect, test } from 'vitest'; @@ -7,7 +8,8 @@ import { describe, expect, test } from 'vitest'; * * The kit lets you test a plugin with NO Databricks workspace, credentials, or * network — so these tests run anywhere, including CI. Delete this file, or use - * it as a starting point for testing your own plugins. + * it as a starting point for testing your own plugins. Scaffolds created with + * `databricks apps init --skip-examples` leave it out. * * Three headline helpers are shown below: * - `createTestApp({ plugins })` — boot a real app (real Express, real routes, @@ -34,15 +36,17 @@ class GreeterPlugin extends Plugin { resources: { required: [], optional: [] }, } as PluginManifest<'greeter'>; - async setup() { + setup() { // Routes registered here are captured by createTestPluginContext().routes. this.context?.addRoute('get', '/hello', (_req, res) => { res.end(); }); + return Promise.resolve(); } // A stand-in for a streaming handler: yields SSE-style event objects. - async *greet(name: string) { + // expectStream also takes async iterables and SSE responses. + *greet(name: string) { yield { type: 'greeting_start', name }; yield { type: 'greeting_end', message: `Hello, ${name}!` }; } @@ -53,9 +57,10 @@ class GreeterPlugin extends Plugin { name: 'greet', method: 'post', path: '/greet', - handler: async (req, res) => { + handler: (req, res) => { const { name } = req.body as { name: string }; res.json({ message: `Hello, ${name}!` }); + return Promise.resolve(); }, }); } @@ -98,3 +103,4 @@ describe('testing kit example', () => { } }); }); +{{- end}} diff --git a/tools/template-render.test.ts b/tools/template-render.test.ts new file mode 100644 index 000000000..a03ed4888 --- /dev/null +++ b/tools/template-render.test.ts @@ -0,0 +1,145 @@ +import { globSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + renderTemplateConditionals, + type TemplateVars, +} from "./template-render"; + +const root = resolve(import.meta.dirname, ".."); +const template = join(root, "template"); + +describe("renderTemplateConditionals", () => { + const render = (source: string, vars: TemplateVars = {}) => + renderTemplateConditionals(source, vars); + + test("renders plugin conditionals", () => { + const source = "a{{if .plugins.analytics}}b{{end}}c"; + expect(render(source, { plugins: { analytics: {} } })).toBe("abc"); + expect(render(source, { plugins: {} })).toBe("ac"); + }); + + test("applies trim markers", () => { + const source = "a\n{{- if .plugins.x}}\nb\n{{- end}}\nc"; + expect(render(source, { plugins: { x: {} } })).toBe("a\nb\nc"); + expect(render(source)).toBe("a\nc"); + expect(render("{{if .plugins.x -}}\n b\n{{- end}}\n", {})).toBe("\n"); + }); + + test("evaluates not, and, or and parentheses with Go truthiness", () => { + const vars = { plugins: { a: {} }, appEnv: "", skipExamples: true }; + expect(render("{{if not .skipExamples}}x{{end}}", vars)).toBe(""); + expect(render("{{if not .skipExamples}}x{{end}}", {})).toBe("x"); + expect( + render("{{if and .plugins.a (not .skipExamples)}}x{{end}}", {}), + ).toBe(""); + expect( + render("{{if and .plugins.a (not .skipExamples)}}x{{end}}", { + plugins: { a: {} }, + }), + ).toBe("x"); + expect(render("{{if or .appEnv .plugins.b}}x{{end}}", vars)).toBe(""); + expect(render("{{if or .appEnv .plugins.a}}x{{end}}", vars)).toBe("x"); + }); + + test("picks the first matching branch of an else-if chain", () => { + const source = "{{if .plugins.a}}A{{else if .plugins.b}}B{{else}}C{{end}}"; + expect(render(source, { plugins: { a: {}, b: {} } })).toBe("A"); + expect(render(source, { plugins: { b: {} } })).toBe("B"); + expect(render(source, { plugins: {} })).toBe("C"); + }); + + test("keeps other actions verbatim but renders conditionals nested in them", () => { + const source = + "{{- if .bundle.x}}{{.projectName}}{{- if .plugins.a}}A{{- end}}{{- end}}"; + expect(render(source, { plugins: {} })).toBe( + "{{- if .bundle.x}}{{.projectName}}{{- end}}", + ); + expect( + render( + '{{range $n, $p := .plugins}}{{if eq $p.S "beta"}}b{{end}}{{end}}', + ), + ).toBe('{{range $n, $p := .plugins}}{{if eq $p.S "beta"}}b{{end}}{{end}}'); + }); + + test("rejects unbalanced blocks", () => { + expect(() => render("{{if .plugins.a}}x")).toThrow(/Unclosed/); + expect(() => render("x{{end}}")).toThrow(/Unexpected/); + }); +}); + +describe("template scaffold", () => { + const plugins = Object.fromEntries( + Object.keys( + ( + JSON.parse( + readFileSync(join(template, "appkit.plugins.json"), "utf-8"), + ) as { plugins: Record } + ).plugins, + ).map((name) => [name, {}]), + ); + const files = globSync("**/*", { + cwd: template, + exclude: (name) => name === "node_modules", + }).filter((file) => /\.(tsx?|sql|json|tmpl|ya?ml|html)$/.test(file)); + + /** Renders the template like `apps init`: files that render empty are skipped. */ + function scaffold(vars: TemplateVars): Map { + const out = new Map(); + for (const file of files) { + const rendered = renderTemplateConditionals( + readFileSync(join(template, file), "utf-8"), + vars, + ); + if (rendered.trim()) out.set(file, rendered); + } + return out; + } + + const examples = [ + "config/queries/hello_world.sql", + "config/queries/mocked_sales.sql", + "server/example.test.ts", + ]; + + test("keeps the example queries and test by default", () => { + const app = scaffold({ plugins }); + for (const file of examples) expect(app.has(file), file).toBe(true); + expect(app.get("client/src/pages/analytics/AnalyticsPage.tsx")).toContain( + "useAnalyticsQuery('hello_world'", + ); + }); + + test("leaves the example queries and test out with skipExamples", () => { + const app = scaffold({ plugins, skipExamples: true }); + for (const file of examples) expect(app.has(file), file).toBe(false); + for (const [file, content] of app) { + expect(content, file).not.toMatch(/hello_world|mocked_sales/); + } + expect(app.get("client/src/pages/analytics/AnalyticsPage.tsx")).toContain( + "export function AnalyticsPage()", + ); + }); + + test("renders no example queries without the analytics plugin", () => { + const app = scaffold({ plugins: {} }); + expect(app.has("config/queries/hello_world.sql")).toBe(false); + expect(app.has("config/queries/mocked_sales.sql")).toBe(false); + expect(app.has("server/example.test.ts")).toBe(true); + }); + + test.each([false, true])( + "leaves no template syntax in the gated files (skipExamples: %s)", + (skipExamples) => { + const app = scaffold({ plugins, skipExamples }); + for (const file of [ + ...examples, + "client/src/pages/analytics/AnalyticsPage.tsx", + ]) { + expect(app.get(file) ?? "", file).not.toContain("{{"); + } + }, + ); +}); From 4c677766c1f1c8a6bbfd6b090433562b6a2ba8f9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 12 Nov 2026 23:02:28 +0100 Subject: [PATCH 3/4] fix(appkit): stop telling scaffolding agents to add a query before typegen The analytics `should` rule asked agents to make sure config/queries/ has a .sql file before typegen. With `apps init --skip-examples` the folder is gone on purpose, and typegen, build and the server all work without it, so the rule would send the Builder back to create a placeholder. Say to add queries only for what the app runs. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/plugins/analytics/manifest.json | 2 +- template/appkit.plugins.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index d811f061d..63e016105 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -31,7 +31,7 @@ "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" ], "should": [ - "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + "Add .sql files to config/queries/ only for queries the app runs; typegen and build work without the folder" ] } }, diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index e32eb3ccb..7c01917a2 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -196,7 +196,7 @@ "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" ], "should": [ - "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + "Add .sql files to config/queries/ only for queries the app runs; typegen and build work without the folder" ] } } From 0d42d573c9412b2d95dbfff04e3a0cc56b2af2ec Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 12 Nov 2026 23:19:24 +0100 Subject: [PATCH 4/4] docs: don't present the planned --skip-examples flag as available No released Databricks CLI has `apps init --skip-examples` yet. Describe `.skipExamples` by what it does, note that the CLI flag is planned and that the variable is unset until then, and drop the flag from the comment in the scaffolded example test and from internal comments. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .github/workflows/ci.yml | 4 ++-- docs/docs/development/templates.md | 2 +- template/server/example.test.ts | 3 +-- tools/smoke-test-template.ts | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829f22722..90011103a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,8 +307,8 @@ jobs: # the only CI check that exercises the template's example test, and the # only one that consumes @databricks/appkit/testing the way a customer # does — through a real npm install of a packed tarball. The example test - # is wrapped in a Go template conditional (`apps init --skip-examples` - # leaves it out), so render it first: after the zip, which must keep the + # is wrapped in a Go template conditional (`.skipExamples` leaves it + # out), so render it first: after the zip, which must keep the # conditional, and before the upload, so a broken example fails the build # instead of shipping. - name: Run the template's own tests diff --git a/docs/docs/development/templates.md b/docs/docs/development/templates.md index d020da7a4..88dbdc1cc 100644 --- a/docs/docs/development/templates.md +++ b/docs/docs/development/templates.md @@ -30,7 +30,7 @@ Files named with a `_` prefix are renamed to `.` prefix (e.g. `_gitignore` → ` | `.dotEnv.example` | Generated `.env.example` content with placeholders | | `.bundle.*` | Generated `databricks.yml` sections (variables, resources, target variables) | | `.appEnv` | Generated `app.yaml` env entries | -| `.skipExamples` | `true` with `databricks apps init --skip-examples`, used by app builders that replace the starter files. The template then leaves out its example queries (`config/queries/hello_world.sql`, `mocked_sales.sql`) and example test (`server/example.test.ts`), and the Analytics page reads no queries. CLIs without the flag omit the variable, so test it as `{{if not .skipExamples}}` to keep the examples by default. | +| `.skipExamples` | When `true`, the template leaves out its example queries (`config/queries/hello_world.sql`, `mocked_sales.sql`) and example test (`server/example.test.ts`), and the Analytics page reads no queries. Meant for app builders that replace the starter files. No released Databricks CLI sets it yet (a `databricks apps init` flag for it is planned), so it's unset and the examples are kept. Test it as `{{if not .skipExamples}}` so an unset variable keeps them. | ### Conditional content diff --git a/template/server/example.test.ts b/template/server/example.test.ts index 6ecb31680..80894aa14 100644 --- a/template/server/example.test.ts +++ b/template/server/example.test.ts @@ -8,8 +8,7 @@ import { describe, expect, test } from 'vitest'; * * The kit lets you test a plugin with NO Databricks workspace, credentials, or * network — so these tests run anywhere, including CI. Delete this file, or use - * it as a starting point for testing your own plugins. Scaffolds created with - * `databricks apps init --skip-examples` leave it out. + * it as a starting point for testing your own plugins. * * Three headline helpers are shown below: * - `createTestApp({ plugins })` — boot a real app (real Express, real routes, diff --git a/tools/smoke-test-template.ts b/tools/smoke-test-template.ts index e3a614411..5ff40c647 100644 --- a/tools/smoke-test-template.ts +++ b/tools/smoke-test-template.ts @@ -21,8 +21,8 @@ * --package-manager Optional. Package manager to scaffold (pnpm or npm). * Defaults to "pnpm". (short: -p) * --run Install, build with prebuild, and check HTTP startup. - * --skip-examples Scaffold as the Builder does (`apps init --skip-examples`): - * no example queries or example test. + * --skip-examples Set the `.skipExamples` template variable, as the Builder + * will: no example queries or example test. */ import {