diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..8ceb8e722a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Files a generator owns. Collapsed in review diffs and left out of language +# stats: reviewing them means reviewing the generator instead. +*.gen.ts linguist-generated=true diff --git a/AGENTS.md b/AGENTS.md index 31c8a93de3..5e0c5885c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. +- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). ## Dev Environment diff --git a/cli/generate-dev-recorder.ts b/cli/generate-dev-recorder.ts new file mode 100644 index 0000000000..372da5b699 --- /dev/null +++ b/cli/generate-dev-recorder.ts @@ -0,0 +1,137 @@ +/** + * Bundles the frontend's raw-app session recorder into a browser script the dev + * server can serve, and writes it as a string constant the CLI bundle carries. + * + * The recorder is not re-implemented here: `wmill app dev --recording` must + * produce the exact recording format the players read, and the event + * classification it encodes is the hard part. The one adaptation is the `$state` + * rune: outside Svelte the store's fields are read through its getters, so the + * rune is stripped and the values become plain `let`s. + * + * Run `bun run gen:dev-recorder` after touching anything under + * frontend/src/lib/components/recording/; test/dev_recorder_bundle_unit.test.ts + * fails when the committed bundle no longer matches those sources. + */ +import * as esbuild from "esbuild"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const REPO_ROOT = join(import.meta.dir, ".."); +const RECORDING_DIR = join( + REPO_ROOT, + "frontend", + "src", + "lib", + "components", + "recording", +); +const ENTRY = "./rawAppRecording.svelte"; +const OUT_FILE = join( + import.meta.dir, + "src", + "commands", + "app", + "devRecorderBundle.gen.ts", +); + +/** Hash of everything that went into the bundle. Over the modules esbuild + * actually pulled in rather than a hand-kept list, so a module added to the + * recorder's import graph can't slip past the staleness check. Line endings are + * normalized: a CRLF checkout is the same source, and must not read as drift. */ +export function hashRecorderSources( + sources: string[], + root: string = REPO_ROOT, +): string { + const hash = createHash("sha256"); + for (const file of sources) { + hash.update(file); + hash.update( + readFileSync(join(root, file), "utf-8").replace(/\r\n/g, "\n"), + ); + } + return hash.digest("hex"); +} + +/** Reactivity has no meaning in a plain browser script, and the store exposes + * every rune-backed field through a getter, so `$state(x)` collapses to `x`. + * Every other rune is compiled away by Svelte and would survive here as an + * undefined identifier, so refuse rather than emit a bundle that throws. */ +function stripRunes(source: string, file: string): string { + const unsupported = [ + ...(source.match(/\$state[\w.]*\s*\(?/g) ?? []).filter( + (r) => r.replace(/\s/g, "") !== "$state(", + ), + ...(source.match( + /\$(derived|effect|props|bindable|inspect|host)[\w.]*/g, + ) ?? []), + ]; + if (unsupported.length > 0) { + throw new Error( + `${file} uses ${ + [...new Set(unsupported)].join(", ") + }, which this generator cannot strip. ` + + `Keep the recorder store free of runes beyond \`$state(...)\`.`, + ); + } + return source.replace(/\$state\s*\(/g, "("); +} + +const runeStripper: esbuild.Plugin = { + name: "strip-svelte-runes", + setup(build) { + build.onLoad({ filter: /\.svelte\.ts$/ }, (args) => ({ + contents: stripRunes(readFileSync(args.path, "utf-8"), args.path), + loader: "ts", + })); + }, +}; + +if (import.meta.main) { + const result = await esbuild.build({ + stdin: { + contents: `export { createRawAppRecording } from '${ENTRY}'`, + resolveDir: RECORDING_DIR, + loader: "ts", + }, + plugins: [runeStripper], + bundle: true, + format: "iife", + globalName: "__wmillRecorder", + target: "es2020", + minify: true, + metafile: true, + write: false, + legalComments: "none", + }); + + const js = result.outputFiles[0].text; + // Repo-relative and sorted, so the hash does not move with the checkout path + // or with esbuild's traversal order. `` is the entry above, not a file. + const sources = Object.keys(result.metafile.inputs) + .filter((f) => f !== "") + .map((f) => relative(REPO_ROOT, join(import.meta.dir, f))) + .sort(); + writeFileSync( + OUT_FILE, + `// Generated by cli/generate-dev-recorder.ts. Do not edit.\n` + + `// Run \`bun run gen:dev-recorder\` from cli/ to rebuild it from\n` + + `// frontend/src/lib/components/recording/.\n\n` + + `/** Repo-relative sources bundled below. */\n` + + `export const DEV_RECORDER_SOURCES = ${ + JSON.stringify(sources, null, 2) + };\n\n` + + `/** SHA-256 of those sources, as of this build. */\n` + + `export const DEV_RECORDER_SOURCE_HASH = ${ + JSON.stringify(hashRecorderSources(sources)) + };\n\n` + + `/** IIFE exposing \`createRawAppRecording\` on \`window.__wmillRecorder\`. */\n` + + `export const DEV_RECORDER_BUNDLE = ${JSON.stringify(js)};\n`, + "utf-8", + ); + console.log( + `Wrote ${OUT_FILE} (${ + Math.round(js.length / 1024) + } KB of bundled JS from ${sources.length} sources)`, + ); +} diff --git a/cli/package.json b/cli/package.json index e39bafdac2..9a51e8c1ae 100644 --- a/cli/package.json +++ b/cli/package.json @@ -9,6 +9,7 @@ "preinstall": "node ./prepare-yaml-validator.mjs", "dev": "bun run src/main.ts", "build": "./build.sh", + "gen:dev-recorder": "bun run generate-dev-recorder.ts", "test": "bun test test/", "test:unit": "UNIT_ONLY=1 bun test test/*_unit*", "check": "bunx tsc --noEmit", diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index f4c75ec1f1..9513256d2d 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -51,6 +51,9 @@ import { pollJobWithQueueLogging } from "../../utils/job_polling.ts"; const TOP_HASH = "__app_hash"; export const APP_BACKEND_FOLDER = "backend"; +/** Where `wmill app dev --recording` writes finished session recordings. Local + * artifacts, so the app source push skips them. */ +export const RECORDINGS_FOLDER = "recordings"; // Union type for app files that can be either raw or normal apps type AppFile = RawAppFile | NormalAppFile; diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index c4206403c6..b45666a1e1 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -48,6 +48,17 @@ import { hasFolderSuffix, loadNonDottedPathsSetting, } from "../../utils/resource_folders.ts"; +import { + createRecorderShellHTML, + DEV_RECORDER_BUNDLE, + isOwnOrigin, + isRecordingFileName, + RECORDER_BUNDLE_PATH, + RECORDER_SAVE_PATH, + RECORDER_SHELL_PATH, + recordingFileName, + RECORDINGS_FOLDER, +} from "./devRecorder.ts"; // Resolved once per `wmill app dev` run from wmill.yaml; a bare `.ts` under // backend/ denotes this runtime, so readers must agree with the path assigner. @@ -333,6 +344,7 @@ interface DevOptions extends GlobalOptions { host?: string; entry?: string; open?: boolean; + recording?: boolean; } async function dev(opts: DevOptions, appFolder?: string) { @@ -537,6 +549,23 @@ async function dev(opts: DevOptions, appFolder?: string) { fs.mkdirSync(distDir); } + // Session recording: the app moves into an iframe of a shell page holding the + // recorder toolbar, and finished recordings land in the app folder. + const recordingEnabled = opts.recording ?? false; + const recordingsDir = path.join(process.cwd(), RECORDINGS_FOLDER); + // The player is a page of the instance this app is developed against, so the + // recording it fetches from here crosses origins. + const playerBaseUrl = workspace.remote.endsWith("/") + ? workspace.remote + : `${workspace.remote}/`; + const playerOrigin = (() => { + try { + return new URL(playerBaseUrl).origin; + } catch { + return undefined; + } + })(); + // SSE clients for live reload const clients: http.ServerResponse[] = []; @@ -721,10 +750,152 @@ async function dev(opts: DevOptions, appFolder?: string) { ); } + // Same ceiling the player refuses to load past, so nothing is written here + // that could not be replayed. + const MAX_RECORDING_BYTES = 100 * 1024 * 1024; + + function sendJson(res: http.ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } + + /** Write the recording under a name nothing else holds. `wx` is what makes + * this safe: two tabs stopping in the same millisecond both create, and the + * loser retries rather than overwriting the winner. */ + async function writeRecording(body: string): Promise { + const now = new Date(); + for (let attempt = 0; attempt < 100; attempt++) { + const file = recordingFileName(now, attempt); + try { + await fs.promises.writeFile(path.join(recordingsDir, file), body, { + flag: "wx", + }); + return file; + } catch (error: any) { + if (error?.code !== "EEXIST") throw error; + } + } + throw new Error("Could not find a free recording file name"); + } + + function saveRecording(req: http.IncomingMessage, res: http.ServerResponse) { + if (!isOwnOrigin(req.headers.origin, req.headers.host)) { + sendJson(res, 403, { error: "Cross-origin recording upload refused" }); + return; + } + const chunks: Buffer[] = []; + let size = 0; + let refused = false; + // An upload cut short (by the refusal below, or by the browser) raises + // 'error' on the request, which unhandled takes the dev server down. + req.on("error", (error: Error) => { + if (!refused) { + log.warn(colors.yellow(`Recording upload failed: ${error.message}`)); + } + }); + req.on("data", (chunk: Buffer) => { + if (refused) return; + size += chunk.length; + if (size > MAX_RECORDING_BYTES) { + refused = true; + // Torn down only once the 413 is on the wire: destroying the socket + // first loses the response the browser is waiting to read. + res.on("finish", () => req.destroy()); + sendJson(res, 413, { + error: `Recording exceeds ${MAX_RECORDING_BYTES} bytes`, + }); + return; + } + chunks.push(chunk); + }); + req.on("end", async () => { + if (refused) return; + const body = Buffer.concat(chunks).toString("utf-8"); + try { + JSON.parse(body); + } catch { + sendJson(res, 400, { error: "Body is not valid JSON" }); + return; + } + let file: string; + try { + await fs.promises.mkdir(recordingsDir, { recursive: true }); + file = await writeRecording(body); + } catch (error: any) { + log.error(colors.red(`Failed to save recording: ${error.message}`)); + sendJson(res, 500, { error: error.message }); + return; + } + log.info( + colors.green( + `🎬 Recording saved to ${path.join(RECORDINGS_FOLDER, file)}`, + ), + ); + log.info( + colors.gray( + ` Replay it at ${playerBaseUrl}replay (open the file, or use ?src=)`, + ), + ); + sendJson(res, 200, { file }); + }); + } + + function serveRecording(url: string, res: http.ServerResponse) { + const file = url.slice(RECORDER_SAVE_PATH.length + 1); + if (!isRecordingFileName(file)) { + sendJson(res, 400, { error: "Invalid recording name" }); + return; + } + const filePath = path.join(recordingsDir, file); + if (!fs.existsSync(filePath)) { + sendJson(res, 404, { error: "Recording not found" }); + return; + } + res.writeHead(200, { + "Content-Type": "application/json", + // The player runs on the instance, not on the dev server: without this it + // can fetch the recording but never read it. + ...(playerOrigin ? { "Access-Control-Allow-Origin": playerOrigin } : {}), + }); + // Streamed: a recording is megabytes, and this server also carries the app, + // its live reload and every runnable call. + fs.createReadStream(filePath).on("error", () => res.end()).pipe(res); + } + // Create HTTP server const server = http.createServer((req, res) => { const url = req.url || "/"; + if (recordingEnabled) { + const pathname = url.split("?")[0]; + if (pathname === RECORDER_BUNDLE_PATH) { + res.writeHead(200, { "Content-Type": "application/javascript" }); + res.end(DEV_RECORDER_BUNDLE); + return; + } + if (pathname === RECORDER_SAVE_PATH && req.method === "POST") { + saveRecording(req, res); + return; + } + if ( + pathname.startsWith(`${RECORDER_SAVE_PATH}/`) && req.method === "GET" + ) { + serveRecording(pathname, res); + return; + } + if (pathname === RECORDER_SHELL_PATH) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + createRecorderShellHTML({ + appPath, + workspace: workspaceId, + playerBaseUrl, + }), + ); + return; + } + } + // SSE endpoint for live reload if (url === "/__events") { res.writeHead(200, { @@ -1359,18 +1530,29 @@ async function dev(opts: DevOptions, appFolder?: string) { server.listen(port, host, () => { const url = `http://${host}:${port}`; + // The toolbar lives beside the app, not in front of it, so recording mode + // is what the browser should land on. + const openUrl = recordingEnabled ? `${url}${RECORDER_SHELL_PATH}` : url; log.info(colors.bold.green(`🚀 Dev server running at ${url}`)); log.info( colors.cyan(`🔌 WebSocket server running at ws://${host}:${port}`), ); log.info(colors.gray(`📦 Serving files from: ${process.cwd()}`)); - log.info(colors.gray(`🔄 Live reload enabled\n`)); + log.info(colors.gray(`🔄 Live reload enabled`)); + if (recordingEnabled) { + log.info( + colors.magenta( + `🎬 Session recording at ${openUrl} : press Record in the toolbar. Recordings are saved to ${RECORDINGS_FOLDER}/`, + ), + ); + } + log.info(""); // Open browser if requested if (shouldOpen) { try { open - .openApp(open.apps.browser, { arguments: [url] }) + .openApp(open.apps.browser, { arguments: [openUrl] }) .catch((error: any) => { log.error( colors.yellow( @@ -1423,6 +1605,10 @@ const command = new Command() "Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)", ) .option("--no-open", "Don't automatically open the browser") + .option( + "--recording", + "Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development", + ) .action(dev as any); export default command; diff --git a/cli/src/commands/app/devRecorder.ts b/cli/src/commands/app/devRecorder.ts new file mode 100644 index 0000000000..dc7b6e5032 --- /dev/null +++ b/cli/src/commands/app/devRecorder.ts @@ -0,0 +1,270 @@ +/** + * `wmill app dev --recording`: a shell page that frames the app under + * development and records the session with the same recorder the Windmill UI + * uses, so a locally-built app can be demoed without deploying it first. + * + * The shell holds the toolbar rather than the app page: the recorder snapshots + * the framed document on every mutation, and a toolbar living in that document + * would record itself. It also lives at its own path, so the app keeps the root + * URL its router and its own links expect. + */ +import { DEV_RECORDER_BUNDLE } from "./devRecorderBundle.gen.ts"; + +/** Path the bundled recorder is served from. */ +export const RECORDER_BUNDLE_PATH = "/__wm_recorder.js"; +/** Path the toolbar shell is served from. Not the root: the app stays there, so + * a link or a router push to `/` inside it reloads the app rather than nesting + * another shell in the frame. */ +export const RECORDER_SHELL_PATH = "/__record"; +/** Recordings are POSTed here and served back from `/`. */ +export const RECORDER_SAVE_PATH = "/__recordings"; + +export { DEV_RECORDER_BUNDLE }; +export { RECORDINGS_FOLDER } from "./app_metadata.ts"; + +/** Whether a request to the save route came from the shell this server serves. + * A cross-site POST carrying JSON under a simple content type (`text/plain`) + * needs no preflight, so without this any page open in the developer's browser + * could write files into the app folder. Compared against the `Host` the browser + * actually reached us on, since the shell is equally `localhost`, `127.0.0.1` or + * whatever `--host` binds. */ +export function isOwnOrigin( + origin: string | undefined, + host: string | undefined, +): boolean { + // Absent on a same-origin request in some browsers, and on non-browser + // clients (curl, a script); a forged one is no worse than no header at all. + if (origin === undefined) return true; + if (host === undefined) return false; + try { + return new URL(origin).host === host; + } catch { + return false; + } +} + +/** Name a recording after the moment it was saved. Millisecond resolution plus + * the caller's collision retry: two tabs stopping together must not have one + * silently overwrite the other. */ +export function recordingFileName(now: Date, attempt = 0): string { + const stamp = now.toISOString().slice(0, 23).replace(/[:T.]/g, "-"); + return `recording-${stamp}${attempt > 0 ? `-${attempt}` : ""}.json`; +} + +/** Names the read route accepts. Rejects every separator and escape, so the + * name can only ever resolve inside the recordings folder. */ +export function isRecordingFileName(file: string): boolean { + return /^[A-Za-z0-9._-]+\.json$/.test(file) && !file.includes(".."); +} + +export function createRecorderShellHTML(opts: { + appPath: string; + workspace: string; + /** Base URL of the Windmill instance, for the "Open in player" link. */ + playerBaseUrl?: string; +}): string { + // `<` escaped: this lands inside an inline ` in + // any value (the app path comes from raw_app.yaml) would end the block. + const config = JSON.stringify({ + appPath: opts.appPath, + workspace: opts.workspace, + playerBaseUrl: opts.playerBaseUrl ?? null, + savePath: RECORDER_SAVE_PATH, + }).replace(/ + + + + + Windmill App Dev Recording + + + +
+ + Not recording + + + Passwords are masked. Mark sensitive elements with data-wm-no-record +
+ + + + + +`; +} diff --git a/cli/src/commands/app/devRecorderBundle.gen.ts b/cli/src/commands/app/devRecorderBundle.gen.ts new file mode 100644 index 0000000000..0e7f004b4b --- /dev/null +++ b/cli/src/commands/app/devRecorderBundle.gen.ts @@ -0,0 +1,15 @@ +// Generated by cli/generate-dev-recorder.ts. Do not edit. +// Run `bun run gen:dev-recorder` from cli/ to rebuild it from +// frontend/src/lib/components/recording/. + +/** Repo-relative sources bundled below. */ +export const DEV_RECORDER_SOURCES = [ + "frontend/src/lib/components/recording/rawAppRecording.svelte.ts", + "frontend/src/lib/components/recording/rawAppSnapshot.ts" +]; + +/** SHA-256 of those sources, as of this build. */ +export const DEV_RECORDER_SOURCE_HASH = "c93b8b23455528be0a03da303fb573536bd3f0b8386e6f5f1078a6b988f59082"; + +/** IIFE exposing `createRawAppRecording` on `window.__wmillRecorder`. */ +export const DEV_RECORDER_BUNDLE = "var __wmillRecorder=(()=>{var ne=Object.defineProperty;var Fe=Object.getOwnPropertyDescriptor;var Xe=Object.getOwnPropertyNames;var Be=Object.prototype.hasOwnProperty;var je=(t,o)=>{for(var n in o)ne(t,n,{get:o[n],enumerable:!0})},Ke=(t,o,n,s)=>{if(o&&typeof o==\"object\"||typeof o==\"function\")for(let l of Xe(o))!Be.call(t,l)&&l!==n&&ne(t,l,{get:()=>o[l],enumerable:!(s=Fe(o,l))||s.enumerable});return t};var We=t=>Ke(ne({},\"__esModule\",{value:!0}),t);var mt={};je(mt,{createRawAppRecording:()=>Ie});var G=\"data-wm-rec-target\",D=\"data-wm-no-record\";function Ye(t,o){let n=(u,c,f)=>{let d=f.trim();if(!d||/^(data:|blob:|about:|https?:|\\/\\/|#)/i.test(d))return u;try{return`url(${c}${new URL(d,o).href}${c})`}catch{return u}},s=\"\",l=0;for(;ln?String.fromCodePoint(parseInt(n,16)):s)}function Qe(t){let o=new Set,n=new Set;for(let s of Array.from(t.querySelectorAll(\"style\"))){if(_(s))continue;let l=s.textContent??\"\";for(let u of l.matchAll(Ge))o.add(Ee(u[1]));for(let u of l.matchAll(Je))n.add(Ee(u[1]))}return{classes:o,ids:n}}function Ze(t,o){let n=Qe(o),s=[...o.hasAttribute(D)?[o]:[],...Array.from(o.querySelectorAll(`[${D}]`))];for(let l of s){l.replaceChildren(t.createTextNode(\"\\u2022\\u2022\\u2022\")),l.setAttribute(D,\"\");for(let u of Array.from(l.attributes)){if(u.name===D)continue;let c=u.localName.toLowerCase();if(!ze.has(c))l.removeAttributeNode(u);else if(c===\"class\"){let f=u.value.split(/\\s+/).filter(d=>d&&n.classes.has(d));f.length?l.setAttribute(\"class\",f.join(\" \")):l.removeAttributeNode(u)}else c===\"id\"&&!n.ids.has(u.value)&&l.removeAttributeNode(u)}}}var et=4e6,tt=8e6;function nt(t,o){let n=t.querySelectorAll(\"canvas\"),s=o.querySelectorAll(\"canvas\");if(n.length!==s.length)return;let l=tt;for(let u=0;uet||f>l)continue;l-=f;let d;try{d=c.toDataURL(\"image/webp\",.85)}catch{continue}if(!d.startsWith(\"data:image/\"))continue;let m=c.getBoundingClientRect();if(!m.width||!m.height)continue;let y=t.defaultView?.getComputedStyle(c).display,v=!y||y===\"inline\"?\"inline-block\":y,F=s[u],X=F.getAttribute(\"style\");F.setAttribute(\"style\",`${X?X+\";\":\"\"}display:${v};box-sizing:border-box;width:${m.width}px;height:${m.height}px;background-image:url(\"${d}\");background-size:100% 100%;background-repeat:no-repeat`)}}function rt(t,o){let n=t.querySelectorAll(\"select\"),s=o.querySelectorAll(\"select\");if(n.length===s.length)for(let l=0;l_(f)))continue;let c=t.createElement(\"option\");c.setAttribute(\"selected\",\"\"),c.textContent=\"\\u2022\\u2022\\u2022\",s[l].replaceChildren(c)}}function it(t,o){let n=\"input, textarea, select\",s=t.querySelectorAll(n),l=o.querySelectorAll(n);if(s.length===l.length)for(let u=0;u{let n=o.styleSheet;if(!n)return o.cssText;try{let s=be(n.cssRules),l=n.media?.mediaText;return l?`@media ${l} {\n${s}\n}`:s}catch{return o.cssText}}).join(`\n`)}function ot(t,o){let n=be(o);t.href&&(n=Ye(n,t.href));let s=t.media?.mediaText;return s&&(n=`@media ${s} {\n${n}\n}`),n}function st(t,o,n){for(let s of Array.from(t.styleSheets)){let l=s.ownerNode;if(!C(l))continue;if(s.disabled){let m=re(o,l),y=m?ie(n,m):void 0;y&&(y.setAttribute(\"media\",\"not all\"),y.tagName===\"STYLE\"&&(y.textContent=\"\"));continue}if(_(l))continue;let u;try{let m=s.cssRules;if(!m)continue;u=m}catch{continue}let c=re(o,l);if(!c)continue;let f=ie(n,c);if(!f)continue;let d=ot(s,u);if(l.tagName===\"LINK\"){let m=t.createElement(\"style\");m.textContent=d,f.replaceWith(m)}else l.tagName===\"STYLE\"&&(f.textContent=d)}}function Se(t,o={}){let n=t.documentElement,s=n.cloneNode(!0);if(it(t,s),st(t,n,s),nt(t,s),rt(t,s),Ze(t,s),o.target){let d=re(n,o.target);(d?ie(s,d):void 0)?.setAttribute(G,\"\")}s.querySelectorAll(\"template, noscript\").forEach(d=>d.remove()),s.querySelectorAll(\"script\").forEach(d=>d.remove()),s.querySelectorAll('meta[http-equiv=\"refresh\" i]').forEach(d=>d.remove()),s.querySelectorAll(\"*\").forEach(d=>{for(let m of Array.from(d.attributes))m.name.toLowerCase().startsWith(\"on\")&&d.removeAttribute(m.name)});let l=t.defaultView,u=Math.round(l?.scrollY??t.documentElement.scrollTop??0),c=Math.round(l?.scrollX??t.documentElement.scrollLeft??0);if(u>0||c>0){let d=t.createElement(\"style\");d.textContent=`html { margin-top: -${u}px !important; margin-left: -${c}px !important; }`,s.querySelector(\"head\")?.appendChild(d)}let f=s.querySelector(\"head\");if(o.baseHref&&f&&!f.querySelector(\"base\")){let d=t.createElement(\"base\");d.setAttribute(\"href\",o.baseHref),f.prepend(d)}return`${s.outerHTML}`}var gt=`[${G}] {\n\toutline: 3px solid #ef4444 !important;\n\toutline-offset: 2px !important;\n\tbox-shadow: 0 0 0 6px rgba(239, 68, 68, 0.25) !important;\n}`;function oe(t){if(!t||_(t))return\"\";let o=t;return t.querySelector(`[${D}]`)&&(o=t.cloneNode(!0),o.querySelectorAll(`[${D}]`).forEach(n=>n.remove())),(o.textContent??\"\").replace(/\\s+/g,\" \").trim()}function ye(t,o=40){let n=oe(t);return n.length>o?`${n.slice(0,o)}\\u2026`:n}function we(t){let o=t.tagName.toLowerCase(),n=(t.getAttribute(\"type\")??\"text\").toLowerCase(),s=o===\"input\"?`input[${n}]`:o,l=t.labels?.[0],u=t.getAttribute(\"aria-label\")||(l&&!_(l)?ye(l):\"\")||(o===\"input\"&&[\"button\",\"submit\",\"reset\"].includes(n)?t.getAttribute(\"value\"):\"\")||t.getAttribute(\"placeholder\")||t.getAttribute(\"title\")||ye(t)||t.getAttribute(\"name\")||t.getAttribute(\"id\")||\"\";return u?`${s} \"${u}\"`:s}function Re(t){let o=[],n=t,s=0;for(;n&&s<5;){let l=n.tagName.toLowerCase();if(n.id){o.unshift(`#${n.id}`);break}let u=typeof n.className==\"string\"?n.className.trim().split(/\\s+/).filter(Boolean)[0]:void 0,c=n.parentElement,f=u?`${l}.${u}`:l;if(c){let d=Array.from(c.children).filter(m=>m.tagName===n.tagName);d.length>1&&(f+=`:nth-of-type(${d.indexOf(n)+1})`)}o.unshift(f),n=c,s++}return o.join(\" > \")}function Le(t,o,n){switch(t){case\"click\":return`Clicked ${o}`;case\"fill\":return`Filled ${o} with \"${n??\"\"}\"`;case\"select\":return`Selected \"${n??\"\"}\" in ${o}`;case\"toggle\":return n?`${n===\"checked\"?\"Checked\":\"Unchecked\"} ${o}`:`Toggled ${o}`;case\"submit\":return`Submitted ${o}`;case\"key\":return`Pressed ${n??\"key\"} in ${o}`;case\"navigate\":return n?`Navigated to ${n}`:\"Reloaded the app\"}}var V=400,ct=3e3,ve=6e4,Ce=800,_e=new Set([\"button\",\"submit\",\"reset\",\"image\"]),ut=new Set([\"range\",\"color\",\"date\",\"time\",\"datetime-local\",\"month\",\"week\"]),dt=new Set([\"\",\"text\",\"search\",\"url\",\"tel\",\"email\",\"password\",\"number\"]),J=200,ft=250,xe=500;function Ie(){let t=!1,o=0,n=0,s=\"\",l,u,c=[],f=[],d=new Map,m=0,y=!1,v=!1,F={width:0,height:0},X=\"\",z=[],A,M,O=0,L,b,h,q=new Set,Q,N=0,B,Z=!1;function ke(e){return new Promise(a=>{let i=()=>{if(N===0||Date.now()-e>=ve||!P()){a();return}setTimeout(i,V)};i()})}let Me=e=>new Promise(a=>setTimeout(a,e));function P(){try{return u?.contentDocument??void 0}catch{return}}function $(e){if(e===void 0)return;let a=d.get(e);if(a!==void 0)return a;if(m+e.length>41943040){y=!0,v=!0;return}let i=f.length;return f.push(e),d.set(e,i),m+=e.length,i}function S(e){if(v)return;let a=P();if(a)try{return Se(a,{target:e,baseHref:X})}catch(i){console.warn(\"raw app recorder: snapshot failed\",i);return}}function Ne(e){return e.replace(` ${G}=\"\"`,\"\")}function j(){h&&(h.observer.disconnect(),clearTimeout(h.timer),clearTimeout(h.cap),h=void 0)}function se(e){j();let a=P();if(!a)return;let i=()=>{if(N>0&&h&&Date.now()-h.startedAt{h&&(clearTimeout(h.timer),h.timer=setTimeout(i,V))});r.observe(a,{subtree:!0,childList:!0,attributes:!0,characterData:!0}),h={step:e,observer:r,startedAt:Date.now(),timer:setTimeout(i,V),cap:setTimeout(i,ct)}}function ae(e){if(!h)return;let a=h.step;j();let i=e!==void 0&&(L?.html===e||b?.html===e);a.after=$(i?Ne(e):S())}function x(e,a,i,r,w=!1){if(!t)return;let E=Date.now()-n,p=c[c.length-1],R=!!p&&!!a&&ue(M,a)&&p.kind===e&&(w||Ue(a)&&E-O=500&&!R){y=!0,v=!0;return}let T=!!a&&_(a),H=le(a?T?Ae(a):we(a):\"the app\")??\"the app\",W=r&&r.length>J?`${r.slice(0,J)}\\u2026`:r,k=!T||!W?W:e===\"toggle\"?void 0:Y(W),ge=Le(e,H,k);if(R&&p){p.value=k,p.label=ge,O=E,se(p);return}let he={t:E,kind:e,label:ge,target:H,selector:a&&!T?le(Re(a)):void 0,value:k,before:$(i??(e===\"key\"?S(a):void 0))};c.push(he),i!==void 0&&L?.html===i&&(L=void 0),i!==void 0&&b?.html===i&&(b=void 0),M=a,O=E,o=c.length,se(he)}function Pe(e){let i=e.closest(\"label\")?.control;return!i||e===i||i.contains(e)?!1:!e.closest(\"a, button, input, select, textarea\")}function $e(e){return e.ctrlKey||e.metaKey||e.altKey?!1:e.key.length===1||[\" \",\"Enter\",\"ArrowUp\",\"ArrowDown\",\"ArrowLeft\",\"ArrowRight\",\"Home\",\"End\"].includes(e.key)}function ee(e){if(g(e,\"SELECT\"))return!0;if(!g(e,\"INPUT\"))return!1;let a=e.type;return!K(e)&&!_e.has(a)}function He(e){return g(e,\"BUTTON\")?(e.type||\"submit\")===\"submit\":g(e,\"INPUT\")&&[\"submit\",\"image\"].includes(e.type)}function De(e){let a=c[c.length-1];return a?.kind===\"key\"&&a.value===\"Enter\"&&!!e&&!!M&&e.contains(M)&&Date.now()-n-OJ?`${e.slice(0,J)}\\u2026`:e}function K(e){return g(e,\"TEXTAREA\")||e.isContentEditable?!0:g(e,\"INPUT\")&&dt.has(e.type)}function ce(e){let a=g(e,\"INPUT\")||g(e,\"TEXTAREA\")?e.value:oe(e);return g(e,\"INPUT\")&&e.type===\"password\"||_(e)?Y(a):a}function U(e){let a=L?.el;if(!a)return;if(a===e||a.contains(e)||e.contains(a))return L?.html;let i=e.labels;if(i&&Array.from(i).some(r=>r===a||r.contains(a)))return L?.html}function ue(e,a){if(!e||!a)return!1;if(e===a)return!0;let i=e,r=a;return i.type===\"radio\"&&r.type===\"radio\"&&!!i.name&&i.name===r.name&&i.form===r.form}function te(e){if(b)return ue(b.el,e)?b.html:void 0}function I(){if(!A)return;let{el:e,before:a}=A;clearTimeout(A.timer),A=void 0,U(e)!==void 0&&(L=void 0),b?.el===e&&(b=void 0),x(\"fill\",e,a,ce(e))}function de(e){let a=(i,r)=>{e.addEventListener(i,r,!0),z.push(()=>e.removeEventListener(i,r,!0))};a(\"pointerdown\",i=>{let r=C(i.target)?i.target:void 0;r&&(L={el:r,html:S(r)})}),a(\"click\",i=>{let r=C(i.target)?i.target:void 0;r&&(A&&A.el!==r&&I(),!(K(r)||ee(r)||g(r,\"OPTION\"))&&(i.detail===0&&He(r)&&De(r.closest(\"form\"))||Pe(r)||x(\"click\",r,U(r)??S(r))))}),a(\"focusin\",i=>{let r=C(i.target)?i.target:void 0;L&&(!r||U(r)===void 0)&&(L=void 0)}),a(\"beforeinput\",i=>{let r=C(i.target)?i.target:void 0;!r||!K(r)||A?.el===r||U(r)===void 0&&(b={el:r,html:S(r),repeat:!1})}),a(\"input\",i=>{let r=C(i.target)?i.target:void 0;if(!(!r||!K(r)))if(A&&A.el!==r&&I(),A)clearTimeout(A.timer),A.timer=setTimeout(I,Ce);else{let w=U(r),E=te(r),p=w??E??S(r);ae(p),A={el:r,before:p,timer:setTimeout(I,Ce)}}}),a(\"change\",i=>{let r=C(i.target)?i.target:void 0;if(!r)return;if(K(r)){I();return}A&&I();let w=U(r),E=te(r),p=w??E,R=w===void 0&&E!==void 0&&!!b?.repeat;if(g(r,\"SELECT\")){let T=Array.from(r.selectedOptions),H=T.map(k=>k.label||k.value).join(\", \"),W=T.some(k=>_(k));x(\"select\",r,p,W?Y(H):H,R)}else if(g(r,\"INPUT\")){let T=r;[\"checkbox\",\"radio\"].includes(T.type)?x(\"toggle\",r,p,T.checked?\"checked\":\"unchecked\",R):T.type===\"file\"?x(\"fill\",r,p,Array.from(T.files??[]).map(H=>H.name).join(\", \")):x(\"fill\",r,p,ce(r),R)}}),a(\"submit\",i=>{let r=C(i.target)?i.target:void 0;I();let w=c[c.length-1];(w?.kind===\"click\"||w?.kind===\"key\"&&w.value===\"Enter\")&&M&&r&&r.contains(M)&&Date.now()-n-O{let r=C(i.target)?i.target:void 0;r&&ee(r)&&$e(i)&&(i.repeat&&b&&te(r)!==void 0?b.repeat=!0:b={el:r,html:S(r),repeat:i.repeat}),!(i.key!==\"Enter\"&&i.key!==\"Escape\")&&(i.key===\"Enter\"&&r&&(ee(r)||qe(r)||Oe(r))||(I(),x(\"key\",r,i.repeat?void 0:S(r),i.key,i.repeat)))})}function fe(){z.forEach(e=>e()),z=[]}function me(e){let a=e.contentWindow,i=E=>{let p=E.data;if(!p||typeof p!=\"object\"||E.source!==window)return;let{type:R,reqId:T}=p;typeof R!=\"string\"||!R.endsWith(\"Res\")||(q.delete(T),N=q.size)},r=()=>{let E=P();!E||E===Q||(a?.addEventListener(\"message\",i),Q=E)},w=E=>{let p=E.data;if(!p||typeof p!=\"object\"||E.source!==a)return;let{type:R,reqId:T}=p;typeof R!=\"string\"||R.endsWith(\"Res\")||T===void 0||(r(),q.add(T),N=q.size)};return window.addEventListener(\"message\",w),r(),()=>{window.removeEventListener(\"message\",w),a?.removeEventListener(\"message\",i),Q=void 0}}function pe(){fe();let e=h?.step;j(),A&&clearTimeout(A.timer),A=void 0,L=void 0,b=void 0;let a=P();if(!a)return;de(a),u&&(B?.(),B=me(u));let i=S();if(e&&(e.after=$(i)),f.length===0){$(i);return}x(\"navigate\",void 0,i,a.location?.hash||void 0)}return{get active(){return t},get stepCount(){return o},get stopping(){return Z},start(e,a){u=e;let i=P();return i?.documentElement?(t=!0,n=Date.now(),s=a.appPath,l=a.workspace,c=[],M=void 0,O=0,o=0,f=[],d=new Map,m=0,y=!1,v=!1,X=typeof window<\"u\"?window.location.origin:\"\",F={width:e.clientWidth||i.documentElement.clientWidth,height:e.clientHeight||i.documentElement.clientHeight},i.readyState===\"complete\"&&i.location?.href!==\"about:blank\"&&$(S()),de(i),e.addEventListener(\"load\",pe),q.clear(),N=0,B=me(e),!0):(u=void 0,!1)},async stop(){if(I(),fe(),t=!1,h&&N>0){Z=!0;let a=h.startedAt;await ke(a),P()&&await Me(V),Z=!1}if(h){let a=h.step;j(),a.after=$(S())}B?.(),B=void 0,q.clear(),N=0,u?.removeEventListener(\"load\",pe),L=void 0,b=void 0,u=void 0;let e={version:1,type:\"app\",recorded_at:new Date().toISOString(),app_path:s,workspace:l,total_duration_ms:Date.now()-n,viewport:F,frames:f,steps:c,truncated:y||void 0};return c=[],f=[],d=new Map,m=0,e},download(e){let a=new Blob([JSON.stringify(e)],{type:\"application/json\"}),i=URL.createObjectURL(a),r=document.createElement(\"a\");r.href=i,r.download=`app-recording-${(e.app_path||\"untitled\").replace(/\\//g,\"-\")}-${Date.now()}.json`,r.click(),URL.revokeObjectURL(i)}}}return We(mt);})();\n"; diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 93c2314b12..9f83d0322a 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -17,7 +17,7 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts"; import { replaceInlineScripts, repopulateFields } from "./app.ts"; import { createBundle, detectFrameworks } from "./bundle.ts"; -import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; +import { APP_BACKEND_FOLDER, RECORDINGS_FOLDER } from "./app_metadata.ts"; import { writeIfChanged } from "../../utils/utils.ts"; import { yamlOptions } from "../sync/sync.ts"; import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; @@ -319,6 +319,12 @@ async function collectAppFiles( ) { continue; } + // Session recordings, which the dev server only ever writes at the app + // root. Matched there alone, so an app of its own with a `recordings/` + // component folder still ships it. + if (basePath === "/" && entry.name === RECORDINGS_FOLDER) { + continue; + } await readDirRecursive(fullPath + SEP, relativePath + "/"); } else if (entry.isFile()) { // Skip generated/metadata files that shouldn't be part of the app diff --git a/cli/src/commands/app/wmillTsDev.ts b/cli/src/commands/app/wmillTsDev.ts index cd77652f53..7cb2ea328b 100644 --- a/cli/src/commands/app/wmillTsDev.ts +++ b/cli/src/commands/app/wmillTsDev.ts @@ -68,11 +68,45 @@ function initWebSocket() { initWebSocket() +/** A runnable call leaves this page over the WebSocket without touching the DOM, + * so the session recorder of \`wmill app dev --recording\` (which frames the app) + * has nothing else to tell it a step is still waiting on the backend. Announcing + * the request and its answer to the shell mirrors what the deployed runner posts + * across the same boundary. */ +const framed = typeof window !== 'undefined' && window.parent !== window + +function notifyRecorder(type: string, reqId: string) { + if (framed) window.parent.postMessage({ type, reqId }, window.location.origin) +} + +// A reload takes the previous context and its WebSocket with it, so whatever it +// had in flight can never answer. Announcing a fresh module is how the shell +// learns those calls are dead: a message posted from the unloading document +// would be dropped with the realm that sent it, and this runs before any app +// code can issue a call of its own. +if (framed) { + window.parent.postMessage({ type: 'wmillDevReady' }, window.location.origin) +} + +function tracked(type: string, reqId: string, resolve: (v: any) => void, reject: (e: any) => void) { + notifyRecorder(type, reqId) + let settled = false + const done = () => { + if (settled) return + settled = true + notifyRecorder(type + 'Res', reqId) + } + return { + resolve: (v: any) => { done(); resolve(v) }, + reject: (e: any) => { done(); reject(e) } + } +} + async function doRequest(type: string, o: object) { await wsReady return new Promise((resolve, reject) => { const reqId = Math.random().toString(36) - reqs[reqId] = { resolve, reject } + reqs[reqId] = tracked(type, reqId, resolve, reject) ws?.send(JSON.stringify({ ...o, type, reqId })) }) } @@ -119,7 +153,7 @@ export function streamJob( return new Promise(async (resolve, reject) => { await wsReady const reqId = Math.random().toString(36) - reqs[reqId] = { resolve, reject, onUpdate } + reqs[reqId] = { ...tracked('streamJob', reqId, resolve, reject), onUpdate } ws?.send(JSON.stringify({ jobId, type: 'streamJob', reqId })) }) } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6c9bf40002..196c4c54e8 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -141,6 +141,7 @@ import { isExecutionModeAnonymous } from "../app/app.ts"; import { APP_BACKEND_FOLDER, generateAppLocksInternal, + RECORDINGS_FOLDER, } from "../app/app_metadata.ts"; import { isFlowPath, @@ -1867,9 +1868,16 @@ export async function elementsToMap( } if (isRawAppFile(path)) { - const suffix = path.split(getFolderSuffix("raw_app") + SEP).pop(); + // FSFSElement builds paths with the platform separator, while the checks + // below are written with "/": without normalizing, none of them match on + // Windows and the push collector's own exclusions become perpetual diffs. + const suffix = path + .split(getFolderSuffix("raw_app") + SEP) + .pop() + ?.replaceAll(SEP, "/"); if ( suffix?.startsWith("dist/") || + suffix?.startsWith(RECORDINGS_FOLDER + "/") || suffix == "wmill.d.ts" || suffix == "package-lock.json" || suffix == "DATATABLES.md" diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 397e8a88d7..5a2b599ea8 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6734,6 +6734,7 @@ app related commands - \`--host \` - Host to bind the dev server to - \`--entry \` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) - \`--no-open\` - Don't automatically open the browser + - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability - \`--fix\` - Attempt to fix common issues (not implemented yet) - \`app new\` - create a new raw app from a template diff --git a/cli/test/dev_recorder_bundle_unit.test.ts b/cli/test/dev_recorder_bundle_unit.test.ts new file mode 100644 index 0000000000..2c7a35f696 --- /dev/null +++ b/cli/test/dev_recorder_bundle_unit.test.ts @@ -0,0 +1,42 @@ +/** + * The recorder `wmill app dev --recording` serves is generated from the + * frontend's raw-app recorder, not written here, so it can silently ship a stale + * event model after that recorder changes. The committed bundle records the + * sources it was built from and their hash; this fails when they no longer + * agree. + * + * Fix a failure with `bun run gen:dev-recorder` from cli/. + */ + +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { hashRecorderSources } from "../generate-dev-recorder.ts"; +import { + DEV_RECORDER_BUNDLE, + DEV_RECORDER_SOURCE_HASH, + DEV_RECORDER_SOURCES, +} from "../src/commands/app/devRecorderBundle.gen.ts"; + +const REPO_ROOT = path.join(import.meta.dir, "..", ".."); + +describe("dev recorder bundle", () => { + test("exposes the recorder factory as a global", () => { + expect(DEV_RECORDER_BUNDLE).toContain("__wmillRecorder"); + expect(DEV_RECORDER_BUNDLE).toContain("createRawAppRecording"); + // Runes are stripped at generation; one left in would throw at load time. + expect(DEV_RECORDER_BUNDLE).not.toContain("$state"); + }); + + test("is up to date with the frontend recorder", () => { + expect(DEV_RECORDER_SOURCES.length).toBeGreaterThan(0); + const present = DEV_RECORDER_SOURCES.every((f) => + fs.existsSync(path.join(REPO_ROOT, f)) + ); + // The published CLI package ships without the frontend sources. + if (!present) return; + expect(hashRecorderSources(DEV_RECORDER_SOURCES, REPO_ROOT)).toBe( + DEV_RECORDER_SOURCE_HASH, + ); + }); +}); diff --git a/cli/test/dev_recorder_routes_unit.test.ts b/cli/test/dev_recorder_routes_unit.test.ts new file mode 100644 index 0000000000..29ad71c983 --- /dev/null +++ b/cli/test/dev_recorder_routes_unit.test.ts @@ -0,0 +1,43 @@ +/** + * Guards on the routes `wmill app dev --recording` adds: what may write a + * recording, what a recording may be named, and that two saves never collide. + */ + +import { expect, test } from "bun:test"; +import { + isOwnOrigin, + isRecordingFileName, + recordingFileName, +} from "../src/commands/app/devRecorder.ts"; + +test("only the shell's own origin may save a recording", () => { + expect(isOwnOrigin("http://localhost:4000", "localhost:4000")).toBe(true); + expect(isOwnOrigin("http://127.0.0.1:4000", "127.0.0.1:4000")).toBe(true); + // A cross-site POST carrying JSON under a simple content type needs no + // preflight, so a foreign origin sharing the port must still be refused. + expect(isOwnOrigin("http://attacker.example:4000", "localhost:4000")).toBe( + false, + ); + expect(isOwnOrigin("null", "localhost:4000")).toBe(false); + // No Origin at all is a non-browser client, not a cross-site page. + expect(isOwnOrigin(undefined, "localhost:4000")).toBe(true); +}); + +test("recording names stay inside the recordings folder", () => { + expect(isRecordingFileName("recording-2026-01-01-00-00-00-000.json")).toBe( + true, + ); + expect(isRecordingFileName("../../../etc/passwd")).toBe(false); + expect(isRecordingFileName("..%2Fx.json")).toBe(false); + expect(isRecordingFileName("sub/dir.json")).toBe(false); + expect(isRecordingFileName("recording.txt")).toBe(false); +}); + +test("two saves in the same millisecond get distinct names", () => { + const now = new Date("2026-01-01T00:00:00.123Z"); + const first = recordingFileName(now, 0); + const second = recordingFileName(now, 1); + expect(first).toBe("recording-2026-01-01-00-00-00-123.json"); + expect(second).not.toBe(first); + expect(isRecordingFileName(second)).toBe(true); +}); diff --git a/cli/test/raw_app_recordings_skip_unit.test.ts b/cli/test/raw_app_recordings_skip_unit.test.ts new file mode 100644 index 0000000000..408671cfe3 --- /dev/null +++ b/cli/test/raw_app_recordings_skip_unit.test.ts @@ -0,0 +1,63 @@ +/** + * `wmill app dev --recording` writes multi-MB session recordings into + * `.raw_app/recordings/`. They are local artifacts: the sync differ must + * not offer them as app source (the push itself drops them in + * `collectAppFiles`, so a differ that still sees them reports a change that + * pushing can never settle). + */ + +import { expect, test } from "bun:test"; +import { sep as SEP } from "node:path"; +import { elementsToMap } from "../src/commands/sync/sync.ts"; + +type MockFile = { path: string; content: string }; + +// FSFSElement joins with the platform separator, and the exclusion has to hold +// on Windows too. +const p = (...parts: string[]) => parts.join(SEP); + +function mockElement(files: MockFile[]) { + return { + isDirectory: true, + path: "", + async getContentText() { + return ""; + }, + async *getChildren() { + for (const file of files) { + yield { + isDirectory: false, + path: file.path, + async getContentText() { + return file.content; + }, + async *getChildren() {}, + }; + } + }, + }; +} + +test("elementsToMap skips recordings/ at the root of a raw app folder only", async () => { + const app = p("f", "demo", "myapp.raw_app"); + const files: MockFile[] = [ + { path: p(app, "index.tsx"), content: "export {}" }, + { + path: p(app, "recordings", "recording-2026-01-01-00-00-00.json"), + content: '{"version":1}', + }, + // The dev server never writes here, so this is the app's own source. + { path: p(app, "src", "recordings", "fixture.json"), content: "{}" }, + ]; + + const result = await elementsToMap( + mockElement(files) as any, + () => false, + false, + {}, + ); + + expect(Object.keys(result).sort()).toEqual( + [p(app, "index.tsx"), p(app, "src", "recordings", "fixture.json")].sort(), + ); +}); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 8d04a720db..38d0974f75 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -32,6 +32,7 @@ app related commands - `--host ` - Host to bind the dev server to - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) - `--no-open` - Don't automatically open the browser + - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability - `--fix` - Attempt to fix common issues (not implemented yet) - `app new` - create a new raw app from a template diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index c10259ff89..173a5b50d0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2932,6 +2932,7 @@ app related commands - \`--host \` - Host to bind the dev server to - \`--entry \` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) - \`--no-open\` - Don't automatically open the browser + - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability - \`--fix\` - Attempt to fix common issues (not implemented yet) - \`app new\` - create a new raw app from a template diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index cba3a1da40..3538432694 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -37,6 +37,7 @@ app related commands - `--host ` - Host to bind the dev server to - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) - `--no-open` - Don't automatically open the browser + - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability - `--fix` - Attempt to fix common issues (not implemented yet) - `app new` - create a new raw app from a template