mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
eebe24d8b0
* feat(cli): add `wmill flow dev` subcommand with per-flow reverse proxy and launch.json Also generates .claude/launch.json for existing flow folders during `wmill init`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: responsive dev layout and hide splitter for single-pane views Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: clamp flow graph height between minHeight and maxHeight Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(cli): enhance app new with Claude Desktop integration and better defaults - Add .claude/launch.json to generated app scaffold for Claude Code preview support - Add "Open in Claude Desktop?" prompt that creates a CLI session and opens it in Claude Desktop Code mode via the claude://resume deep link - Improve default CSS template with body background, system fonts, and padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): handle both .flow and __flow suffixes in wmill dev The flow detection in loadPaths only checked the configured suffix (dotted or non-dotted), so users with nonDottedPaths=true who had .flow folders (or vice versa) would see inline script edits treated as standalone script changes instead of flow changes. Now checks both suffix forms everywhere: type classification, folder path extraction, path stripping, and loadWmPath lookup. Also adds raw_app launch.json generation to init and sync pull. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(cli): update generated skills with dev workflow and preview commands Update cli-commands, write-flow, and raw-app skills to document the new local dev workflow (wmill dev --path, --proxy-port, .claude/launch.json). Add wmill script preview and wmill flow preview to all script/flow skills so agents know how to test without deploying. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): include path in dev URL and use open.default for browser - Append &path= to the printed/opened URL when --path is specified - Use open.default(url) instead of open.openApp for more reliable browser opening Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): add Claude CLI/Desktop detection hints in wmill flow new Show contextual instructions for previewing flows based on available tools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate auto-generated CLI skills for new dev flags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): handle mixed flow suffixes in dev file watcher The ignore() function uses isFlowPath() which only checks the configured suffix (__flow or .flow), causing files in the other variant to be silently ignored. Bypass the ignore check for any file inside a flow folder and force flow type detection regardless of suffix configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): drop default proxy in flow folders, open browser, add --no-browser Manual `wmill dev` in a flow folder should not implicitly enable the reverse proxy. Both proxy and legacy modes now open the browser; the new --no-browser flag opts out. Claude Code launch.json templates pass --no-browser so the IDE preview doesn't fight a system browser window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): gate dev broadcasts by --path and push currentLastEdit on connect When --path (or auto-detected flow path) is set, drop file events for any other path so the dev page stays locked to the requested resource and currentLastEdit can never reflect an unrelated edit. The connection handler proactively pushes currentLastEdit so the page renders without waiting for the first file change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): prefer WebSocket for flow round-trip when wmill dev is connected updateFlow used isInIframe priority, which routed Claude Code's iframe preview through postMessage (no listener) and silently dropped flow edits. Flip the priority: when the wmill dev WebSocket is open, use it (covers standalone tabs and Claude Code's preview); fall back to postMessage only when no WS is connected (the VS Code extension's iframe URL has no `local=true`, so it never opens one). Also stop assigning lastSent before a channel actually accepted the message, so a CONNECTING WS doesn't silently swallow the first change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): url is source of truth for path; add workspace file picker Drops the server-side --path gate added in3c2d5155e1. The dev page now filters by its URL's ?path= and the CLI is a dumb broadcaster, which lets multiple tabs each watch different paths. When the URL has no ?path=, the page asks the CLI for a list of workspace items (flows, scripts, raw_apps) via a new {type:'listPaths'} WS message and renders a picker. Clicking a flow or script soft-updates the URL via history.pushState and loads it; raw_apps surface a hint to use `wmill app dev` since they don't render here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): picker uses homepage tree view with summaries Replace the hand-rolled Button-list picker with a TreeView-style layout that mirrors the Windmill homepage: folder/user tree grouping via `groupItems`, item rows rendered through the shared `Row.svelte` (no actions, no favourites, no link — just the visual), a `SearchItems` fuzzy filter with the same search input styling and placeholder as the homepage, and `group-open:` chevron toggling on native <details>. The CLI's listWorkspacePaths now also reads summaries from each item's metadata (flow.yaml for flows, <script>.script.yaml for scripts) in parallel so the picker shows summaries as the primary row label, same as the homepage. Raw apps have no standard manifest so they show the path only. Additional polish: title shows "<workspace> (local)" instead of generic text, subtitle trimmed, item-wrapper owns the border-b so Row's internal last:border-b-0 doesn't zero it out, summary border gated on group-open: to avoid doubled lines when a folder is collapsed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill dev --no-browser was a no-op Cliffy's `.option("--no-browser", ...)` creates an option named `browser` (boolean, default undefined) that becomes `false` when the flag is passed. The previous code checked `opts.noBrowser`, which Cliffy never populates, so the guard silently no-op'd and the browser always opened. Rename to `browser` and check `=== false` explicitly, matching the `wmill app dev --no-open` convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): picker warns when wmill dev server is unreachable Track WS state in Dev.svelte (connecting/open/closed) — 'closed' is set on either the WS error or close event. When closed, the picker replaces the toggle + search + tree with a warning Alert telling the user to run `wmill dev` from the workspace root. Toggle and search are hidden rather than rendered disabled because there's nothing to filter anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): rename wmill dev --no-browser to --no-open Match the pre-existing `wmill app dev --no-open` flag. Having `--no-browser` on one dev command and `--no-open` on the other was just an oversight from my earlier change. All three launch.json templates (init, flow new, sync pull) switch to `--no-open`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill init creates root .claude/launch.json for the picker Adds a workspace-root .claude/launch.json so Claude Code can launch `wmill dev` from the project root and land on the file picker (no --path → picker mode). Per-flow and per-raw_app launch.json files are already generated by the existing scans. Skipped (with a gray log) if the file already exists, so the user's customizations are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): add skipClaudeAssets wmill.yaml flag When `skipClaudeAssets: true` is set in wmill.yaml, all generators that previously wrote Claude-specific assets become no-ops: - writeAiGuidanceFiles skips CLAUDE.md and .claude/skills/ (AGENTS.md is still written — vendor-neutral) - wmill init skips the root .claude/launch.json + per-flow + per-raw_app launch.json scans - wmill sync pull skips the per-flow + per-raw_app launch.json scans - wmill flow new skips the new flow's .claude/launch.json - wmill app new skips the new raw_app's .claude/ folder + launch.json The flag is added to SyncOptions, DEFAULT_SYNC_OPTIONS, and the generated wmill.yaml template (commented out — opt-in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill init removes existing Claude assets when skipClaudeAssets is set Re-running `wmill init` with `skipClaudeAssets: true` now removes previously-generated Claude assets so the workspace state matches the config. Narrow scope, no confirmation: - per-flow / per-raw_app .claude/launch.json (each parent .claude/ collapsed if empty) - root .claude/launch.json - .claude/skills/ (wholly ours; safe to remove the subtree) - root .claude/ collapsed if empty - CLAUDE.md only if its content matches the default ("Instructions are in @AGENTS.md\n"); otherwise left in place with a note Each removal is logged in yellow under a single gray intro line that prints lazily on the first removal — a clean tree stays silent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill workspace add browser open silently no-ops `open.openApp(open.apps.browser, { arguments: [url] })` resolves its Promise even when the OS-level launch does nothing, so the CLI prints "Opened browser for you" but no tab appears. Same pattern was already fixed in `dev.ts` by commit3272c29c2e— use `open.default(url)`, which delegates to the native URL opener (`open` on macOS, `xdg-open` on Linux, `start` on Windows) and actually rejects on failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill init workspace prompt no longer duplicates active profile name Cliffy's Select.prompt renders `default: X` as `(X)` next to the question header, which duplicates whichever workspace name the default points to. Drop `default` and instead reorder the list so the active profile is first (cursor-preselected by virtue of position) and append "— active" to its label so the indicator lives where it's contextually relevant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): expand preview-vs-run guidance for write-flow + all write-script-* skills Both `wmill flow preview/run` and `wmill script preview/run` have the same intent split — preview hits the local file, run hits the deployed version, sync push deploys. The skills' "after writing" sections used to terse-list the commands and just say "do not run them yourself", which encouraged the wrong reflex of `sync push` + `run` to "test". Rewrite the section in both `system_prompts/base/flow-base.md` (drives write-flow) and the `script_cli_intro` block in `system_prompts/generate.py` (drives all write-script-<lang>) to: - explicitly list `preview` as the default for local iteration, - spell out the few cases when `run` or `sync push` are appropriate, - offer to test as a one-sentence next step (no multi-option menus), - mark `preview` as safe to run autonomously. Regenerate auto-generated/ + cli/src/guidance/skills.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): wmill dev — clearer mode names and accurate startup messaging - Rename `startLegacyServer` to `startDirectServer`. "Legacy" implied it was on the way out; the two modes (proxy vs direct WS) actually serve different topologies and both stay. Add comments above each section spelling out who they're for: proxy mode for embedders that require a localhost origin (Claude Code preview), direct mode for standalone browser tabs and the VS Code extension iframe. - Replace the stale "Dev server will automatically point to the last script edited locally" log line. Now print path-aware text: - with --path (or auto-detected): "Watching <path> — edits will live -reload in the dev page" - without: "Open the dev page and pick a flow or script to preview — edits will live-reload" plus a hint about --path Mirror the same in proxy mode after the listen callback. - Drop the redundant "Go to <url>" line when --no-open isn't passed (maybeOpenBrowser already prints "Opened browser at <url>"). - Rename "Server listening on port 3001" to "Dev WebSocket listening on ws://localhost:<port>/ws" so the line's purpose is obvious. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): drop per-folder .claude/launch.json generation Stop creating `.claude/launch.json` inside every flow folder, raw_app folder, and at `wmill flow new`/`wmill app new` time. The workspace- root `.claude/launch.json` from `wmill init` stays — it's the picker entry point and the one place where the deterministic "click → preview" UX is high-value. Removed from: - `wmill init` — per-flow + per-raw_app scans - `wmill sync pull` — per-flow + per-raw_app scans (also drops the now-unused `node:fs` mkdirSync/writeFileSync import) - `wmill flow new` — bootstrap no longer scaffolds `.claude/` - `wmill app new` — same; also drops the `.claude/launch.json` lines from the post-create directory listing Skills already give the agent the right CLI commands, so per-folder launch.json was redundant context. Existing files in user projects keep working but won't be regenerated; `wmill init` with `skipClaudeAssets: true` cleans them up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill app new flags + tighten raw-app skill for AI agents `wmill app new` is interactive by default, which hangs forever when an AI agent tries to use it. Add flags so the wizard can be bypassed end-to-end: - `--summary <text>`, `--path <path>`, `--framework <react19|react18| svelte5|vue>` (required for non-interactive) - `--datatable <name>` (opt into the datatable wizard) - `--schema <name>` (creates schema with CREATE SCHEMA IF NOT EXISTS if it doesn't already exist; only valid with --datatable) - `--overwrite` (replace existing directory without prompting) - `--no-open-in-desktop` (suppress the Claude Desktop offer) Mode is auto-detected: providing any of --summary/--path/--framework puts the run into non-interactive mode where the datatable wizard, overwrite prompt, and Claude Desktop prompt all skip silently (or fail fast on conflict instead of waiting for stdin). Each provided flag is validated upfront with a clear error message. Skill side: rewrite `system_prompts/base/raw-app.md`'s "Creating a Raw App" section so the AI agent knows it should run the command itself with flags (not tell the user to run it interactively). Direct the agent to use `AskUserQuestion` with one bundled call to gather any missing summary/path/framework — refuse to invent values, refuse to default. Anti-patterns spelled out explicitly. AGENTS.md template (`cli/src/guidance/core.ts`) had a contradicting line ("MUST ask the user to run wmill app new in its terminal first") that was loaded eagerly into agent context and overrode the skill — replaced with the same agent-driven guidance, pointing to the raw-app skill for the full procedure. Regenerate auto-generated/ + cli/src/guidance/skills.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): per-target preview launch.json + agent uses wmill flow new Refactor the agent's dev/preview workflow: - Drop root .claude/launch.json generation from `wmill init`. Sharing one generic entry across sessions caused preview collisions; agents now add per-target named entries (windmill: <wmill_path>) on demand. - New `preview` skill in system_prompts/base/preview.md. Branches on whether `mcp__Claude_Preview__*` MCP tools are available: with them, add a per-target launch.json entry pinning its own port + --proxy-port + --path + --no-open and invoke the MCP preview tool; without them, start `wmill dev --path <X> --no-open` directly and hand the URL the CLI prints to the user. Never touch launch.json in the direct case. - Agents must run `wmill flow new <path>` themselves to scaffold flows (folder + flow.yaml with the right suffix), parallel to the existing `wmill app new` rule. Missing path/summary trigger AskUserQuestion; no inventing values. - write-flow skill: 4-step Creating a Flow procedure that opens the visual preview *before* editing flow.yaml so the user watches the flow take shape via live reload. - `wmill flow new` always prints the `wmill dev --path <X>` preview hint; drop the Claude CLI/Desktop detection branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): open app preview before editing in raw-app skill Mirrors the flow skill's Step 3 — opening `wmill app dev` via the preview skill before touching App.tsx so the user watches the app take shape via live reload, instead of seeing the finished result at the end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): guard WS replaceFlow with lockChanges to prevent echo The postMessage handler at Dev.svelte:306-312 wraps replaceFlow with lockChanges = true (cleared 500 ms later) so the $effect on flowStore.val doesn't immediately re-serialize and re-send the freshly received payload. The WebSocket handler did not, so on the initial flow push (dev.ts:568-574 sends currentLastEdit on connect), the client would echo back to handleFlowRoundTrip, which runs the orphan-file scan. On content equality the write was a no-op, but the scan could still delete files the server did not list. Mirror the same lockChanges/timeout pattern in the WS replaceData handler. Apply to both flow and script paths for symmetry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): correct wmill dev description + gate broadcasts server-side Two related fixes: 1. The 'auto-pushes them to the remote workspace' wording in the wmill dev description was wrong — the command never deploys, it only broadcasts file changes over WS for live preview. Reworded to call this out explicitly and point at 'wmill sync push' for the deploy case. 2. Move the path filter out of the client (Dev.svelte:491-495) and into broadcastChanges. Earlier the filter was client-side with the comment 'server stays a dumb broadcaster' even though commit3c2d5155was titled 'gate dev broadcasts by --path'. Doing the compare server-side aligns the implementation with the commit narrative, cuts WS traffic when --path is set, and keeps the per-tab semantics for the picker (each picker tab still gets the full 'paths' listing on first connect). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): drop dead launch.json cleanup, fix description, narrow orphan scope Three review fixes: 1. cleanupClaudeAssets removed both root and per-folder .claude/launch.json files that this CLI never generates anymore. Per the user's "feature hasn't been released yet" guidance, no migration is needed — drop the dead scan and the root rm. Also drop the now-unused nonDottedPaths argument (and its flowSuffix / rawAppSuffix locals). 2. The skipClaudeAssets description in template.ts listed .claude/launch.json among the assets it skips, but launch.json is no longer generated. Drop it from the description string. 3. The dev round-trip's orphan cleanup deleted any non-dot file in a flow folder that wasn't in extractedPaths — including README.md, fixtures, TODO.md, etc. Restrict the deletion to files whose extension is in a known inline-script set (.ts/.js/.py/.go/.sh/.sql/.ps1/.php/.rs/.java/.cs/.r/.graphql). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli, frontend): dedupe flow suffix helpers, use UI components Five small follow-ups from the PR review: 1. dev.ts already had stripFolderSuffix() but three callsites were reimplementing the same .flow/__flow if-else inline. Add an isFlowFolderName(name) helper next to it and replace the duplicates in startProxyServer's cwd check, the file-watcher localPath strip, and normalizeWmPath. 2. Dev.svelte:866 was a <div onclick> with two svelte-ignore comments for the missing a11y handlers. Replace with a real <button type="button"> — kills the warnings, no visual change. 3. Dev.svelte:1283 was a raw <input type="text"> for the module summary. Replace with the existing <TextInput> component (same one the picker search at :1010 uses), per frontend/CLAUDE.md. 4. Dev.svelte:197 typed relativePaths as any[]; tighten to the actual union (string | [number, string])[] — the python helper returns tuples, the typescript one returns strings. 5. app/new.ts:822 fired exec("open <deeplink>") with no callback, so an OS that refused the URL scheme silently failed and we still logged "Opened in Claude Desktop!". Move the success log inside an exec callback that surfaces the error and prints the deep link for manual opening. Plus a brief comment above parseWatchPath explaining its resync contract (initial load + popstate + explicit pickPath, no generic pushState listener). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): regenerate auto-gen for dev description; drop apostrophe to satisfy parser generate.py:326 extracts .description() with the regex [^"\']+ which bails on either quote type. Commit ff3a8e4ebd's new description had an apostrophe inside double quotes ('wmill sync push'), so the parser saw no description at all and the auto-generated files dropped the line entirely — which is what check-freshness caught on origin/main. Quickest path to green CI: rephrase the description without the inner apostrophe, then regenerate. The generator's regex is the real bug but fixing it is out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): seed app .claude/launch.json before opening Claude Desktop When the user accepts "Open in Claude Desktop?" in wmill app new, write a per-app .claude/launch.json (named "windmill: <appPath>") into the freshly-created app folder before the deep link fires. Entry runs 'wmill app dev --no-open --port ${PORT:-4001}' from the app folder (which is the cwd Claude Desktop opens with), so the user can hit play right away to launch the preview. Skip if .claude/launch.json already exists — never clobber user edits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix toggles positions * fix(dev): gate picker mode on ?local= so VS Code iframe still renders content The VS Code extension iframe loads the dev page without ?path= and without ?local=true. After the picker rework, an empty watchPath flipped pickerMode on, so the page rendered the picker UI even though the extension was sending replaceScript / replaceFlow postMessages — leaving the user stuck on the picker forever. Picker mode only makes sense on the local dev page, where the wmill dev WebSocket can supply the workspace listing. Anywhere else (VS Code iframe, plain remote tab) the picker has no data source and no purpose. Add an isLocalDevPage check so the picker only shows when ?local=true is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dev): mirror vscode extension's processFlowMessage round-trip Three changes that bring our wmill dev round-trip into lockstep with the windmill-vscode extension's processFlowMessage in src/extension.ts: 1. New cli/src/commands/dev/pathscript-restore.ts — verbatim port of the extension's src/utils/pathscript-restore.ts. Adds AI-agent tool walking that the previous local copy was missing (flows with PathScript-shaped tools weren't being preserved across round-trip). Header comment makes the cross-repo link explicit. 2. handleFlowRoundTrip rewritten to mirror processFlowMessage step- for-step: reads failure_module + preprocessor_module from the current flow.yaml, passes them to extractCurrentMapping, shares one pathAssigner across all extraction calls, extracts inline scripts from those special modules too, skips writing files whose content starts with !inline (treats as pointer directives), and only rewrites flow.yaml when the serialized YAML actually differs. 3. snapshotPathScripts / tagReplacedPathScripts callsites in loadPaths were passing the FlowFile wrapper instead of FlowFile.value — the helpers walk .modules / .failure_module / .preprocessor_module, which only exist on .value, so PathScript snapshots silently no-op'd on the file-watcher path. Pass .value at all four sites. Deliberate divergence from the extension: orphan-cleanup keeps the INLINE_SCRIPT_EXTS allow-list so README.md / fixtures aren't deleted. The extension's version still over-deletes; that's tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): offer visual preview after create instead of auto-opening Both write-flow and raw-app skills used to instruct the agent to open the visual preview without asking right after wmill flow new / wmill app new, on the rationale that live reload is most useful when the page is already up. In practice this surprised users — opening the dev page has side effects (browser window pop, possibly a launch.json entry under MCP-preview Branch A) that warrant consent. Change Step 3 in both skills from "open it without asking" to "offer it as a one-sentence next step" — same pattern the same skills already use for programmatic wmill flow preview offers. Two then- necessary anti-patterns ("just open it", "open it before editing") are dropped along with the auto-open instruction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): probe both ip stacks before binding wmill dev proxy / app dev port Node's default listen() has platform-dependent dual-stack behaviour. If the requested port is already held on the IPv4 stack, listen() can silently fall back to binding IPv6-only ([::1]:N). The OS then routes new localhost connections to the older IPv4 listener, so the user opens http://localhost:N and sees a stale prior server with no signal that anything is wrong. Bit us in practice: a leftover wmill dev --proxy-port 4000 served traffic for a freshly-started wmill app dev --port 4000. New helper at cli/src/utils/port-probe.ts probes both 0.0.0.0 and :: before binding. On collision it walks upward to the next free port (up to +20) and logs a prominent warning naming the holder when lsof / ss can find it: Port 4000 is already in use (held by PID 91418 `bun`). Using port 4001 instead. Wired into: - wmill dev --proxy-port: the resolved port flows into both proxyServer.listen() and the &port=N parameter in the redirect URL, so they always match. Bind explicitly to 0.0.0.0. - wmill app dev --port: only when the user passed --port explicitly (the default getPort.default(...) path already handles fallback). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): pass placeholder via TextInput inputProps not as top-level prop `<TextInput>`'s top-level Props don't include `placeholder` — native input attributes go through the `inputProps` field. The previous `<TextInput placeholder="Summary" .../>` failed `npm run check` with "Object literal may only specify known properties, and '\"placeholder\"' does not exist in type 'Props<\"input\">'.". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nit * fix(cli): sequential port probe + sync dev test regex with renamed log Two CI regressions on test-linux: 1. port-probe parallel race on Linux. isPortFreeOnBothStacks ran the IPv4 and IPv6 binds via Promise.all. On Linux the default is net.ipv6.bindv6only=0, so a bind(::, port) socket also takes the IPv4 stack on the same port. Concurrent v4 + v6 binds then race for v4 — one wins, the other gets EADDRINUSE on a port that is actually free. Walks 20 ports up, all fail the same way, throws, child exits. Tests that fetch http://localhost:port time out at 60s. Doesn't repro on macOS (bindv6only=1 by default — what I tested against). Probe sequentially so each bind fully releases before the next starts. 2. dev_server.test 1 regex out of sync. Commit018dc3861arenamed the startup log from "Server listening on port N" to "Dev WebSocket listening on ws://localhost:N/ws" but didn't update the test, which times out at 30s waiting for the old string. Update the regex to match the current log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update system_prompts/auto-generated/skills/write-script-graphql/SKILL.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(cli): address dev/app PR review — bugs 1-7 Per code review: 1. app new.ts — wrap claude --session-id exec in try/finally so the spinner setInterval is always cleared. On rejection control jumped to the outer catch and the spinner kept writing \r forever, garbling subsequent output. 2. app new.ts — make --overwrite actually wipe the dir before re-creating. Previously logged "Overwriting" but only skipped the prompt; leftover files from a different framework (e.g. App.tsx from a prior react18 install when re-scaffolding as svelte5) survived and produced a hybrid scaffold. 3. dev/dev.ts — anchor the flow-folder match on path segments. The substring checks (cpath.includes(".flow/") / "__flow/") also fired on names like notes_about__flow_design/readme.md. New isInsideFlowFolder + findFlowFolderPrefix split on "/" and check segment suffixes. Drops the now-unreachable script→flow fallback inside the else branch. 4. dev/dev.ts — direct mode also routes through resolveBindPort so it detects dual-stack collisions like the proxy mode does. Bare getPort only probes one stack, defeating the whole point of port-probe.ts. Also bind to BIND_HOST explicitly. Drops the unused getPort import. 5. dev/dev.ts — normalize opts.path once after mergeConfigWithConfigFile. broadcastChanges compared against a non-normalized opts.path, so --path f/foo/ or --path f/foo.flow silently dropped every broadcast. Also pulls normalizeWmPath to module scope (was a closure inside dev()). 6. dev/dev.ts — guard the initial-state ws.send with readyState === OPEN, matching the other branches' pattern. 7. dev/dev.ts — typo: "givena" → "given a". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): address dev/app PR review — items 8-10 8. dev/dev.ts — derive INLINE_SCRIPT_EXTS from exts so adding a new script language to script.ts auto-extends orphan cleanup. Previously .gql, .nu, .rb were missing — flows using those languages would leave orphaned inline files behind. Excludes .yml because user fixtures commonly use it in flow folders, and leaving a stale .playbook.yml inline script is preferable to deleting a fixture. Keeps .js for hand-written flows that aren't in the exts list. 9. app/new.ts — wrap Claude Desktop install probe + prompt in process.platform === "darwin". The probe (ls /Applications/Claude.app) and the open command both only work on macOS — the explicit guard makes the platform scope grep-able. 10. app/new.ts — switch the deep-link spawn from exec(`open ${shell- escaped url}`) to execFile("open", [deepLink]). sessionId is a UUID and absAppDir is URI-encoded today so the old form was safe, but execFile removes the shell entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,dev): cubic review — port-probe error semantics, pluralize spacing [2] cli/src/utils/port-probe.ts — distinguish IPv6-unsupported from port collision in isPortFree. Previously every error code returned false, including EAFNOSUPPORT / EADDRNOTAVAIL on the IPv6 probe when the host has no v6 stack at all (IPv4-only containers). resolveBindPort would then walk all 20 ports getting the same error and throw. Treat only EADDRINUSE / EACCES as "not free"; everything else as free. [13] cli/src/commands/app/dev.ts — only probe both stacks when binding to localhost. The dual-stack collision risk is specific to localhost (which resolves to 127.0.0.1 + ::1); for an explicit IPv4 host there's only one stack to worry about, so don't move the user's requested port over a phantom v6 collision. [14] frontend/src/lib/components/Dev.svelte — pluralize already inserts a space between quantity and word, so " item" produced "3 items". Drop the leading space in both call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): cubic review — preview args, skill scopes Source changes in base/ + generate.py, then regenerated auto-generated/ via python system_prompts/generate.py. Per cubic review: [4]+[7] generate.py — "pick plausible args from the `main` signature" was language-blind. SQL queries and Bash scripts use $1/$2 positional parameters, not a main(...) signature. Reword to call out both shapes explicitly so the wording survives across all 19 generated language skills (postgresql, bash, mysql, …) instead of just the ones that happen to have main(). [5] base/raw-app.md — the "CLI Commands" table said "Tell the user they can run these commands (do NOT run them yourself)" while the "Creating a Raw App" section above (added in this PR) tells the agent to run `wmill app new` itself. Carve `wmill app new` out of the table and add a one-line note pointing back to the create flow, so the guidance no longer self-contradicts. [10] base/preview.md — "These print a `Go to <url>` line on stdout" was wrong for `wmill app dev`, which prints "🚀 Dev server running at <url>". List both line shapes explicitly and suggest a loose http:// match for URL capture. [12] base/flow-base.md — "regenerate lock files for the flow you modified" misstated the default scope. `wmill generate-metadata` scans scripts, flows, and apps by default (see cli/src/commands/generate-metadata/generate-metadata.ts:71-73). Update wording to call out the default scope and how to narrow it. Also folds the cubic [1] graphql safety wording (originally a one-off edit on the auto-generated file ina895db7) back into generate.py itself, so it survives regeneration and applies to all language skills. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): cubic round 2 — language-specific placeholder syntax Round 1 wording was too narrow: - "$1, $2 placeholders for SQL queries and Bash" was wrong for MySQL (`?`), Snowflake (`?`), MSSQL (`@P1`), BigQuery (`@name`), and PowerShell (which uses `param(...)`, not main()). - The preview-skill URL match said "first `http://...` token" — remote workspaces serve HTTPS, so the regex would miss them. Source-only fixes in generate.py and base/preview.md, then regenerated auto-generated/ via python system_prompts/generate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(flow): track maxHeight in FlowGraphV2 height effect cubic [3]: updateHeight() reads both minHeight and maxHeight, but the $effect only tracked minHeight. Changing maxHeight alone (e.g. when a parent shrinks the cap during a layout transition) left height frozen at the previously clamped value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): tool-agnostic wording in base/ cubic [11]: system_prompts/README.md says these prompts must NOT contain tool usage instructions. Three base files violated this: - base/flow-base.md (4× AskUserQuestion). Worst offender — leaks into the frontend copilot via FLOW_BASE in prompts.ts (consumed by getFlowPrompt in frontend/src/lib/components/copilot/chat/flow/ core.ts:1287). Frontend has no AskUserQuestion tool, so the wording was both irrelevant and confusing there. - base/raw-app.md (5× AskUserQuestion + 1× mcp__Claude_Preview__). CLI-skill-only but covered by the same scope rule. - base/preview.md (5× mcp__Claude_Preview__). CLI-skill-only, same. Replaced with role descriptions: "ask the user (use a structured- question tool if your runtime has one)" and "a tool that can embed a localhost URL inside the IDE / chat surface". Kept one mention of mcp__Claude_Preview__ in preview.md as an illustrative example, since documentation of one runtime is fine — what's not fine is gating behaviour on a specific tool name. Source-only edits, then regenerated auto-generated/ via python system_prompts/generate.py. Verification: grep -r AskUserQuestion system_prompts/auto-generated/ now returns nothing. The remaining AskUserQuestion refs in cli/src/guidance/core.ts are hand-written CLI-only AGENTS.md content (not part of system_prompts), and Claude Code does have that tool, so those are correctly scoped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(flow): drop .no-splitter CSS hack, use snippets to skip Splitpanes cubic [8]: the previous fix for "top pane is empty in aiagent / noEditor mode" was a CSS rule that hid `:global(.splitpanes__splitter)` inside .no-splitter. That cascaded into nested splitpanes too — the aiagent left/right tabs panel (line 1043), the debug-console editor split (line 877), and the doubly-nested debug panel (line 1472) all lost their resize handles. Refactor the layout instead. Extract top-pane and bottom-pane content as snippets, then conditionally render either: - just the bottom snippet (no Splitpanes wrapper) when the top pane would be empty (aiagent or noEditor), or - the original two-Pane Splitpanes layout otherwise. This removes the splitter at its root rather than hiding it, so nested splitters are unaffected. The bottom Pane's complex bind:size getter/setter (which returned 100 when aiagent) collapses to a simple binding now that the aiagent path no longer goes through the wrapping Pane at all. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nit * fix(flow,preview): cubic round 3 — FlowPathViewer regression + preview skill rewrite [3149192182] FlowModuleComponent.svelte: my last refactor's "aiagent || noEditor" condition stripped the FlowPathViewer for noEditor + type === 'flow', because the top-pane snippet was no longer rendered. The flow-viewer pane is the only thing that *does* show in that mode, so it shouldn't have been collapsed. Tighten the condition to "aiagent || (noEditor && type !== 'flow')". [3149060930] system_prompts/base/preview.md: Branch A detection was too broad — "can embed or open a localhost URL" is strictly weaker than "can read .claude/launch.json and launch a configuration". Only the Claude Desktop / Code MCP integration does the latter; most embedders only do the former. Restructure preview.md around two orthogonal axes: 1. Mode (proxy vs direct) — driven by "does the embedder need a localhost URL?". Direct is the default; proxy is for embedders that sandbox cross-origin loads. 2. Who starts the server — you spawn `wmill dev` yourself, OR a launch.json-aware runtime (currently only the `mcp__Claude_Preview__*` MCP family) launches it on demand. The two compose into four common cases (regular browser tab, generic preview pane, localhost-only preview pane, Claude MCP), each with a clear instruction. The launch.json/MCP machinery is now scoped to a single section gated on actually having that tool in your tool list. Source-only edit in base/preview.md, then regenerated auto-generated/ via python system_prompts/generate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix dev step display * nit --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
/**
|
|
* Dev Server Smoke Tests
|
|
*
|
|
* Tests for `wmill dev` and `wmill app dev` commands.
|
|
* Verifies server startup, WebSocket connectivity, and file-change broadcasting.
|
|
*
|
|
* Run with:
|
|
* bun test test/dev_server.test.ts
|
|
*/
|
|
|
|
import { expect, test } from "bun:test";
|
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createServer } from "node:net";
|
|
import { Subprocess } from "bun";
|
|
import WebSocket from "ws";
|
|
import { withTestBackend } from "./test_backend.ts";
|
|
|
|
/** Find a free port by binding to port 0 */
|
|
async function findFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.listen(0, () => {
|
|
const port = (server.address() as any).port;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/** Wait for a condition with timeout */
|
|
async function waitFor<T>(
|
|
fn: () => T | Promise<T>,
|
|
timeoutMs: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const result = await fn();
|
|
if (result) return result;
|
|
} catch (e) {
|
|
lastError = e;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
throw new Error(`Timed out waiting for: ${label} (after ${timeoutMs}ms). Last error: ${lastError}`);
|
|
}
|
|
|
|
/** Get CLI main.ts path */
|
|
function getCLIMainPath(): string {
|
|
return join(dirname(fileURLToPath(import.meta.url)), "..", "src", "main.ts");
|
|
}
|
|
|
|
// =============================================================================
|
|
// TEST 1: `wmill dev` smoke test
|
|
// =============================================================================
|
|
|
|
test(
|
|
"wmill dev: starts server, broadcasts file changes over WebSocket",
|
|
async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
// Create wmill.yaml config
|
|
await writeFile(
|
|
join(tempDir, "wmill.yaml"),
|
|
"defaultTs: bun\n",
|
|
"utf-8",
|
|
);
|
|
|
|
// Create a script file
|
|
const scriptDir = join(tempDir, "f", "test");
|
|
await mkdir(scriptDir, { recursive: true });
|
|
await writeFile(
|
|
join(scriptDir, "hello.ts"),
|
|
'export function main() { return "hello"; }\n',
|
|
"utf-8",
|
|
);
|
|
await writeFile(
|
|
join(scriptDir, "hello.script.yaml"),
|
|
`summary: "test"\ndescription: ""\nlock: ""\nschema:\n $schema: "https://json-schema.org/draft/2020-12/schema"\n type: object\n properties: {}\n required: []\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
// Push the script so the workspace has content
|
|
const pushResult = await backend.runCLICommand(
|
|
["sync", "push", "--yes"],
|
|
tempDir,
|
|
);
|
|
if (pushResult.code !== 0) {
|
|
console.error("Push stderr:", pushResult.stderr);
|
|
console.error("Push stdout:", pushResult.stdout);
|
|
}
|
|
expect(pushResult.code).toEqual(0);
|
|
|
|
// Build the CLI command for `wmill dev`
|
|
const cliMainPath = getCLIMainPath();
|
|
const args = [
|
|
"run",
|
|
cliMainPath,
|
|
"--base-url",
|
|
backend.baseUrl,
|
|
"--workspace",
|
|
backend.workspace,
|
|
"--token",
|
|
backend.token!,
|
|
"--config-dir",
|
|
backend.testConfigDir,
|
|
"dev",
|
|
];
|
|
|
|
let proc: Subprocess | null = null;
|
|
let ws: WebSocket | null = null;
|
|
|
|
try {
|
|
// Spawn wmill dev as background process
|
|
proc = Bun.spawn(["bun", ...args], {
|
|
cwd: tempDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env },
|
|
});
|
|
|
|
// Read stdout to find the port
|
|
const stdoutReader = proc.stdout.getReader();
|
|
let stdoutBuffer = "";
|
|
let port: number | null = null;
|
|
|
|
// Wait for the dev WebSocket startup line — see startDirectServer
|
|
// in cli/src/commands/dev/dev.ts.
|
|
const portMatch = await waitFor(
|
|
async () => {
|
|
try {
|
|
const { done, value } = await Promise.race([
|
|
stdoutReader.read(),
|
|
new Promise<{ done: true; value: undefined }>((r) =>
|
|
setTimeout(() => r({ done: true, value: undefined }), 500),
|
|
),
|
|
]);
|
|
if (!done && value) {
|
|
stdoutBuffer += new TextDecoder().decode(value);
|
|
}
|
|
} catch {
|
|
// Reader may be exhausted
|
|
}
|
|
const match = stdoutBuffer.match(
|
|
/Dev WebSocket listening on ws:\/\/localhost:(\d+)/,
|
|
);
|
|
return match;
|
|
},
|
|
30000,
|
|
"dev server to start",
|
|
);
|
|
|
|
port = parseInt(portMatch[1], 10);
|
|
expect(port).toBeGreaterThan(0);
|
|
stdoutReader.releaseLock();
|
|
|
|
// Connect WebSocket
|
|
ws = new WebSocket(`ws://localhost:${port}`);
|
|
|
|
// Wait for connection to open
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(
|
|
() => reject(new Error("WebSocket connection timeout")),
|
|
5000,
|
|
);
|
|
ws!.on("open", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
ws!.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
expect(ws.readyState).toEqual(WebSocket.OPEN);
|
|
|
|
// Set up a promise to receive the next WebSocket message
|
|
const isWindows = process.platform === "win32";
|
|
const messagePromise = new Promise<any>((resolve, reject) => {
|
|
const timeout = setTimeout(
|
|
() => reject(new Error("WebSocket message timeout")),
|
|
isWindows ? 30000 : 10000,
|
|
);
|
|
ws!.on("message", (data) => {
|
|
clearTimeout(timeout);
|
|
try {
|
|
resolve(JSON.parse(data.toString()));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Modify the script file on disk
|
|
// Windows fs.watch() needs more time to initialize with recursive: true
|
|
await new Promise((r) => setTimeout(r, isWindows ? 2000 : 300));
|
|
await writeFile(
|
|
join(scriptDir, "hello.ts"),
|
|
'export function main() { return "modified"; }\n',
|
|
"utf-8",
|
|
);
|
|
|
|
// Wait for WebSocket message
|
|
const message = await messagePromise;
|
|
|
|
// Verify the message
|
|
expect(message.type).toEqual("script");
|
|
expect(message.content).toContain("modified");
|
|
expect(message.path).toContain("f/test/hello");
|
|
expect(message.language).toBeTruthy();
|
|
} finally {
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
if (proc) {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ timeout: 60000 },
|
|
);
|
|
|
|
// =============================================================================
|
|
// TEST 2: `wmill app dev` smoke test
|
|
// =============================================================================
|
|
|
|
test(
|
|
"wmill app dev: starts HTTP server, serves HTML, provides SSE endpoint",
|
|
async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
// Create wmill.yaml config
|
|
await writeFile(
|
|
join(tempDir, "wmill.yaml"),
|
|
"defaultTs: bun\n",
|
|
"utf-8",
|
|
);
|
|
|
|
// Create a raw app directory with the right suffix
|
|
const appDir = join(tempDir, "f", "test", "myapp.raw_app");
|
|
await mkdir(appDir, { recursive: true });
|
|
|
|
// Create raw_app.yaml
|
|
await writeFile(
|
|
join(appDir, "raw_app.yaml"),
|
|
`custom_path: f/test/myapp\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
// Create package.json (minimal, with react dependency)
|
|
await writeFile(
|
|
join(appDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "test-app",
|
|
private: true,
|
|
dependencies: {
|
|
react: "^18.0.0",
|
|
"react-dom": "^18.0.0",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
// Create index.tsx entry point
|
|
await writeFile(
|
|
join(appDir, "index.tsx"),
|
|
`import React from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import App from "./App";
|
|
|
|
const root = createRoot(document.getElementById("root")!);
|
|
root.render(<App />);
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
// Create App.tsx
|
|
await writeFile(
|
|
join(appDir, "App.tsx"),
|
|
`import React from "react";
|
|
|
|
export default function App() {
|
|
return <div>Hello from test app</div>;
|
|
}
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
// Run npm install in the app directory
|
|
const npmInstall = Bun.spawn(["npm", "install"], {
|
|
cwd: appDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
await Promise.all([
|
|
new Response(npmInstall.stdout).text(),
|
|
new Response(npmInstall.stderr).text(),
|
|
]);
|
|
const npmExitCode = await npmInstall.exited;
|
|
expect(npmExitCode).toEqual(0);
|
|
|
|
// Find a free port
|
|
const port = await findFreePort();
|
|
|
|
// Build the CLI command for `wmill app dev`
|
|
const cliMainPath = getCLIMainPath();
|
|
const args = [
|
|
"run",
|
|
cliMainPath,
|
|
"--base-url",
|
|
backend.baseUrl,
|
|
"--workspace",
|
|
backend.workspace,
|
|
"--token",
|
|
backend.token!,
|
|
"--config-dir",
|
|
backend.testConfigDir,
|
|
"app",
|
|
"dev",
|
|
appDir,
|
|
"--no-open",
|
|
"--port",
|
|
String(port),
|
|
];
|
|
|
|
let proc: Subprocess | null = null;
|
|
|
|
try {
|
|
// Spawn wmill app dev as background process
|
|
proc = Bun.spawn(["bun", ...args], {
|
|
cwd: tempDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env },
|
|
});
|
|
|
|
// Collect stderr in background for debugging
|
|
const stderrReader = proc.stderr.getReader();
|
|
let stderrBuffer = "";
|
|
(async () => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await stderrReader.read();
|
|
if (done) break;
|
|
stderrBuffer += new TextDecoder().decode(value);
|
|
}
|
|
} catch {
|
|
// Process may have exited
|
|
}
|
|
})();
|
|
|
|
// Wait for server to be ready by polling the HTTP endpoint
|
|
await waitFor(
|
|
async () => {
|
|
try {
|
|
const res = await fetch(`http://localhost:${port}/`, {
|
|
signal: AbortSignal.timeout(1000),
|
|
});
|
|
if (res.ok) {
|
|
await res.text();
|
|
return true;
|
|
}
|
|
await res.text();
|
|
} catch {
|
|
// Not ready yet
|
|
}
|
|
return false;
|
|
},
|
|
60000,
|
|
"app dev server to be ready",
|
|
);
|
|
|
|
// Verify GET / returns HTML
|
|
const htmlRes = await fetch(`http://localhost:${port}/`);
|
|
const contentType = htmlRes.headers.get("content-type");
|
|
const htmlBody = await htmlRes.text();
|
|
expect(contentType).toContain("text/html");
|
|
expect(htmlBody).toContain("<!DOCTYPE html>");
|
|
expect(htmlBody).toContain("<div id=\"root\">");
|
|
|
|
// Verify GET /__events returns SSE stream
|
|
const controller = new AbortController();
|
|
const sseTimeout = setTimeout(() => controller.abort(), 5000);
|
|
try {
|
|
const sseRes = await fetch(`http://localhost:${port}/__events`, {
|
|
signal: controller.signal,
|
|
});
|
|
const sseContentType = sseRes.headers.get("content-type");
|
|
expect(sseContentType).toContain("text/event-stream");
|
|
// Read a small chunk to verify SSE sends data
|
|
const reader = sseRes.body!.getReader();
|
|
const { value } = await reader.read();
|
|
const chunk = new TextDecoder().decode(value);
|
|
expect(chunk).toContain("data: connected");
|
|
reader.cancel();
|
|
} finally {
|
|
clearTimeout(sseTimeout);
|
|
}
|
|
|
|
// Verify esbuild bundle is served and contains valid JS
|
|
const bundleRes = await fetch(`http://localhost:${port}/dist/bundle.js`);
|
|
expect(bundleRes.status).toEqual(200);
|
|
expect(bundleRes.headers.get("content-type")).toContain("application/javascript");
|
|
const bundleJs = await bundleRes.text();
|
|
expect(bundleJs.length).toBeGreaterThan(100);
|
|
// The bundle should contain the React app code
|
|
expect(bundleJs).toContain("Hello from test app");
|
|
|
|
// Verify source map is served
|
|
const mapRes = await fetch(`http://localhost:${port}/dist/bundle.js.map`);
|
|
expect(mapRes.status).toEqual(200);
|
|
expect(mapRes.headers.get("content-type")).toContain("application/json");
|
|
const sourceMap = await mapRes.json() as any;
|
|
expect(sourceMap.version).toEqual(3);
|
|
expect(sourceMap.sources).toBeArray();
|
|
} finally {
|
|
if (proc) {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ timeout: 120000 },
|
|
);
|
|
|
|
// =============================================================================
|
|
// TEST 3: `wmill app dev` with Svelte app — verifies esbuild + Svelte plugin
|
|
// =============================================================================
|
|
|
|
test(
|
|
"wmill app dev: Svelte app bundles correctly and serves valid JS",
|
|
async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await writeFile(
|
|
join(tempDir, "wmill.yaml"),
|
|
"defaultTs: bun\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const appDir = join(tempDir, "f", "test", "svelteapp.raw_app");
|
|
await mkdir(appDir, { recursive: true });
|
|
|
|
await writeFile(
|
|
join(appDir, "raw_app.yaml"),
|
|
`custom_path: f/test/svelteapp\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
await writeFile(
|
|
join(appDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "test-svelte-app",
|
|
private: true,
|
|
dependencies: {
|
|
svelte: "^5.0.0",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
// Svelte apps use index.ts (not index.tsx)
|
|
await writeFile(
|
|
join(appDir, "index.ts"),
|
|
`import App from "./App.svelte";
|
|
import { mount } from "svelte";
|
|
|
|
const app = mount(App, { target: document.getElementById("root")! });
|
|
export default app;
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
await writeFile(
|
|
join(appDir, "App.svelte"),
|
|
`<script>
|
|
let count = $state(0);
|
|
</script>
|
|
|
|
<h1>Svelte Dev Test</h1>
|
|
<button onclick={() => count++}>clicks: {count}</button>
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
// npm install
|
|
const npmInstall = Bun.spawn(["npm", "install"], {
|
|
cwd: appDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
await Promise.all([
|
|
new Response(npmInstall.stdout).text(),
|
|
new Response(npmInstall.stderr).text(),
|
|
]);
|
|
expect(await npmInstall.exited).toEqual(0);
|
|
|
|
const port = await findFreePort();
|
|
|
|
const cliMainPath = getCLIMainPath();
|
|
const args = [
|
|
"run",
|
|
cliMainPath,
|
|
"--base-url",
|
|
backend.baseUrl,
|
|
"--workspace",
|
|
backend.workspace,
|
|
"--token",
|
|
backend.token!,
|
|
"--config-dir",
|
|
backend.testConfigDir,
|
|
"app",
|
|
"dev",
|
|
appDir,
|
|
"--no-open",
|
|
"--port",
|
|
String(port),
|
|
];
|
|
|
|
let proc: Subprocess | null = null;
|
|
|
|
try {
|
|
proc = Bun.spawn(["bun", ...args], {
|
|
cwd: tempDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env },
|
|
});
|
|
|
|
// Collect stderr for debugging on failure
|
|
const stderrReader = proc.stderr.getReader();
|
|
let stderrBuffer = "";
|
|
(async () => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await stderrReader.read();
|
|
if (done) break;
|
|
stderrBuffer += new TextDecoder().decode(value);
|
|
}
|
|
} catch {
|
|
// Process may have exited
|
|
}
|
|
})();
|
|
|
|
// Wait for the bundle to be ready (poll /dist/bundle.js instead of just /)
|
|
await waitFor(
|
|
async () => {
|
|
try {
|
|
const res = await fetch(`http://localhost:${port}/dist/bundle.js`, {
|
|
signal: AbortSignal.timeout(1000),
|
|
});
|
|
if (res.ok) {
|
|
const text = await res.text();
|
|
// Only consider ready when bundle has real content (not empty/placeholder)
|
|
return text.length > 100;
|
|
}
|
|
await res.text();
|
|
} catch {
|
|
// Not ready yet
|
|
}
|
|
return false;
|
|
},
|
|
60000,
|
|
"Svelte app dev server bundle to be ready",
|
|
);
|
|
|
|
// Verify HTML is served with correct structure
|
|
const htmlRes = await fetch(`http://localhost:${port}/`);
|
|
const htmlBody = await htmlRes.text();
|
|
expect(htmlBody).toContain("<!DOCTYPE html>");
|
|
expect(htmlBody).toContain('<div id="root">');
|
|
|
|
// Verify the JS bundle contains compiled Svelte code
|
|
const bundleRes = await fetch(`http://localhost:${port}/dist/bundle.js`);
|
|
expect(bundleRes.status).toEqual(200);
|
|
const bundleJs = await bundleRes.text();
|
|
expect(bundleJs.length).toBeGreaterThan(100);
|
|
// The bundle should contain the Svelte component text
|
|
expect(bundleJs).toContain("Svelte Dev Test");
|
|
|
|
// Verify source map
|
|
const mapRes = await fetch(`http://localhost:${port}/dist/bundle.js.map`);
|
|
expect(mapRes.status).toEqual(200);
|
|
const sourceMap = await mapRes.json() as any;
|
|
expect(sourceMap.version).toEqual(3);
|
|
} finally {
|
|
if (proc) {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ timeout: 120000 },
|
|
);
|
|
|