From 4c2c165a5b757bd5f2f49074bb290407bce3b2fb Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:10:28 +0100 Subject: [PATCH] feat(cli): add unified generate-metadata command (#8335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add unified generate-metadata command - Add generate-metadata command that calls script, flow, and app handlers - Export generateLocks from flow.ts and generateMetadata from script.ts - Add deprecation warnings to individual metadata commands * feat(cli): improve unified generate-metadata command - Use internal handlers for single-pass collection of stale items - Add --dry-run flag to show what would be updated - Fix WASM parser init deprecation warning - Add comprehensive tests for all flags - Match original handler behavior for per-item messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(cli): add skip flags and comprehensive tests for generate-metadata - Add --skip-scripts, --skip-flows, --skip-apps flags for granular control - --schema-only now properly skips flows and apps (they only have locks) - Dynamic "Checking X, Y, Z..." message based on what's being processed - Show warning when all types are skipped - Add comprehensive tests for all flags: - --dry-run shows stale items without updating - --schema-only only processes scripts - --skip-scripts, --skip-flows, --skip-apps work correctly - skipping all types shows warning - 'All metadata up-to-date' when nothing to update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * improve output Signed-off-by: pyranota * refactor(cli): add shared test fixtures with cross-links Co-Authored-By: Claude Opus 4.5 * feat(cli): add folder argument to generate-metadata command Co-Authored-By: Claude Opus 4.5 --------- Signed-off-by: pyranota Co-authored-by: Claude Opus 4.5 --- cli/src/commands/app/app.ts | 3 + cli/src/commands/app/app_metadata.ts | 18 +- cli/src/commands/flow/flow.ts | 5 +- cli/src/commands/flow/flow_metadata.ts | 10 +- .../generate-metadata/generate-metadata.ts | 332 +++++++++++ cli/src/commands/script/script.ts | 5 +- cli/src/main.ts | 2 + cli/src/utils/metadata.ts | 4 +- cli/test/sync_pull_push.test.ts | 23 +- cli/test/test_backend.ts | 13 + cli/test/test_fixtures.ts | 531 ++++++++++++++++++ cli/test/unified_generate_metadata.test.ts | 451 +++++++++++++++ 12 files changed, 1378 insertions(+), 19 deletions(-) create mode 100644 cli/src/commands/generate-metadata/generate-metadata.ts create mode 100644 cli/test/test_fixtures.ts create mode 100644 cli/test/unified_generate_metadata.test.ts diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 3aa8305542..f048aac616 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -275,6 +275,9 @@ const command = new Command() "Default TypeScript runtime (bun or deno)" ) .action(async (opts: any, appFolder: string | undefined) => { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const { generateLocksCommand } = await import("./app_metadata.ts"); await generateLocksCommand(opts, appFolder); }); diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 3a28770225..78a3019566 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -157,7 +157,7 @@ export async function generateAppLocksInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -180,9 +180,11 @@ export async function generateAppLocksInternal( } if (changedScripts.length > 0) { - log.info( - `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` - ); + if (!noStaleMessage) { + log.info( + `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` + ); + } if (rawApp) { const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER); @@ -230,7 +232,7 @@ export async function generateAppLocksInternal( yamlStringify(appFile as Record, yamlOptions) ); } - } else { + } else if (!noStaleMessage) { log.info(colors.gray(`No scripts changed in ${appFolder}`)); } } @@ -246,7 +248,9 @@ export async function generateAppLocksInternal( for (const [scriptPath, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(appFolder, hash, scriptPath); } - log.info(colors.green(`App ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`App ${remote_path} lockfiles updated`)); + } } /** @@ -767,7 +771,7 @@ export async function inferRunnableSchemaFromFile( } } -function getAppFolders(elems: Record, extension: string) { +export function getAppFolders(elems: Record, extension: string) { return Object.keys(elems) .filter((p) => p.endsWith(SEP + extension)) .map((p) => p.substring(0, p.length - (SEP + extension).length)); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index f0974cf354..3b01385fb9 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -308,12 +308,15 @@ async function preview( } } -async function generateLocks( +export async function generateLocks( opts: GlobalOptions & { yes?: boolean; } & SyncOptions, folder: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const workspace = await resolveWorkspace(opts); await requireLogin(opts); opts = await mergeConfigWithConfigFile(opts); diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index d52d05a3ed..2e48efaa10 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -98,7 +98,7 @@ export async function generateFlowLockInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -121,7 +121,9 @@ export async function generateFlowLockInternal( } } - log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + if (!noStaleMessage) { + log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + } const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, @@ -180,7 +182,9 @@ export async function generateFlowLockInternal( for (const [path, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(folder, hash, path); } - log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + } } /** diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts new file mode 100644 index 0000000000..0d7435856c --- /dev/null +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -0,0 +1,332 @@ +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { colors } from "@cliffy/ansi/colors"; +import { sep as SEP } from "node:path"; +import { GlobalOptions } from "../../types.ts"; +import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { requireLogin } from "../../core/auth.ts"; +import * as log from "../../core/log.ts"; +import { + generateScriptMetadataInternal, + getRawWorkspaceDependencies, +} from "../../utils/metadata.ts"; +import { generateFlowLockInternal } from "../flow/flow_metadata.ts"; +import { generateAppLocksInternal, getAppFolders } from "../app/app_metadata.ts"; +import { + elementsToMap, + FSFSElement, + ignoreF, +} from "../sync/sync.ts"; +import { exts } from "../script/script.ts"; +import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts"; +import { listSyncCodebases } from "../../utils/codebase.ts"; + +interface StaleItem { + type: "script" | "flow" | "app"; + path: string; + folder: string; + isRawApp?: boolean; +} + +async function generateMetadata( + opts: GlobalOptions & { + yes?: boolean; + lockOnly?: boolean; + schemaOnly?: boolean; + dryRun?: boolean; + skipScripts?: boolean; + skipFlows?: boolean; + skipApps?: boolean; + } & SyncOptions, + folder?: string +) { + if (folder === "") { + folder = undefined; + } + + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + opts = await mergeConfigWithConfigFile(opts); + + const rawWorkspaceDependencies = await getRawWorkspaceDependencies(); + const codebases = await listSyncCodebases(opts); + const ignore = await ignoreF(opts); + + const staleItems: StaleItem[] = []; + + // --schema-only implies skipping flows and apps (they only have locks, no schemas) + const skipScripts = opts.skipScripts ?? false; + const skipFlows = opts.skipFlows ?? opts.schemaOnly ?? false; + const skipApps = opts.skipApps ?? opts.schemaOnly ?? false; + + const checking: string[] = []; + if (!skipScripts) checking.push("scripts"); + if (!skipFlows) checking.push("flows"); + if (!skipApps) checking.push("apps"); + + if (checking.length === 0) { + log.info(colors.yellow("Nothing to check (all types skipped)")); + return; + } + + log.info(colors.gray(`Checking ${checking.join(", ")}...`)); + + // === Collect stale scripts === + if (!skipScripts) { + // TODO: run elementsToMap only once but for all runnable types. + const scriptElems = await elementsToMap( + await FSFSElement(process.cwd(), codebases, false), + (p, isD) => { + return ( + (!isD && !exts.some((ext) => p.endsWith(ext))) || + ignore(p, isD) || + isFlowPath(p) || + isAppPath(p) + ); + }, + false, + {} + ); + + for (const e of Object.keys(scriptElems)) { + const candidate = await generateScriptMetadataInternal( + e, + workspace, + opts, + true, // dryRun + true, // noStaleMessage + rawWorkspaceDependencies, + codebases, + false + ); + if (candidate) { + staleItems.push({ type: "script", path: candidate, folder: e }); + } + } + } + + // === Collect stale flows === + if (!skipFlows) { + const flowElems = Object.keys( + await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "flow.yaml") && + !p.endsWith(SEP + "flow.json")) + ); + }, + false, + {} + ) + ).map((x) => x.substring(0, x.lastIndexOf(SEP))); + + for (const folder of flowElems) { + const candidate = await generateFlowLockInternal( + folder, + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "flow", path: candidate, folder }); + } + } + } + + // === Collect stale apps === + if (!skipApps) { + const elems = await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "raw_app.yaml") && + !p.endsWith(SEP + "app.yaml")) + ); + }, + false, + {} + ); + + const rawAppFolders = getAppFolders(elems, "raw_app.yaml"); + const appFolders = getAppFolders(elems, "app.yaml"); + + for (const appFolder of rawAppFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + true, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: true }); + } + } + + for (const appFolder of appFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + false, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: false }); + } + } + } + + // === Filter by folder if specified === + let filteredItems = staleItems; + if (folder) { + // Strip trailing separator to match deprecated flow/app handler behavior + // (see generateFlowLockInternal line 64-66, generateAppLocksInternal line 109-110) + if (folder.endsWith(SEP)) { + folder = folder.substring(0, folder.length - 1); + } + filteredItems = staleItems.filter((item) => item.folder === folder || item.folder.startsWith(folder + SEP)); + } + + // === Show stale items and confirm === + if (filteredItems.length === 0) { + log.info(colors.green("All metadata up-to-date")); + return; + } + + // Group items by type for display + const scripts = filteredItems.filter((i) => i.type === "script"); + const flows = filteredItems.filter((i) => i.type === "flow"); + const apps = filteredItems.filter((i) => i.type === "app"); + + log.info(""); + log.info(`Found ${filteredItems.length} item(s) with stale metadata:`); + + if (scripts.length > 0) { + log.info(colors.gray(` Scripts (${scripts.length}):`)); + for (const item of scripts) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (flows.length > 0) { + log.info(colors.gray(` Flows (${flows.length}):`)); + for (const item of flows) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (apps.length > 0) { + log.info(colors.gray(` Apps (${apps.length}):`)); + for (const item of apps) { + log.info(colors.yellow(` ${item.path}`)); + } + } + + if (opts.dryRun) { + return; + } + + log.info(""); + + if ( + !opts.yes && + !(await Confirm.prompt({ + message: "Update metadata?", + default: true, + })) + ) { + return; + } + + log.info(""); + + // === Process all stale items with progress counter === + const total = filteredItems.length; + const maxWidth = `[${total}/${total}]`.length; + let current = 0; + + const formatProgress = (n: number) => { + const bracket = `[${n}/${total}]`; + return colors.gray(bracket.padEnd(maxWidth, " ")); + }; + + // Process scripts + for (const item of scripts) { + current++; + log.info(`${formatProgress(current)} script ${colors.cyan(item.path)}`); + await generateScriptMetadataInternal( + item.folder, + workspace, + opts, + false, // dryRun + true, // noStaleMessage - we handle output + rawWorkspaceDependencies, + codebases, + false + ); + } + + // Process flows + for (const item of flows) { + current++; + log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}`); + await generateFlowLockInternal( + item.folder, + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + // Process apps + for (const item of apps) { + current++; + log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}`); + await generateAppLocksInternal( + item.folder, + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ); + } + + log.info(""); + log.info(colors.green(`Done. Updated ${total} item(s).`)); +} + +const command = new Command() + .description("Generate metadata (locks, schemas) for all scripts, flows, and apps") + .arguments("[folder:string]") + .option("--yes", "Skip confirmation prompt") + .option("--dry-run", "Show what would be updated without making changes") + .option("--lock-only", "Re-generate only the lock files") + .option("--schema-only", "Re-generate only script schemas (skips flows and apps)") + .option("--skip-scripts", "Skip processing scripts") + .option("--skip-flows", "Skip processing flows") + .option("--skip-apps", "Skip processing apps") + .option( + "-i --includes ", + "Comma separated patterns to specify which files to include" + ) + .option( + "-e --excludes ", + "Comma separated patterns to specify which files to exclude" + ) + .action(generateMetadata as any); + +export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9c6f094b41..3a6556b34d 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -978,7 +978,7 @@ export type GlobalDeps = Map< Record >; -async function generateMetadata( +export async function generateMetadata( opts: GlobalOptions & { lockOnly?: boolean; schemaOnly?: boolean; @@ -986,6 +986,9 @@ async function generateMetadata( } & SyncOptions, scriptPath: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); log.info( "This command only works for workspace scripts, for flows inline scripts use `wmill flow generate-locks`" ); diff --git a/cli/src/main.ts b/cli/src/main.ts index f923ef38fd..d81570e784 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -39,6 +39,7 @@ import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; +import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; import { fetchVersion } from "./core/context.ts"; @@ -129,6 +130,7 @@ const command = new Command() .command("queues", queues) .command("dependencies", dependencies) .command("jobs", jobs) + .command("generate-metadata", generateMetadata) .command("docs", docs) .command("version --version", "Show version information") .action(async (opts: any) => { diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 590c3d7d8e..76ff33247a 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -35,7 +35,7 @@ function loadParser(pkgName: string): Promise { const wasmPath = _require.resolve( `${pkgName}/windmill_parser_wasm_bg.wasm` ); - await mod.default(readFileSync(wasmPath)); + await mod.default({ module_or_path: readFileSync(wasmPath) }); return mod; })(); _parserCache.set(pkgName, p); @@ -223,7 +223,7 @@ export async function generateScriptMetadataInternal( return `${remotePath} (${language})`; } - if (!justUpdateMetadataLock) { + if (!justUpdateMetadataLock && !noStaleMessage) { log.info(colors.gray(`Generating metadata for ${scriptPath}`)); } diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 2b062eb157..613de2ef82 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -3,6 +3,13 @@ * * Tests the sync pull and push functionality with a simulated filesystem * containing every kind of Windmill resource type. + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Shared local fixtures (prefer using this module for new tests) + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * + * This file contains: Local fixtures (should migrate to test_fixtures.ts) + createRemoteScript + * If you add new helpers, update cross-links in the files above. */ import { expect, test, describe } from "bun:test"; @@ -37,10 +44,13 @@ import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path- // ============================================================================= // Test Fixtures - Every Type of Windmill Resource +// See file header for cross-links to related helpers. +// Consider migrating these to test_fixtures.ts for reuse across tests. // ============================================================================= /** - * Creates a mock script file structure + * Creates a mock script file structure. + * See file header for cross-links to related helpers. */ function createScriptFixture( name: string, @@ -89,7 +99,8 @@ kind: script } /** - * Creates a mock flow file structure + * Creates a mock flow file structure. + * See file header for cross-links to related helpers. */ function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); @@ -123,7 +134,8 @@ schema: } /** - * Creates a mock app file structure + * Creates a mock app file structure. + * See file header for cross-links to related helpers. */ function createAppFixture(name: string): Record { const appSuffix = getFolderSuffix("app"); @@ -151,7 +163,8 @@ policy: } /** - * Creates a mock raw_app file structure + * Creates a mock raw_app file structure. + * See file header for cross-links to related helpers. */ function createRawAppFixture(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); @@ -1920,7 +1933,7 @@ excludes: [] import type { TestBackend } from "./test_backend.ts"; -/** Create a script on the remote via API */ +/** Create a script on the remote via API. See file header for cross-links. */ async function createRemoteScript( backend: TestBackend, scriptPath: string, diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 63c7f3078d..16fb79528d 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -19,6 +19,13 @@ * // ... * }); * }); + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Local file fixtures (createLocalScript, createLocalFlow, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: API-based creation helpers (createTestApp, createTestResource, etc.) + * If you add new helpers, update cross-links in the files above. */ import { CargoBackend, CargoBackendConfig } from "./cargo_backend.ts"; @@ -109,6 +116,7 @@ class CargoBackendAdapter implements TestBackend { return this.backend.apiRequest(path, options); } + /** Seeds test data via API calls. See file header for cross-links to related helpers. */ async seedTestData(): Promise { // Create test folder first await this.createTestFolder("test"); @@ -124,6 +132,7 @@ class CargoBackendAdapter implements TestBackend { await this.createTestApp("f/test/test_dashboard"); } + /** See file header for cross-links to related helpers. */ private async createTestApp(path: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/apps/create`, { method: "POST", @@ -156,6 +165,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestFolder(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/folders/create`, { method: "POST", @@ -172,6 +182,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestGroup(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/groups/create`, { method: "POST", @@ -189,6 +200,7 @@ class CargoBackendAdapter implements TestBackend { } + /** See file header for cross-links to related helpers. */ private async createTestResource(path: string, description: string): Promise { // First ensure the folder exists const folderPath = path.split("/").slice(0, 2).join("/"); // e.g., "f/test" @@ -228,6 +240,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestVariable(path: string, value: string): Promise { const response = await this.backend.apiRequest( `/api/w/${this.workspace}/variables/create`, diff --git a/cli/test/test_fixtures.ts b/cli/test/test_fixtures.ts new file mode 100644 index 0000000000..5f64869949 --- /dev/null +++ b/cli/test/test_fixtures.ts @@ -0,0 +1,531 @@ +/** + * Test Fixtures + * + * Shared helpers for creating test data (scripts, flows, apps, raw apps) in tests. + * + * Two types of helpers: + * - Fixture functions: Return data structures with paths and contents (no disk I/O) + * - Local creation functions: Create fixtures AND write them to disk + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: Shared local fixtures (createLocalScript, createLocalFlow, etc.) + * If you add new helpers, update cross-links in the files above. + * + * @example + * // Using fixtures (data only) + * const fixture = createScriptFixture("my_script", "bun"); + * + * // Using local creation (writes to disk) + * await createLocalScript(tempDir, "f/test", "my_script", "bun"); + * + * @keywords createLocal, local script, local flow, local app, raw app, fixture, test data + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { + getFolderSuffix, + getMetadataFileName, +} from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// Fixture Types +// ============================================================================= + +export interface FileFixture { + path: string; + content: string; +} + +export interface ScriptFixture { + contentFile: FileFixture; + metadataFile: FileFixture; +} + +export interface FlowFixture { + metadata: FileFixture; + inlineScript: FileFixture; +} + +export interface AppFixture { + metadata: FileFixture; +} + +export interface RawAppFixture { + metadata: FileFixture; + indexHtml: FileFixture; + indexJs: FileFixture; + [key: string]: FileFixture; +} + +// ============================================================================= +// Script Fixtures +// ============================================================================= + +/** + * Creates a script fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * Use this when you need fine-grained control over the script structure. + * For simple cases, use {@link createLocalScript} instead. + * + * @param name - Script name (without extension) + * @param language - Script language + * @param content - Optional custom script content + * @returns Script fixture with content and metadata files + * + * @example + * const fixture = createScriptFixture("my_script", "bun"); + * const fixture = createScriptFixture("custom", "python3", "def main(): return 42"); + * + * @keywords script fixture, create script, local script + */ +export function createScriptFixture( + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): ScriptFixture { + const extensions: Record = { + python3: ".py", + deno: ".ts", + bun: ".ts", + bash: ".sh", + go: ".go", + postgresql: ".sql", + }; + + const ext = extensions[language]; + const defaultContent: Record = { + python3: `def main():\n return "Hello from ${name}"`, + deno: `export async function main() {\n return "Hello from ${name}";\n}`, + bun: `export async function main() {\n return "Hello from ${name}";\n}`, + bash: `#!/bin/bash\necho "Hello from ${name}"`, + go: `package inner\n\nfunc main() string {\n return "Hello from ${name}"\n}`, + postgresql: `-- ${name}\nSELECT 'Hello from ${name}';`, + }; + + return { + contentFile: { + path: `${name}${ext}`, + content: content ?? defaultContent[language], + }, + metadataFile: { + path: `${name}.script.yaml`, + content: `summary: "${name} script" +description: "A ${language} script for testing" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +is_template: false +lock: "" +kind: script +`, + }, + }; +} + +// ============================================================================= +// Flow Fixtures +// ============================================================================= + +/** + * Creates a flow fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: language, summary, description + * + * Use this when you need fine-grained control over the flow structure. + * For simple cases, use {@link createLocalFlow} instead. + * + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * @returns Flow fixture with metadata and inline script + * + * @example + * const fixture = createFlowFixture("my_flow"); + * + * @keywords flow fixture, create flow, local flow + */ +export function createFlowFixture( + name: string, + inlineScriptContent?: string +): FlowFixture { + const flowSuffix = getFolderSuffix("flow"); + const metadataFile = getMetadataFileName("flow", "yaml"); + + const scriptContent = + inlineScriptContent ?? + `export async function main() {\n return "Hello from flow ${name}";\n}`; + + return { + metadata: { + path: `${name}${flowSuffix}/${metadataFile}`, + content: `summary: "${name} flow" +description: "A flow for testing" +value: + modules: + - id: a + value: + type: rawscript + content: | + ${scriptContent.split("\n").join("\n ")} + language: bun + input_transforms: {} +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +`, + }, + inlineScript: { + path: `${name}${flowSuffix}/a.inline_script.ts`, + content: scriptContent, + }, + }; +} + +// ============================================================================= +// App Fixtures +// ============================================================================= + +/** + * Creates an app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, summary, grid + * + * Use this when you need fine-grained control over the app structure. + * For simple cases, use {@link createLocalApp} instead. + * + * @param name - App name + * @returns App fixture with metadata + * + * @example + * const fixture = createAppFixture("my_app"); + * + * @keywords app fixture, create app, local app + */ +export function createAppFixture(name: string): AppFixture { + const appSuffix = getFolderSuffix("app"); + const metadataFile = getMetadataFileName("app", "yaml"); + + return { + metadata: { + path: `${name}${appSuffix}/${metadataFile}`, + content: `summary: "${name} app" +value: + type: app + grid: + - id: button1 + data: + type: buttoncomponent + componentInput: + type: runnable + runnable: + type: runnableByName + inlineScript: + content: | + export async function main() { + return "hello from app"; + } + language: bun + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`, + }, + }; +} + +// ============================================================================= +// Raw App Fixtures +// ============================================================================= + +/** + * Creates a raw app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, htmlContent, jsContent + * + * Raw apps are React/frontend apps with separate inline scripts. + * Use this when you need fine-grained control over the raw app structure. + * For simple cases, use {@link createLocalRawApp} instead. + * + * @param name - Raw app name + * @returns Raw app fixture with metadata and frontend files + * + * @example + * const fixture = createRawAppFixture("my_raw_app"); + * + * @keywords raw app fixture, create raw app, local raw app, react app + */ +export function createRawAppFixture(name: string): RawAppFixture { + const rawAppSuffix = getFolderSuffix("raw_app"); + const metadataFile = getMetadataFileName("raw_app", "yaml"); + + return { + metadata: { + path: `${name}${rawAppSuffix}/${metadataFile}`, + content: `summary: "${name} raw app" +policy: + execution_mode: publisher + triggerables: {} +`, + }, + indexHtml: { + path: `${name}${rawAppSuffix}/index.html`, + content: ` + +${name} +
+`, + }, + indexJs: { + path: `${name}${rawAppSuffix}/index.tsx`, + content: `import React from 'react' +import { createRoot } from 'react-dom/client' + +const App = () =>

${name}

+ +const root = createRoot(document.getElementById('root')!) +root.render() +`, + }, + packageJson: { + path: `${name}${rawAppSuffix}/package.json`, + content: `{ + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0" + } +}`, + }, + inlineScript: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.ts`, + content: `export async function main(x: string) { + return x +} +`, + }, + inlineScriptLock: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.lock`, + content: ``, + }, + }; +} + +// ============================================================================= +// Local Creation Functions (Fixture + Write to Disk) +// ============================================================================= + +/** + * Creates a script on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a script fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Script name (without extension) + * @param language - Script language (default: "bun") + * @param content - Optional custom script content + * + * @example + * await createLocalScript(tempDir, "f/test", "my_script"); + * await createLocalScript(tempDir, "f/test", "custom", "python3", "def main(): return 42"); + * + * @keywords create local script, local script, write script, script on disk + */ +export async function createLocalScript( + tempDir: string, + path: string, + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): Promise { + const fixture = createScriptFixture(name, language, content); + await mkdir(`${tempDir}/${path}`, { recursive: true }); + await writeFile( + `${tempDir}/${path}/${fixture.contentFile.path}`, + fixture.contentFile.content, + "utf-8" + ); + await writeFile( + `${tempDir}/${path}/${fixture.metadataFile.path}`, + fixture.metadataFile.content, + "utf-8" + ); +} + +/** + * Creates a flow on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a flow fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * + * @example + * await createLocalFlow(tempDir, "f/test", "my_flow"); + * + * @keywords create local flow, local flow, write flow, flow on disk + */ +export async function createLocalFlow( + tempDir: string, + path: string, + name: string, + inlineScriptContent?: string +): Promise { + const fixture = createFlowFixture(name, inlineScriptContent); + const flowDir = `${tempDir}/${path}/${name}${getFolderSuffix("flow")}`; + await mkdir(flowDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates an app on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates an app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - App name + * + * @example + * await createLocalApp(tempDir, "f/test", "my_app"); + * + * @keywords create local app, local app, write app, app on disk + */ +export async function createLocalApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createAppFixture(name); + const appDir = `${tempDir}/${path}/${name}${getFolderSuffix("app")}`; + await mkdir(appDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates a raw app on the local filesystem. + * See file header for cross-links to related helpers. + * + * Raw apps are React/frontend apps with separate inline scripts. + * This is a convenience function that creates a raw app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Raw app name + * + * @example + * await createLocalRawApp(tempDir, "f/test", "my_raw_app"); + * + * @keywords create local raw app, local raw app, write raw app, raw app on disk, react app + */ +export async function createLocalRawApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createRawAppFixture(name); + const rawAppSuffix = getFolderSuffix("raw_app"); + const appDir = `${tempDir}/${path}/${name}${rawAppSuffix}`; + await mkdir(`${appDir}/inline_scripts`, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + await mkdir(dir, { recursive: true }); + await writeFile(fullPath, file.content, "utf-8"); + } +} + +// ============================================================================= +// Resource Fixtures (Variables, Resources, Schedules, etc.) +// ============================================================================= + +/** + * Creates a resource fixture. + * + * @keywords resource fixture, create resource + */ +export function createResourceFixture( + name: string, + resourceType: string, + value: Record +): FileFixture { + return { + path: `${name}.resource.yaml`, + content: `resource_type: "${resourceType}" +value: +${Object.entries(value) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n")} +`, + }; +} + +/** + * Creates a variable fixture. + * + * @keywords variable fixture, create variable + */ +export function createVariableFixture( + name: string, + value: string, + isSecret: boolean = false +): FileFixture { + return { + path: `${name}.variable.yaml`, + content: `value: "${value}" +is_secret: ${isSecret} +description: "Variable ${name} for testing" +`, + }; +} + +/** + * Creates a schedule fixture. + * + * @keywords schedule fixture, create schedule + */ +export function createScheduleFixture( + name: string, + scriptPath: string, + schedule: string = "0 * * * *" +): FileFixture { + return { + path: `${name}.schedule.yaml`, + content: `path: "${name}" +schedule: "${schedule}" +script_path: "${scriptPath}" +is_flow: false +args: {} +enabled: true +timezone: "UTC" +`, + }; +} diff --git a/cli/test/unified_generate_metadata.test.ts b/cli/test/unified_generate_metadata.test.ts new file mode 100644 index 0000000000..5cd240dce9 --- /dev/null +++ b/cli/test/unified_generate_metadata.test.ts @@ -0,0 +1,451 @@ +/** + * Unified generate-metadata Command Tests + * + * Tests the new unified `generate-metadata` command that processes + * scripts, flows, and apps together. + */ + +import { expect, test, describe } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { writeFile } from "node:fs/promises"; +import { + createLocalScript, + createLocalFlow, + createLocalApp, + createLocalRawApp, +} from "./test_fixtures.ts"; + +/** + * Helper to set up a workspace with wmill.yaml + */ +async function setupWorkspace(backend: any, tempDir: string, workspaceName: string) { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: []`, "utf-8"); +} + +// ============================================================================= +// Main test: processes scripts, flows, and apps together +// ============================================================================= + +test("generate-metadata: processes scripts, flows, and apps together", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "unified_all_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "unified_all_test" + ); + + expect(result.code).toEqual(0); + // Should find stale items + expect(result.stdout).toContain("Found"); + expect(result.stdout).toContain("stale metadata"); + }); +}); + +// ============================================================================= +// Flag tests +// ============================================================================= + +describe("generate-metadata flags", () => { + test("--includes filters to specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "includes_test"); + + // Create two scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with --includes to only process f/included + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-i", "f/included/**"], + tempDir, + "includes_test" + ); + + expect(result.code).toEqual(0); + // Should only mention the included script + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("--excludes filters out specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "excludes_test"); + + // Create two scripts + await createLocalScript(tempDir, "f/keep", "script_keep"); + await createLocalScript(tempDir, "f/skip", "script_skip"); + + // Run with --excludes to skip f/skip + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-e", "f/skip/**"], + tempDir, + "excludes_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_keep"); + expect(output).not.toContain("script_skip"); + }); + }); + + test("--dry-run shows stale items without updating", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "dry_run_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with --dry-run + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + + expect(result.code).toEqual(0); + // Should show stale items (Scripts section header) + expect(result.stdout).toContain("Scripts"); + expect(result.stdout).toContain("my_script"); + // Should NOT show "Done" (didn't actually update) + expect(result.stdout).not.toContain("Done"); + + // Run again without --dry-run to verify it would still be stale + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + expect(result2.stdout).toContain("Scripts"); + }); + }); + + test("--lock-only only regenerates locks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "lock_only_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--lock-only"], + tempDir, + "lock_only_test" + ); + + expect(result.code).toEqual(0); + }); + }); + + test("--schema-only only processes scripts (skips flows and apps)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "schema_only_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--schema-only"], + tempDir, + "schema_only_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should show "Checking scripts..." only + expect(output).toContain("Checking scripts..."); + // Should find the script (Scripts section header) + expect(output).toContain("Scripts"); + // Should NOT find flows or apps + expect(output).not.toContain("Flows"); + expect(output).not.toContain("Apps"); + }); + }); + + test("--skip-scripts skips scripts", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_scripts_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-scripts"], + tempDir, + "skip_scripts_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should NOT contain script + expect(output).not.toContain("Scripts"); + // Should contain flow + expect(output).toContain("Flows"); + }); + }); + + test("--skip-flows skips flows", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_flows_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-flows"], + tempDir, + "skip_flows_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain flow + expect(output).not.toContain("Flows"); + }); + }); + + test("--skip-apps skips apps", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_apps_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-apps"], + tempDir, + "skip_apps_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain app + expect(output).not.toContain("Apps"); + }); + }); + + test("shows 'All metadata up-to-date' when nothing to update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "uptodate_test"); + + // Create a script and run generate-metadata twice + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run - generates metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + // Second run - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("skipping all types shows warning", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_all_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--skip-scripts", "--skip-flows", "--skip-apps"], + tempDir, + "skip_all_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("Nothing to check"); + }); + }); +}); + +// ============================================================================= +// Folder argument tests +// ============================================================================= + +describe("generate-metadata folder argument", () => { + test("filters to specific script folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_script_test"); + + // Create scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with folder argument + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/script_a.ts"], + tempDir, + "folder_script_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("filters to specific flow folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_flow_test"); + + // Create flows in different folders + await createLocalFlow(tempDir, "f/included", "flow_a"); + await createLocalFlow(tempDir, "f/excluded", "flow_b"); + + // Run with folder argument (flow folder path - uses .flow suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/flow_a.flow"], + tempDir, + "folder_flow_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("flow_a"); + expect(output).not.toContain("flow_b"); + }); + }); + + test("filters to specific app folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_app_test"); + + // Create apps in different folders + await createLocalApp(tempDir, "f/included", "app_a"); + await createLocalApp(tempDir, "f/excluded", "app_b"); + + // Run with folder argument (app folder path - uses .app suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/app_a.app"], + tempDir, + "folder_app_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("app_a"); + expect(output).not.toContain("app_b"); + }); + }); + + test("shows up-to-date when folder has no stale items", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_uptodate_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run to generate metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "folder_uptodate_test" + ); + + // Second run with folder - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts"], + tempDir, + "folder_uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("trailing slash is stripped (matches deprecated behavior)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "trailing_slash_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with trailing slash + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts/"], + tempDir, + "trailing_slash_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("my_script"); + }); + }); + + test("parent folder matches all children", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "parent_folder_test"); + + // Create scripts in nested folders + await createLocalScript(tempDir, "f/parent", "script_a"); + await createLocalScript(tempDir, "f/parent/child", "script_b"); + await createLocalScript(tempDir, "f/other", "script_c"); + + // Run with parent folder - should match both scripts in f/parent tree + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/parent"], + tempDir, + "parent_folder_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).toContain("script_b"); + expect(output).not.toContain("script_c"); + }); + }); + + test("non-existent folder shows up-to-date", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "nonexistent_folder_test"); + + await createLocalScript(tempDir, "f/exists", "my_script"); + + // Run with non-existent folder + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/does_not_exist"], + tempDir, + "nonexistent_folder_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); +});