diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2270c..64b7458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added public surface and docs coverage for provider-scoped Pinterest URL recovery with explicit `--url` inputs. + +### Changed +- Inspiredesign CLI completion now reports workflow readiness when available. +- Omitted screenshot calls now persist `.opendevbrowser/screenshot//capture.png` and return `path` plus `artifact_path` instead of base64 by default. +- Omitted screencast calls now persist replay artifacts under `.opendevbrowser/screencast/` while preserving explicit caller paths. + +### Fixed +- Documented `ranked-references.json.rejectedReferences` diagnostics for captured-but-rejected `interface_chrome_shell` evidence without weakening reference promotion rules. + ## [0.0.33] - 2026-05-21 ### Added diff --git a/docs/CLI.md b/docs/CLI.md index d4453de..92d5185 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -540,13 +540,14 @@ npx opendevbrowser inspiredesign run --brief "Synthesize a premium docs landing npx opendevbrowser inspiredesign run --brief "Extract a reusable dashboard design contract from live references" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign npx opendevbrowser inspiredesign harvest --brief "Synthesize a premium docs workspace" --query "best docs product landing pages" --provider web/default --max-references 5 --visual-evidence required --browser-mode managed --mode json npx opendevbrowser inspiredesign harvest --brief "Premium digital photography studio landing page" --query "Pinterest premium digital photography studio landing page cinematic parallax portfolio" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json +npx opendevbrowser inspiredesign harvest --brief "Fashion design studio landing page with atelier motion references" --provider social/pinterest --url "https://www.pinterest.com/pin/27654985208435505/" --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json ``` Flags: - `--brief` (required) - `--url` (repeatable inspiration URL input; explicit URLs are kept before discovered URLs) - `--query` (`harvest` only; discovers bounded public references through provider search when available) -- `--provider` (repeatable `harvest` provider id used with `--query`; `social/pinterest` routes through the browser-native Pinterest site recipe, not a default full social provider) +- `--provider` (repeatable `harvest` provider id used with `--query` or compatible browser-native URL recovery; `social/pinterest` routes through the Pinterest site recipe, not a default full social provider) - `--max-references` (`1` to `10`; `harvest` defaults to `5`) - `--visual-evidence` (`off|auto|required`; `run` defaults to `off`, `harvest` defaults to `required`) - `--capture-mode` (`off|deep`; `off` is ignored when any `--url` is provided) @@ -568,14 +569,16 @@ Notes: - Any `--url` forces deep capture so inspiredesign can collect DOM/layout evidence. Without URLs, `--capture-mode` defaults to `off`. - Repeat `--url` for multiple inspiration sources. There is no `--urls` alias. - `harvest` merges explicit URLs before discovered URLs, trims and de-duplicates references, and stores rejected reference diagnostics in generated metadata. -- `social/pinterest` is a browser-native site recipe. Use it with extension mode, `--use-cookies`, and `--cookie-policy required` when the brief needs logged-in Pinterest search. The workflow must recover session evidence first when Pinterest returns login, challenge, empty-grid, or search-shell states. +- `social/pinterest` is a browser-native site recipe. Use it with extension mode, `--use-cookies`, and `--cookie-policy required` when the brief needs logged-in Pinterest search. Compatible Pinterest URL recovery can run as `--provider social/pinterest --url ` without `--query`; generic provider plus URL recovery without a query remains rejected. The workflow must recover session evidence first when Pinterest returns login, challenge, empty-grid, or search-shell states. - Browser-native site recipes do not silently widen scope to unrelated providers. If fallback to broad web sources is desired, ask the user or rerun with an explicit `--provider web/default`. - `--include-prototype-guidance` appends prototype structure guidance to the generated design contract output. - Successful runs emit `advanced-brief.md`, `canvas-plan.request.json`, `design-agent-handoff.json`, `visual-evidence.json`, `screenshot-index.json`, `ranked-references.json`, and `meta-prompt.md` alongside the existing design contract and implementation artifacts. Harvest PNG files are written under `visual-evidence//viewport.png`. +- `ranked-references.json.rejectedReferences` serializes captured-but-rejected diagnostics, including `interface_chrome_shell`, without promoting those captures into design-facing references. - Visual JSON is metadata-only: it contains artifact-relative paths, hashes, byte counts, viewport metadata when available, reference id and URL, and warnings. It must not contain base64 screenshots, temp paths, full DOM, or full snapshot text. - Policy boundaries are preserved: visual capture must not bypass `policy_blocked`, unresolved `auth_required`, `challenge_detected`, or `rate_limited` provider outcomes. - Workflow outputs can include typed `nextStepGuidance` with `readiness`, `reasonCode`, `primaryAction`, command examples, `paramsExamples`, `validationChecks`, `fallbackPolicy`, and `doNotProceedIf` blockers. - Treat readiness as the gate between artifact completion and design readiness. Continue to Canvas only when `nextStepGuidance.readiness` is `ready` and no `doNotProceedIf` condition applies. +- CLI completion text includes `readiness=` when `nextStepGuidance.readiness` is present, so wrapper success is not mistaken for design readiness. - For `needs_recovery`, `blocked`, or `diagnostic_only`, follow the primary recovery action first. Common blockers are zero references, empty ranked references, failed required screenshots, provider unavailability, login or challenge screens, and diagnostic-only captures. - The ready follow-through path is explicit: read `advanced-brief.md`, `meta-prompt.md`, `ranked-references.json`, and screenshot metadata first; load `opendevbrowser_skill_load opendevbrowser-best-practices "quick start"`, `opendevbrowser_skill_load opendevbrowser-design-agent "canvas-contract"`, and `opendevbrowser_skill_load opendevbrowser-motion-design "quick start"`; open a Canvas session; fill the session ids in `canvas-plan.request.json`; run `opendevbrowser canvas --command canvas.plan.set --params-file ./canvas-plan.request.json`; confirm `planStatus=accepted`; then patch only the governance blocks called out by `design-agent-handoff.json`. - `--browser-mode` applies to provider-backed reference retrieval. Deep capture still uses the browser manager capture lane. @@ -1358,6 +1361,8 @@ npx opendevbrowser screenshot --session-id --path ./capture.png --t Notes: - `--ref` and `--full-page` are mutually exclusive. - `--timeout-ms` sets client-side daemon timeout for screenshot capture. +- When `--path` is omitted, screenshots save to `.opendevbrowser/screenshot//capture.png` and JSON output includes `path` plus `artifact_path`. +- Explicit `--path` remains caller-controlled and does not create the omitted-output screenshot artifact directory. - Default visible capture may still report the existing viewport-only fallback warning when extension capture has to degrade, but ref and full-page requests do not silently reuse that fallback. ### Screencast start @@ -1375,6 +1380,8 @@ npx opendevbrowser screencast-start \ Notes: - `screencast-start` is a manager-owned browser replay lane layered on the existing screenshot primitive. - The recorder writes `replay.json`, `replay.html`, `frames/`, and `preview.png` into the chosen output directory. +- When `--output-dir` is omitted, screencasts save those replay files under `.opendevbrowser/screencast/` and JSON output includes `artifact_path`. +- Explicit `--output-dir` remains caller-controlled and keeps replay files inside that directory. - `--interval-ms` defaults to `1000` and must be at least `250`. - `--max-frames` defaults to `300`. diff --git a/docs/RELEASE_0.0.30_EVIDENCE.md b/docs/RELEASE_0.0.30_EVIDENCE.md index 7ff1088..c8c0cef 100644 --- a/docs/RELEASE_0.0.30_EVIDENCE.md +++ b/docs/RELEASE_0.0.30_EVIDENCE.md @@ -26,7 +26,8 @@ Historical status note: - Omitted generated workflow output roots now resolve to `/.opendevbrowser//` for CLI invocations. - OpenCode direct tools and raw daemon RPCs now resolve omitted workflow output roots from the project workspace root instead of transient process temp directories. - Explicit `outputDir` values remain preserved across CLI, direct tools, daemon RPC, and provider workflow calls. -- Low-level non-workflow artifact fallback and screencast replay defaults remain unchanged. +- Low-level non-workflow artifact fallback and screencast replay defaults remain unchanged for `0.0.30`. + - Historical note: later browser evidence output behavior supersedes this exception for omitted screenshot and screencast outputs. ## Version Alignment diff --git a/docs/SURFACE_REFERENCE.md b/docs/SURFACE_REFERENCE.md index d47a6dd..c55d2df 100644 --- a/docs/SURFACE_REFERENCE.md +++ b/docs/SURFACE_REFERENCE.md @@ -101,6 +101,10 @@ First-contact note: - `screencast-start` - Start a browser replay capture that samples the existing screenshot lane. - `screencast-stop` - Finalize and retrieve a browser replay capture by session and screencast id. +Browser capture behavior: +- Omitted screenshot output saves `.opendevbrowser/screenshot//capture.png` and returns `path` plus `artifact_path`; explicit `--path` remains caller-controlled. +- Omitted screencast output saves replay files under `.opendevbrowser/screencast/` and returns `artifact_path`; explicit `--output-dir` remains caller-controlled. + ### Desktop observation (6) - `desktop-status` - Inspect sibling desktop observation availability. - `desktop-windows` - List observable desktop windows. @@ -184,8 +188,8 @@ Operational note: - `opendevbrowser_is_checked` - Check ref checked state. ### Browser capture (3) -- `opendevbrowser_screenshot` - Capture a page screenshot. -- `opendevbrowser_screencast_start` - Start a browser replay screencast capture. +- `opendevbrowser_screenshot` - Capture a page screenshot and persist omitted outputs under `.opendevbrowser/screenshot//capture.png`. +- `opendevbrowser_screencast_start` - Start a browser replay screencast capture and persist omitted outputs under `.opendevbrowser/screencast/`. - `opendevbrowser_screencast_stop` - Stop a browser replay screencast capture and return artifact metadata. ### Desktop observation (6) @@ -549,11 +553,13 @@ Auth and policy: - Workflow and macro execute cookie options: `research run`, `shopping run`, `product-video run`, `inspiredesign run`, `inspiredesign harvest`, and `macro-resolve --execute` accept `--use-cookies` and `--cookie-policy-override off|auto|required` (`--cookie-policy` alias) so provider macros can require observable cookie-backed browser sessions. - Workflow and macro execute override flags: `research run`, `shopping run`, `product-video run`, `inspiredesign run`, `inspiredesign harvest`, and `macro-resolve --execute` accept `--challenge-automation-mode off|browser|browser_with_helper`, which maps to `challengeAutomationMode` with `run > session > config` precedence. - Inspiredesign harvest flags: `--query`, repeatable `--provider`, `--max-references 1..10`, and `--visual-evidence off|auto|required`. Harvest requires `--query` or at least one `--url`, keeps the daemon method as `inspiredesign.run`, defaults to `mode=path`, `visualEvidence=required`, and `maxReferences=5`, and keeps explicit `--url` references before discovered references. -- Inspiredesign harvest supports browser-native site recipes for visually driven sites. `--provider social/pinterest` selects the Pinterest recipe and should be run with extension mode, cookies, and `--cookie-policy required` when logged-in search is required. Pinterest is not registered as a default full social provider. +- Inspiredesign harvest supports browser-native site recipes for visually driven sites. `--provider social/pinterest` selects the Pinterest recipe and should be run with extension mode, cookies, and `--cookie-policy required` when logged-in search is required. Compatible Pinterest URL recovery can run as `--provider social/pinterest --url ` without `--query`; generic provider plus URL recovery without query remains rejected. Pinterest is not registered as a default full social provider. - Inspiredesign harvest artifacts: `visual-evidence.json`, `screenshot-index.json`, `ranked-references.json`, and `meta-prompt.md` are emitted with screenshot PNGs under `visual-evidence//viewport.png`. JSON remains metadata-only with artifact-relative paths, hashes, byte counts, viewport metadata when available, reference id and URL, and warnings. +- `ranked-references.json.rejectedReferences` serializes captured-but-rejected diagnostics, including `interface_chrome_shell`, without promoting those captures into design-facing references. - Inspiredesign visual policy boundaries: visual capture must not bypass `policy_blocked`, unresolved `auth_required`, `challenge_detected`, or `rate_limited`; blocked references surface diagnostics instead of browser screenshot fallback. -- Workflow response keys: artifact-bearing workflow success payloads use `artifact_path`; provider follow-up summaries use `meta.primaryConstraintSummary`; typed recovery and handoff payloads use `nextStepGuidance.readiness`, `reasonCode`, `primaryAction`, `paramsExamples`, `validationChecks`, `fallbackPolicy`, and `doNotProceedIf` when available. +- Workflow response keys: artifact-bearing workflow success payloads use `artifact_path`; provider follow-up summaries use `meta.primaryConstraintSummary`; typed recovery and handoff payloads use `nextStepGuidance.readiness`, `reasonCode`, `primaryAction`, `paramsExamples`, `validationChecks`, `fallbackPolicy`, and `doNotProceedIf` when available. The inspiredesign CLI completion message includes `readiness=` when available so wrapper success is not confused with design readiness. - Continue to Canvas only when `nextStepGuidance.readiness` is `ready`. For `needs_recovery`, `blocked`, or `diagnostic_only`, follow recovery-first guidance and do not treat emitted artifacts as design-ready. +- Browser evidence omitted outputs use workspace-local artifact roots: screenshots write `.opendevbrowser/screenshot//capture.png` with `path` and `artifact_path`, and screencasts write `.opendevbrowser/screencast/` with replay files. Explicit `--path` and `--output-dir` remain caller-controlled. - Research and shopping guidance uses `meta.primaryConstraint.guidance.reason` plus `meta.primaryConstraint.guidance.recommendedNextCommands[]` when provider recovery steps are known. Migrated workflow paths can include `nextStepGuidance` alongside those compatibility fields. - Failure tallies use `meta.metrics.reasonCodeDistribution` for research/shopping and `meta.reasonCodeDistribution` for product-video. diff --git a/docs/investigations/inspiredesign-harvest-command-issues-2026-05-20.md b/docs/investigations/inspiredesign-harvest-command-issues-2026-05-20.md new file mode 100644 index 0000000..5d7760a --- /dev/null +++ b/docs/investigations/inspiredesign-harvest-command-issues-2026-05-20.md @@ -0,0 +1,102 @@ +# Inspiredesign Harvest Command Issues Investigation + +Date: 2026-05-20 + +## Summary + +Investigating problems observed while testing `inspiredesign harvest` with Pinterest for a fashion design studio landing-page prototype. + +## Symptoms + +- Query harvest returned `success:true` but `nextStepGuidance.readiness=diagnostic_only`. +- Query harvest used `social/pinterest`, discovered five Pinterest pin URLs, captured two viewport screenshots, failed three required visual captures, and produced zero ranked references. +- Explicit URL recovery using the two captured Pinterest pin URLs also returned `success:true`, captured two screenshots, and still produced zero ranked references. +- `--provider social/pinterest` with only `--url` and no `--query` failed with `--provider requires --query`. +- `ranked-references.json` reported `diagnosticOnlyReasons: ["interface_chrome_shell"]` and no ready references. +- Relative screenshot and screencast output paths from later prototype validation resolved under `~/.cache/opendevbrowser`, not the repo working directory. +- Generated guidance says to recover Pinterest evidence in an authenticated browser session, but the recovery command repeated the same blocked shape. + +## Background / Prior Research + +- Prior memory says Pinterest harvest is intentionally implemented as `inspiredesign harvest`, not a separate workflow. +- Prior memory says Pinterest should use browser-native logged-in navigation, extension mode, cookies, and the Pinterest search bar. +- Prior memory says motion references are a first-class requirement for this workflow family. + +## Hypotheses + +- H1: CLI validation has an avoidable command-shape mismatch for URL-only recovery with provider context. +- H2: Pinterest evidence classification is too strict or too coarse, causing captured pin screenshots to be rejected as `interface_chrome_shell`. +- H3: The workflow conflates command success with readiness, making non-actionable harvests look successful unless callers inspect `nextStepGuidance`. +- H4: Recovery guidance reruns the same query harvest without changing enough state to recover from diagnostic-only Pinterest results. +- H5: Relative output path resolution is inconsistent with user expectations for workspace-local artifacts. + +## Investigator Findings + +### Root causes + +1. `--provider social/pinterest --url ...` without `--query` fails before the workflow can use the explicit URLs. The CLI rejects any provider when `parsed.query` is absent, then separately allows harvest with query or URL, so provider-aware URL recovery is blocked by ordering and policy, not by URL support itself. Evidence: `src/cli/commands/inspiredesign.ts:265-270`. The daemon workflow repeats the same restriction, so direct RPC would still reject the shape even if the CLI were relaxed. Evidence: `src/providers/workflows.ts:1706-1717`. Existing tests lock in this behavior for providers without query. Evidence: `tests/cli-workflows.test.ts:580-587`. + +2. The validation rule conflicts with Pinterest recovery guidance. The Pinterest recipe explicitly lists an `explicit-url` recovery step for blocked search. Evidence: `src/guidance/recipes/pinterest.ts:164-167`. Generic recovery examples also tell users to use explicit reference URLs when provider discovery is blocked. Evidence: `src/guidance/recipes/generic.ts:410-423`. But the primary recovery command builder always emits `--query ... --provider ...`, never URL-first recovery. Evidence: `src/guidance/recipes/generic.ts:62-73`. + +3. Captured Pinterest PNGs can still rank zero references by design when the textual evidence is interface chrome. The run artifacts show captured screenshots for two query-harvest pins and all two explicit-URL pins, yet both ranked outputs report `rankedReferenceCount: 0` and `diagnosticOnlyReasons: ["interface_chrome_shell"]`. Evidence: `.opendevbrowser/inspiredesign/9716bed8-cb7a-4970-bb4d-e54f713263cb/visual-evidence.json:131-149`, `.opendevbrowser/inspiredesign/9716bed8-cb7a-4970-bb4d-e54f713263cb/ranked-references.json:2-14`, `.opendevbrowser/inspiredesign/095ae735-864c-45c2-8096-f50a57e78bf6/visual-evidence.json:90-143`, `.opendevbrowser/inspiredesign/095ae735-864c-45c2-8096-f50a57e78bf6/ranked-references.json:2-14`. + +4. The classifier rejects interface chrome before ranking unless the Pinterest-specific exception applies. `diagnosticPageReasons()` emits `interface_chrome_shell` from chrome markers, `hasPinterestVisualMetadataEvidence()` only allows captured Pinterest visuals when clean metadata exists and diagnostics are only soft Pinterest chrome, and `hasInspiredesignUsableReferenceEvidence()` returns false for blocking diagnostic reasons. Evidence: `src/inspiredesign/reference-pattern-board.ts:274-321`, `src/inspiredesign/reference-pattern-board.ts:424-447`, `src/inspiredesign/reference-pattern-board.ts:464-477`. Tests confirm both sides: chrome-only Pinterest screenshots are rejected, while clean screenshot-backed Pinterest metadata can remain usable. Evidence: `tests/providers-inspiredesign-contract.test.ts:1209-1254`, `tests/providers-inspiredesign-contract.test.ts:1256-1294`, `tests/providers-inspiredesign-contract.test.ts:1360-1378`. + +5. `diagnostic_only` readiness is an intentional gate, not a capture failure by itself. The guidance source forwards ranked counts and diagnostic-only reasons into the readiness context. Evidence: `src/providers/workflows.ts:2897-2934`, `src/guidance/context.ts:119-126`, `src/guidance/context.ts:149-177`. Readiness becomes `diagnostic_only` when diagnostic reasons exist and ranked count is zero. Evidence: `src/guidance/readiness.ts:12-16`, `src/guidance/readiness.ts:48-57`. Workflow tests reproduce Pinterest accepted URLs plus zero ranked references and require `readiness: "diagnostic_only"`. Evidence: `tests/providers-inspiredesign-workflow.test.ts:2160-2186`. + +6. `success:true` is a wrapper-level command contract, not design readiness. After `callDaemon("inspiredesign.run", ...)` returns, the CLI always wraps the daemon response as `{ success: true, message, data }`. Evidence: `src/cli/commands/inspiredesign.ts:276-293`. Actual readiness is nested in `data.meta.nextStepGuidance`, which the workflow constructs after ranking and guidance routing. Evidence: `src/providers/workflows.ts:4401-4420`. Current docs already describe readiness as the gate between artifact completion and design readiness. Evidence: `docs/CLI.md:574-577`, `docs/SURFACE_REFERENCE.md:555-556`. + +7. Repeated failed recovery shape is caused by generic command generation, not by the Pinterest recipe registry. Pinterest is correctly registered as a site recipe for provider IDs and URLs. Evidence: `src/guidance/recipes/site-registry.ts:14-37`. The high-priority Pinterest guidance recipe routes non-ready Pinterest contexts to browser-native recovery. Evidence: `src/guidance/recipes/generic.ts:558-572`. But it still calls the generic recovery builder, whose only command is `inspiredesignHarvestCommand(context)`, so the emitted command repeats query/provider search rather than explicit URLs. Evidence: `src/guidance/recipes/generic.ts:398-409`, `src/guidance/recipes/generic.ts:62-73`. + +8. Relative screenshot and replay output behavior is separate from harvest output. Harvest workflow output resolves relative `--output-dir` at the CLI using `path.resolve(value)`, with `.opendevbrowser` as the default. Evidence: `src/cli/commands/workflow-output.ts:1-13`, `src/providers/workflow-output-root.ts:3-17`. Browser screenshots pass `--path` through unchanged to the daemon and browser manager. Evidence: `src/cli/commands/devtools/screenshot.ts:36-49`, `src/browser/browser-manager.ts:2012-2057`, `src/browser/ops-browser-manager.ts:911-913`. Screencast output resolves relative `--output-dir` against the browser manager `worktree`, not the caller cwd. Evidence: `src/cli/commands/devtools/screencast-start.ts:39-52`, `src/browser/browser-manager.ts:445-463`, `src/browser/browser-manager.ts:2071-2079`, `src/browser/screencast-recorder.ts:104-116`. + +### Eliminated hypotheses + +- Pinterest discovery is not accidentally using a standard social provider. `social/pinterest` resolves to the Pinterest site recipe and runs browser-native search through `runBrowserNativeDiscovery()`. Evidence: `src/providers/workflows.ts:1989-2027`, `src/guidance/recipes/pinterest.ts:143-174`. +- Zero ranked references do not mean screenshots were absent. The saved artifacts contain screenshot entries while ranked outputs still show `interface_chrome_shell`. Evidence: `.opendevbrowser/inspiredesign/9716bed8-cb7a-4970-bb4d-e54f713263cb/screenshot-index.json:1-22`, `.opendevbrowser/inspiredesign/095ae735-864c-45c2-8096-f50a57e78bf6/evidence.json:292-344`. +- `success:true` is not currently a command-level crash bug. It is misleading only if callers treat wrapper success as readiness. Evidence: `src/cli/commands/inspiredesign.ts:289-293`, `docs/CLI.md:574-577`. +- Relative output path surprises are not harvest-specific. Harvest workflow paths and screenshot/screencast browser paths use different resolution layers. Evidence: `src/cli/commands/workflow-output.ts:5-13`, `src/browser/screencast-recorder.ts:104-116`. + +### Recommended fixes + +1. Relax provider-without-query validation only for explicit URL recovery when all requested providers resolve to site recipes compatible with the supplied URLs. Update both `src/cli/commands/inspiredesign.ts:265-270` and `src/providers/workflows.ts:1706-1717`. Add tests replacing or narrowing `tests/cli-workflows.test.ts:580-587`. + +2. Make Pinterest recovery command generation URL-aware. In `src/guidance/recipes/generic.ts:62-73` and `src/guidance/recipes/generic.ts:398-423`, prefer repeated `--url` commands when context has Pinterest URLs, accepted URL diagnostics, or explicit URL recovery guidance. Only include `--provider social/pinterest` after validation supports provider-scoped URL recovery. + +3. Keep strict `interface_chrome_shell` blocking, but improve diagnostics. `ranked-references.json` or guidance should explain that screenshots were captured but rejected because only interface chrome survived classification. Relevant source: `src/inspiredesign/reference-pattern-board.ts:274-321`, `src/inspiredesign/reference-pattern-board.ts:464-477`, `src/inspiredesign/contract.ts:2096-2134`. + +4. Surface readiness beside wrapper success for CLI users. Keep `success:true` as transport completion if needed, but add top-level `readiness` or improve `message` in `src/cli/commands/inspiredesign.ts:289-293` using `data.meta.nextStepGuidance.readiness` from `src/providers/workflows.ts:4401-4420`. + +5. Normalize or document browser output paths separately from workflow paths. For best CLI UX, resolve screenshot `--path` and screencast `--output-dir` in `src/cli/commands/devtools/screenshot.ts:36-49` and `src/cli/commands/devtools/screencast-start.ts:39-52` before daemon dispatch. If preserving current behavior, document that screencast relative output binds to browser manager `worktree`, unlike workflow `--output-dir`. + +## Root Cause Analysis + +The core problem is not one failure. It is a contract mismatch between three layers: + +- The harvest command supports URL-only runs, but provider-scoped URL recovery is rejected by both CLI and workflow validation. +- Pinterest recovery guidance tells users to recover in a browser-native authenticated session and mentions explicit URLs, but the generated primary command repeats query/provider discovery. +- The workflow correctly refuses Canvas/design continuation when ranking sees only diagnostic Pinterest chrome, but the top-level command still reports wrapper success. + +That means the tool is safe, but not clear enough. It avoids turning bad Pinterest captures into design input, yet its command examples and success envelope make the recovery path harder to follow. + +## Final Recommended Fix Order + +1. Fix validation for site-recipe URL recovery in `src/cli/commands/inspiredesign.ts` and `src/providers/workflows.ts`. +2. Make Pinterest recovery command generation URL-aware in `src/guidance/recipes/generic.ts`. +3. Add top-level readiness or message wording in `src/cli/commands/inspiredesign.ts` so `success:true` cannot be mistaken for design readiness. +4. Improve ranked-reference diagnostics so captured-but-rejected screenshots explain the exact evidence gap. +5. Normalize or document screenshot and screencast relative output path behavior separately from workflow artifact output. + +## Evidence Appendix + +Primary local run artifacts: + +- `.opendevbrowser/tool-evaluation/fashion-studio-motion/harvest-result.json` +- `.opendevbrowser/tool-evaluation/fashion-studio-motion/harvest-recovery-result.json` +- `.opendevbrowser/inspiredesign/9716bed8-cb7a-4970-bb4d-e54f713263cb` +- `.opendevbrowser/inspiredesign/095ae735-864c-45c2-8096-f50a57e78bf6` + +RepoPrompt investigation artifacts: + +- `prompt-exports/oracle-question-2026-05-20-232424-harvest-issues-b2c9f-dd92.md` +- Pair investigator session: `24E46849-D017-4F78-B674-0976DD465DBA` diff --git a/docs/plans/inspiredesign-harvest-recovery-and-browser-output-artifacts-2026-05-21.md b/docs/plans/inspiredesign-harvest-recovery-and-browser-output-artifacts-2026-05-21.md new file mode 100644 index 0000000..607fdad --- /dev/null +++ b/docs/plans/inspiredesign-harvest-recovery-and-browser-output-artifacts-2026-05-21.md @@ -0,0 +1,548 @@ +# InspireDesign Harvest Recovery and Browser Output Artifacts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `inspiredesign harvest` so Pinterest explicit URL recovery is valid, generated recovery guidance is executable, readiness is visible, diagnostic screenshot rejection is understandable, and omitted screenshot and screencast outputs save under workflow-style artifact roots. + +**Architecture:** Keep the strict design-readiness gate and `interface_chrome_shell` rejection intact. Repair the command contract around it by sharing site-recipe URL compatibility validation, making Pinterest guidance URL-aware, and adding a browser artifact helper that writes omitted browser evidence to `.opendevbrowser//` while preserving explicit caller paths. + +**Tech Stack:** TypeScript, Node.js, Vitest, OpenDevBrowser daemon commands, provider workflows, guidance recipes, browser managers, Playwright or extension-backed screenshots, generated public-surface metadata. + +--- + +## Background + +The source investigation is `docs/investigations/inspiredesign-harvest-command-issues-2026-05-20.md`. + +- `src/cli/commands/inspiredesign.ts:252-270` rejects `--provider` without `--query`, then separately allows harvest when either `--query` or `--url` exists. This blocks `--provider social/pinterest --url ...` before daemon dispatch. +- `src/providers/workflows.ts:1700-1717` repeats the same provider-without-query rejection in workflow normalization, so direct daemon and tool calls are also blocked. +- `src/guidance/recipes/pinterest.ts:146-176` registers Pinterest as an authenticated-preferred site recipe and explicitly lists URL recovery. +- `src/guidance/recipes/generic.ts:62-76`, `src/guidance/recipes/generic.ts:398-423`, and `src/guidance/recipes/generic.ts:558-572` route Pinterest recovery through a generic command builder that repeats query/provider search instead of URL-first recovery. +- `src/inspiredesign/reference-pattern-board.ts:274-321` emits `interface_chrome_shell`; `src/inspiredesign/reference-pattern-board.ts:424-477` rejects blocking diagnostic evidence unless the strict Pinterest metadata exception applies. +- `tests/providers-inspiredesign-contract.test.ts:1210-1294` requires chrome-only Pinterest captures to remain rejected while clean screenshot-backed Pinterest metadata can be usable. +- `tests/providers-inspiredesign-workflow.test.ts:2162-2186` requires accepted Pinterest URLs plus zero ranked references to produce `readiness === "diagnostic_only"` and block Canvas continuation. +- `src/providers/workflow-output-root.ts:3-18` defaults workflow artifacts to `.opendevbrowser`; `src/providers/artifacts.ts:44-60` writes `//`. +- `src/browser/browser-manager.ts:1999-2059` and `src/browser/ops-browser-manager.ts:891-918` return base64 when screenshot `path` is omitted. +- `src/browser/screencast-recorder.ts:104-118` writes omitted screencasts under `.opendevbrowser/replays/screencasts//`, not `.opendevbrowser/screencast/`. + +## Recommended Decisions + +- Use `.opendevbrowser/screenshot//capture.png` for omitted screenshot output. +- Use `.opendevbrowser/screencast//` for omitted screencast output, with existing replay files inside that directory. +- Treat `screencast` as the final namespace, not `browser-replay`, because the user named screenshot and screencast and the public CLI command is `screencast-start`. +- Preserve explicit `--path` and `--output-dir` behavior as caller-controlled paths. +- Apply omitted-output artifact behavior consistently across CLI, daemon, and tool calls because all route through the same managers. +- For omitted screenshots, switch all surfaces from base64-only output to persisted file output with `path` and `artifact_path`. Do not keep base64 additively unless a future explicit in-memory screenshot mode is requested. +- Serialize captured-but-rejected diagnostics in `ranked-references.json` under `rejectedReferences`, since that file already contains `qualitySummary`, ready `references`, rejected references, and synthesis. +- Do not weaken `interface_chrome_shell`; make diagnostics clearer instead. + +## Dependency Map + +```text +Task 1 browser artifact helper + -> Task 2 omitted screenshot artifacts + -> Task 3 omitted screencast artifacts + -> Task 11 public surface and docs + +Task 4 site-recipe URL validation helper + -> Task 5 CLI validation + -> Task 6 workflow normalization + -> Task 7 URL-aware Pinterest guidance + +Task 8 readiness messaging +Task 9 captured-but-rejected diagnostics + -> Task 10 focused tests + -> Task 11 docs and generated surface +``` + +## Workstream Order + +Keep this as one plan, but implement in two atomic workstreams to reduce review risk. + +1. Harvest recovery workstream: Tasks 4, 5, 6, 7, 8, 9, and their focused tests. +2. Browser output artifact workstream: Tasks 1, 2, 3, and their focused tests. +3. Shared closeout workstream: Tasks 10, 11, and 12 after both behavior lanes are implemented. + +## Task Dependency Sequence + +1. Browser artifact helper. +2. Screenshot omitted output. +3. Screencast omitted output. +4. Site-recipe URL compatibility helper. +5. CLI harvest validation. +6. Workflow normalization. +7. Pinterest recovery guidance. +8. Readiness surfacing. +9. Captured-but-rejected diagnostics. +10. Focused tests. +11. Public surface, docs, generated manifest, changelog. +12. Targeted and full verification. + +## Task 1 - Add Browser Output Artifact Helper + +Reasoning: Screenshot and screencast omitted outputs need one workflow-style root helper instead of duplicating path construction in managers. + +What to do: Add a small helper that creates omitted browser artifact directories under `.opendevbrowser//`. + +How: +1. Create `src/providers/browser-output-artifacts.ts`. +2. Export `BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE = "screenshot"` and `BROWSER_SCREENCAST_ARTIFACT_NAMESPACE = "screencast"`. +3. Add `createBrowserOutputArtifactDirectory({ workspaceRoot, namespace })`. +4. Inside the helper, call `resolveWorkflowArtifactRoot(undefined, { workspaceRoot })`. +5. Generate `randomUUID()` and create `//` with `mkdir(..., { recursive: true, mode: 0o700 })`. +6. Return `{ artifactPath, namespace, runId }`. +7. Add a unit test file `tests/browser-output-artifacts.test.ts`. + +Files impacted: +- New: `src/providers/browser-output-artifacts.ts` +- New: `tests/browser-output-artifacts.test.ts` +- Existing: `src/providers/workflow-output-root.ts` only if a shared type export is needed. + +End goal: Browser evidence has the same omitted-root contract as workflows without coupling browser managers to provider workflow bundles. + +Acceptance criteria: +- [ ] Helper returns `.opendevbrowser/screenshot/` for screenshot when no explicit path is provided. +- [ ] Helper returns `.opendevbrowser/screencast/` for screencast when no explicit output directory is provided. +- [ ] Helper rejects blank namespaces. +- [ ] Helper does not process explicit caller paths. + +## Task 2 - Persist Omitted Screenshot Output + +Reasoning: The user requested screenshot output parity, but omitted screenshots currently return base64 and write no artifact. + +What to do: Route omitted screenshot calls through the browser artifact helper and write `capture.png`. + +How: +1. Update `src/browser/browser-manager.ts` screenshot handling. +2. When `options.path` is omitted, call `createBrowserOutputArtifactDirectory({ workspaceRoot: this.worktree, namespace: BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE })`. +3. Set the Playwright or CDP write path to `/capture.png`. +4. Return `path` and `artifact_path` for omitted outputs. +5. Preserve current explicit path behavior when `options.path` is provided. +6. Mirror the same omitted-path logic in `src/browser/ops-browser-manager.ts`. +7. Update screenshot result typing where the result shape is declared. +8. Remove omitted-path base64 expectations from CLI, daemon, tool, manager, and ops tests. Omitted screenshots now persist by default. + +Files impacted: +- `src/browser/browser-manager.ts` +- `src/browser/ops-browser-manager.ts` +- Type definition file if screenshot result shape is declared outside those managers. +- `tests/cli-screenshot.test.ts` +- `tests/browser-manager.test.ts` if manager-level screenshot tests exist or need expansion. + +End goal: `npx opendevbrowser screenshot --session-id ` writes a workspace-local PNG artifact by default. + +Acceptance criteria: +- [ ] Omitted screenshot output writes `.opendevbrowser/screenshot//capture.png`. +- [ ] Omitted screenshot response includes a filesystem `path`. +- [ ] Omitted screenshot response includes `artifact_path` pointing to `.opendevbrowser/screenshot/`. +- [ ] Omitted screenshot response does not return base64 by default on CLI, daemon, manager, ops, or direct tool surfaces. +- [ ] Explicit `--path ./somewhere/capture.png` does not create a screenshot artifact directory. +- [ ] Managed and extension-backed screenshot lanes are consistent. + +## Task 3 - Persist Omitted Screencast Output Under Screencast Namespace + +Reasoning: Omitted screencasts already write files, but the current root is `.opendevbrowser/replays/screencasts//` instead of workflow-style `.opendevbrowser/screencast/`. + +What to do: Use the browser artifact helper for omitted screencast output and keep existing replay file names. + +How: +1. Update `src/browser/screencast-recorder.ts`. +2. Keep explicit `options.outputDir` resolution unchanged, including relative resolution against `worktree`. +3. For omitted `options.outputDir`, call `createBrowserOutputArtifactDirectory({ workspaceRoot: worktree, namespace: BROWSER_SCREENCAST_ARTIFACT_NAMESPACE })`. +4. Set `outputDir` to the returned `artifactPath`. +5. Preserve `frames/`, `replay.json`, `replay.html`, and `preview.png`. +6. Add `artifact_path` to returned screencast metadata if the current result type can be expanded additively. +7. Keep non-empty explicit output directory rejection unchanged. + +Files impacted: +- `src/browser/screencast-recorder.ts` +- `src/browser/browser-manager.ts` +- `src/browser/ops-browser-manager.ts` +- `tests/browser-screencast-recorder.test.ts` +- `tests/browser-manager.test.ts` +- `tests/cli-screencast.test.ts` + +End goal: `screencast-start` without `--output-dir` writes replay artifacts to `.opendevbrowser/screencast/`. + +Acceptance criteria: +- [ ] Omitted screencast output directory matches `.opendevbrowser/screencast/`. +- [ ] Directory contains `replay.json`, `replay.html`, `frames/`, and `preview.png` after completion. +- [ ] Explicit `--output-dir ./artifacts/replay` remains caller-controlled. +- [ ] Existing non-empty output directory protection still works. + +## Task 4 - Add Site-Recipe URL Compatibility Validation + +Reasoning: Provider-scoped URL recovery should be valid only for browser-native site recipes whose providers and URLs match. + +What to do: Add one shared validation helper used by CLI and workflow normalization. + +How: +1. Create `src/guidance/recipes/site-recipe-validation.ts`. +2. Import `resolveSiteRecipeForProvider` and `resolveSiteRecipeForUrl` from `src/guidance/recipes/site-registry.ts`. +3. Add `validateProviderUrlSiteRecipeCompatibility({ providers, urls })`. +4. Require at least one provider and one URL for this helper. +5. Require every provider to resolve to a site recipe. +6. Require every URL to resolve to a site recipe. +7. Require all resolved recipe ids to match. +8. Return a typed result such as `{ ok: true, recipeId }` or `{ ok: false, message }`. +9. Add tests covering Pinterest positive, Pinterest plus non-Pinterest URL negative, generic provider negative, and multiple-provider mismatch negative. + +Files impacted: +- New: `src/guidance/recipes/site-recipe-validation.ts` +- New or existing test: `tests/guidance-site-recipe-validation.test.ts` + +End goal: Provider-without-query validation has a precise shared rule instead of blanket rejection. + +Acceptance criteria: +- [ ] `social/pinterest` plus a Pinterest URL is compatible. +- [ ] `pinterest` plus a Pinterest URL is compatible. +- [ ] `social/pinterest` plus `https://example.com/...` is incompatible. +- [ ] `web/default` plus any URL without query remains incompatible. +- [ ] Blank or missing URL/provider cases produce clear messages. + +## Task 5 - Relax CLI Harvest Validation For Compatible URL Recovery + +Reasoning: CLI validation currently rejects the exact Pinterest recovery command shape before daemon dispatch. + +What to do: Replace blanket provider-without-query rejection in `src/cli/commands/inspiredesign.ts`. + +How: +1. Import `validateProviderUrlSiteRecipeCompatibility`. +2. Keep `run` rejecting `--query`. +3. Keep `harvest` requiring either `--query` or `--url`. +4. If providers exist and query is absent, require URLs and validate provider-URL compatibility. +5. If compatibility fails, throw the helper message. +6. If compatibility succeeds, allow dispatch. +7. Add CLI tests for accepted Pinterest provider plus URL, rejected generic provider plus URL, and rejected provider without query or URL. + +Files impacted: +- `src/cli/commands/inspiredesign.ts` +- `tests/cli-workflows.test.ts` + +End goal: `inspiredesign harvest --provider social/pinterest --url ` is a valid CLI recovery shape. + +Acceptance criteria: +- [ ] Compatible provider plus URL dispatches to `inspiredesign.run`. +- [ ] Provider without query and without URL still rejects. +- [ ] Generic provider plus URL without query still rejects. +- [ ] Existing query/provider harvest behavior remains unchanged. + +## Task 6 - Relax Workflow Normalization With The Same Rule + +Reasoning: Direct tool and daemon callers must follow the same contract as CLI callers. + +What to do: Apply the Task 4 helper inside `normalizeInspiredesignInput()` in `src/providers/workflows.ts`. + +How: +1. Import `validateProviderUrlSiteRecipeCompatibility`. +2. Replace `providers.length > 0 && !query` rejection. +3. If providers exist and query is absent, require URLs and validate compatibility. +4. Preserve missing harvest input rejection. +5. Preserve query-only restrictions for non-harvest modes. +6. Update direct workflow and daemon tests. + +Files impacted: +- `src/providers/workflows.ts` +- `tests/tools-workflows.test.ts` +- `tests/daemon-commands.integration.test.ts` + +End goal: CLI, daemon, and direct workflow surfaces agree on provider-scoped explicit URL recovery. + +Acceptance criteria: +- [ ] Direct workflow accepts Pinterest provider plus Pinterest URL. +- [ ] Direct workflow rejects generic provider plus URL without query. +- [ ] Daemon integration tests cover the same positive and negative paths. +- [ ] Existing missing-input validation remains intact. + +## Task 7 - Make Pinterest Recovery Guidance URL-Aware + +Reasoning: Recovery guidance currently recommends rerunning the same query/provider discovery instead of using accepted explicit URLs. + +What to do: Carry accepted or requested URLs into guidance context and emit URL-first Pinterest recovery commands when available. + +How: +1. Update `src/guidance/context.ts` to preserve relevant URLs from the inspiredesign guidance source. +2. Extend the guidance type in `src/guidance/types.ts` if needed with a narrow URL field. +3. Update `src/providers/workflows.ts:2893-2934` only if the source does not already include enough URL data. +4. Update `src/guidance/recipes/generic.ts` command construction. +5. If context is Pinterest-scoped and URLs exist, emit repeated `--url` flags and `--provider social/pinterest`. +6. Preserve query/provider command generation when no usable URL exists. +7. Preserve Pinterest browser settings: `--browser-mode extension`, `--use-cookies`, `--cookie-policy required`, and `--challenge-automation-mode browser_with_helper`. +8. Add workflow or guidance tests that assert the generated recovery command is executable under the new validation rule. + +Files impacted: +- `src/guidance/context.ts` +- `src/guidance/types.ts` +- `src/providers/workflows.ts` +- `src/guidance/recipes/generic.ts` +- `tests/providers-inspiredesign-workflow.test.ts` +- Additional guidance tests if present. + +End goal: Pinterest diagnostic-only recovery tells users to retry accepted explicit Pinterest URLs instead of repeating the failed discovery shape. + +Acceptance criteria: +- [ ] Diagnostic-only Pinterest guidance includes URL-first recovery when URLs exist. +- [ ] URL-first command includes `--provider social/pinterest`. +- [ ] Guidance falls back to query/provider recovery only when no URLs are available. +- [ ] Canvas continuation remains gated until readiness is `ready`. + +## Task 8 - Surface Readiness Beside Wrapper Success + +Reasoning: `{ success: true }` is transport completion, not design readiness; CLI users need the readiness state surfaced without changing command success semantics. + +What to do: Update inspiredesign CLI completion messaging and, if type-safe, add an additive readiness field. + +How: +1. Update `src/cli/commands/inspiredesign.ts`. +2. Read `data.meta.nextStepGuidance.readiness` after daemon completion. +3. Include `readiness=` in the success message when present. +4. Do not change wrapper `success: true`. +5. Do not treat `diagnostic_only` as a CLI error. +6. Add CLI tests for diagnostic-only and ready message output. + +Files impacted: +- `src/cli/commands/inspiredesign.ts` +- `tests/cli-workflows.test.ts` + +End goal: A successful non-ready harvest cannot be mistaken for design-ready output by reading only the CLI message. + +Acceptance criteria: +- [ ] Diagnostic-only CLI result message includes `readiness=diagnostic_only`. +- [ ] Ready CLI result message includes `readiness=ready`. +- [ ] Existing `nextStepGuidance` payload remains nested under `data.meta`. +- [ ] Existing transport success semantics remain unchanged. + +## Task 9 - Explain Captured-But-Rejected Screenshot Diagnostics + +Reasoning: Strict screenshot rejection is correct, but artifacts should explain why captured screenshots did not become design references. + +What to do: Populate `ranked-references.json.rejectedReferences` with explicit diagnostic metadata for captured visual evidence rejected due to interface chrome. + +How: +1. Inspect current rejected reference serialization in `src/inspiredesign/reference-pattern-board.ts` and `src/inspiredesign/contract.ts`. +2. Ensure `ranked-references.json.rejectedReferences` is populated for diagnostic-only rejected captures instead of staying empty when `rejectedReferenceCount` is greater than zero. +3. Add narrow fields such as `captured: true`, `diagnosticReasons`, and `capturedButRejectedReason`. +4. Include capture status, diagnostic reason, and the evidence gap. +5. Do not include raw screenshot data, full DOM, full snapshot text, or browser chrome titles as design-facing reference content. +6. Update contract and workflow tests. + +Files impacted: +- `src/inspiredesign/reference-pattern-board.ts` +- `src/inspiredesign/contract.ts` +- `tests/providers-inspiredesign-contract.test.ts` +- `tests/providers-inspiredesign-workflow.test.ts` + +End goal: `ranked-references.json.rejectedReferences` makes captured-but-rejected Pinterest screenshots understandable without weakening safety. + +Acceptance criteria: +- [ ] Chrome-only Pinterest screenshots remain rejected. +- [ ] Clean screenshot-backed Pinterest metadata remains usable. +- [ ] `ranked-references.json.rejectedReferences` is non-empty when captured screenshots are rejected and `rejectedReferenceCount` is greater than zero. +- [ ] Rejected diagnostics identify `interface_chrome_shell`. +- [ ] Design-facing artifacts do not promote diagnostic-only captures into reference patterns. + +## Task 10 - Update Focused Tests + +Reasoning: The changes alter validation, guidance, output artifacts, messaging, and docs surfaces; each branch needs regression coverage. + +What to do: Add and adjust focused tests before full quality gates. + +How: +1. Update `tests/browser-output-artifacts.test.ts` for helper roots and namespace validation. +2. Update `tests/cli-screenshot.test.ts` for omitted screenshot artifact output and explicit path preservation. +3. Update `tests/cli-screencast.test.ts` for omitted screencast artifact output and explicit output directory preservation. +4. Update `tests/browser-screencast-recorder.test.ts` for `.opendevbrowser/screencast/` omitted output. +5. Update `tests/cli-workflows.test.ts` for CLI provider plus URL validation and readiness message. +6. Update `tests/tools-workflows.test.ts` for direct workflow provider plus URL validation. +7. Update `tests/daemon-commands.integration.test.ts` for daemon provider plus URL validation and browser omitted output dispatch if daemon tests cover it. +8. Update `tests/providers-inspiredesign-workflow.test.ts` for URL-first guidance. +9. Update `tests/providers-inspiredesign-contract.test.ts` for captured-but-rejected diagnostics. + +Files impacted: +- `tests/browser-output-artifacts.test.ts` +- `tests/cli-screenshot.test.ts` +- `tests/cli-screencast.test.ts` +- `tests/browser-screencast-recorder.test.ts` +- `tests/cli-workflows.test.ts` +- `tests/tools-workflows.test.ts` +- `tests/daemon-commands.integration.test.ts` +- `tests/providers-inspiredesign-workflow.test.ts` +- `tests/providers-inspiredesign-contract.test.ts` + +End goal: Every changed branch has a failing-before, passing-after test. + +Acceptance criteria: +- [ ] Positive Pinterest provider plus URL recovery is tested. +- [ ] Negative generic provider plus URL recovery is tested. +- [ ] Omitted screenshot artifact path is tested. +- [ ] Omitted screencast artifact path is tested. +- [ ] Explicit output paths remain tested. +- [ ] Strict chrome diagnostic rejection remains tested. + +## Task 11 - Update Public Surface, Docs, And Changelog + +Reasoning: The behavior changes are user-facing and affect generated help, docs, and public surface inventory. + +What to do: Update source-owned public surface metadata, generated output, docs, and changelog. + +How: +1. Update `src/public-surface/source.ts` screenshot and screencast entries. +2. Regenerate checked-in manifests with `node scripts/generate-public-surface-manifest.mjs`. +3. Confirm both `src/public-surface/generated-manifest.ts` and `src/public-surface/generated-manifest.json` changed only as expected. +4. Update `src/cli/help.ts` only if it is not generated from the public-surface source. +5. Update `docs/CLI.md` with: + - provider-scoped Pinterest URL recovery example + - readiness wording + - omitted screenshot artifact path + - omitted screencast artifact path + - explicit path preservation note +6. Update `docs/SURFACE_REFERENCE.md` with the same public-surface behavior. +7. Update `CHANGELOG.md` under `[Unreleased]`. +8. Do not rewrite historical release evidence as if it originally had the new behavior. If needed, add a short note that later browser evidence output behavior supersedes prior exception text. + +Files impacted: +- `src/public-surface/source.ts` +- `src/public-surface/generated-manifest.ts` +- `src/public-surface/generated-manifest.json` +- `src/cli/help.ts` +- `docs/CLI.md` +- `docs/SURFACE_REFERENCE.md` +- `CHANGELOG.md` +- `docs/RELEASE_0.0.30_EVIDENCE.md` only if current wording would become misleading. + +End goal: CLI help, docs, generated manifest, and changelog match the implemented behavior. + +Acceptance criteria: +- [ ] Help and docs show omitted screenshot output under `.opendevbrowser/screenshot//capture.png`. +- [ ] Help and docs show omitted screencast output under `.opendevbrowser/screencast/`. +- [ ] Docs distinguish omitted output behavior from explicit path behavior. +- [ ] Docs mention readiness as the design gate. +- [ ] Generated manifest has no drift from source metadata. + +## Task 12 - Run Verification And Real-World Validation + +Reasoning: The repo requires real validation, not only static review, and this work changes CLI behavior. + +What to do: Run focused tests, full gates, help checks, and real CLI validation. + +How: +1. Run focused tests: + +```bash +npm run test -- tests/browser-output-artifacts.test.ts +npm run test -- tests/cli-screenshot.test.ts +npm run test -- tests/cli-screencast.test.ts +npm run test -- tests/browser-screencast-recorder.test.ts +npm run test -- tests/cli-workflows.test.ts +npm run test -- tests/tools-workflows.test.ts +npm run test -- tests/daemon-commands.integration.test.ts +npm run test -- tests/providers-inspiredesign-contract.test.ts +npm run test -- tests/providers-inspiredesign-workflow.test.ts +``` + +2. Run full quality gates: + +```bash +npm run lint +npm run typecheck +npm run build +npm run test +npm run extension:build +npm run version:check +npm run test:release-gate +``` + +3. Run help checks when help or public surface changes: + +```bash +npx opendevbrowser --help +npx opendevbrowser help +``` + +4. Run Pinterest explicit URL recovery after implementation: + +```bash +npx opendevbrowser inspiredesign harvest \ + --brief "Fashion design studio landing page with atelier motion references" \ + --provider social/pinterest \ + --url "https://www.pinterest.com/pin/27654985208435505/" \ + --max-references 5 \ + --visual-evidence required \ + --browser-mode extension \ + --use-cookies \ + --cookie-policy required \ + --challenge-automation-mode browser_with_helper \ + --mode json \ + --output-format json +``` + +5. Run omitted screenshot validation against a real session: + +```bash +npx opendevbrowser screenshot \ + --session-id \ + --output-format json +``` + +6. Run omitted screencast validation against a real session: + +```bash +npx opendevbrowser screencast-start \ + --session-id \ + --interval-ms 1000 \ + --max-frames 3 \ + --output-format json +``` + +7. Run explicit output preservation checks: + +```bash +npx opendevbrowser screenshot \ + --session-id \ + --path ./artifacts/manual-capture.png \ + --output-format json + +npx opendevbrowser screencast-start \ + --session-id \ + --output-dir ./artifacts/manual-replay \ + --interval-ms 1000 \ + --max-frames 3 \ + --output-format json +``` + +Files impacted: +- None directly. + +End goal: Implementation is proven through focused tests, full gates, help checks, and realistic CLI tasks. + +Acceptance criteria: +- [ ] Focused tests pass. +- [ ] Full tests pass with required coverage. +- [ ] Lint, typecheck, build, extension build, version check, and release gate pass. +- [ ] Help commands render the new behavior. +- [ ] Real Pinterest explicit URL recovery command is accepted. +- [ ] Omitted screenshot writes `.opendevbrowser/screenshot//capture.png`. +- [ ] Omitted screencast writes `.opendevbrowser/screencast/`. +- [ ] Explicit output paths remain caller-controlled. + +## Risks And Mitigations + +- Risk: Provider validation accidentally allows generic providers without query. Mitigation: require every provider and URL to resolve to the same site recipe. +- Risk: Some callers expect omitted screenshots to return base64 only. Mitigation: treat persistent omitted output as an intentional behavior change, update tests and docs, and keep explicit path behavior stable. +- Risk: Screencast namespace change breaks consumers of `.opendevbrowser/replays/screencasts/...`. Mitigation: preserve explicit output directory behavior and document the new omitted-output contract clearly. +- Risk: Readiness messaging could be mistaken for command failure. Mitigation: keep wrapper `success: true` and surface readiness as additional state. +- Risk: Generated public-surface drift. Mitigation: update `src/public-surface/source.ts`, regenerate `src/public-surface/generated-manifest.json`, and run help parity tests. + +## Final Acceptance Criteria + +- [ ] No source implementation happens during this planning task. +- [ ] `inspiredesign harvest --provider social/pinterest --url ` is supported after implementation. +- [ ] Generic provider-without-query remains rejected. +- [ ] Pinterest recovery guidance emits executable URL-first recovery commands when accepted URLs exist. +- [ ] Strict `interface_chrome_shell` blocking is preserved. +- [ ] Captured-but-rejected screenshots are explained in artifacts or guidance. +- [ ] Omitted screenshots write to `.opendevbrowser/screenshot//capture.png`. +- [ ] Omitted screencasts write to `.opendevbrowser/screencast/`. +- [ ] Explicit screenshot and screencast output paths are preserved. +- [ ] Tests, docs, help, public surface, generated manifest, and changelog are updated. +- [ ] Targeted and full quality gates pass with zero errors and zero warnings. diff --git a/docs/reviews/inspiredesign-harvest-plan-critique-2026-05-21.md b/docs/reviews/inspiredesign-harvest-plan-critique-2026-05-21.md new file mode 100644 index 0000000..7feb109 --- /dev/null +++ b/docs/reviews/inspiredesign-harvest-plan-critique-2026-05-21.md @@ -0,0 +1,35 @@ +# Inspiredesign Harvest Plan Critique + +## Context / Scope + +Reviewed only `docs/plans/inspiredesign-harvest-recovery-and-browser-output-artifacts-2026-05-21.md` (`Plan`) and `prompt-exports/oracle-plan-2026-05-21-073822-harvest-plan-8ff2c1-a09b.md` (`Export`). Source was not edited. + +## Findings + +1. **Screencast artifact namespace is still a decision seam.** The plan chooses `.opendevbrowser/screencast/` in recommended decisions and acceptance criteria (`Plan:30-31`, `Plan:152-156`, `Plan:398-400`, `Plan:507-528`). The export explicitly flags the label as ambiguous, then its generated plan chooses `browser-replay` (`Export:71-74`, `Export:236-251`, `Export:285-310`). Implementation should not start until this label is finalized, because it changes helper constants, tests, docs, real-world validation, and migration notes. + +2. **Browser screenshot response shape is under-specified across surfaces.** Task 2 says to return `path` and `artifact_path` and update typing “where declared” (`Plan:105-118`, `Plan:122-127`), while the export names tool, CLI, daemon, and manager surfaces as part of the contract (`Export:31-36`, `Export:56`, `Export:263-281`). The plan should state whether omitted screenshots stop returning base64, keep base64 additively, or expose both only on some surfaces. That answer changes tests and docs before manager edits. + +3. **Captured-but-rejected diagnostics need a concrete serialization target.** Task 9 names a possible field and says “ranked-references.json or equivalent” (`Plan:302-327`), but the export frames the relevant seam as `reference-pattern-board.ts`, `contract.ts`, and guidance readiness serialization (`Export:13`, `Export:28-29`, `Export:113-115`). The plan should pick the exact artifact or payload field before implementation, otherwise tests may encode the wrong public contract. + +## Specificity Balance Compared With Export + +The plan is stronger than the export on executable task formatting, targeted test inventory, and full gates. It is weaker on unresolved design choices the export marked as ambiguities, especially the screencast namespace and cross-surface browser output contract. It also says to regenerate the public surface “using the repo’s existing generation command or script if present” (`Plan:374-376`) without naming the command, while the export required exact generated-surface handling (`Export:65-67`). + +## Contradictions / Missing Dependencies + +- Namespace contradiction: `screencast` in the plan vs `browser-replay` in the export’s generated plan. +- The plan requires unavailable or external sub-skills by name (`Plan:3`), which may block agentic execution unless replaced with repo-available guidance. +- Public-surface regeneration is a missing dependency. The plan should first locate or define the manifest generation command before Task 11. + +## Over-planning Risk + +The plan combines two mostly independent lanes: `inspiredesign harvest` recovery and browser output artifacts. That may be too broad for one implementation pass. A safer order is two atomic workstreams with separate test gates: first provider URL validation plus guidance readiness, then browser artifact roots plus public-surface docs. + +## Questions That Could Change Implementation Order + +1. Should omitted screencasts use `screencast` or `browser-replay` as the final namespace? +2. Should omitted screenshots preserve base64 in any API/tool response, or fully switch to persisted file output? +3. What is the canonical command for regenerating `src/public-surface/generated-manifest.json`? +4. Is readiness only CLI message text, or an additive structured field in the public response? +5. Which exact artifact owns captured-but-rejected diagnostics: `ranked-references.json`, `nextStepGuidance`, or a separate rejected-reference payload? diff --git a/src/browser/browser-manager.ts b/src/browser/browser-manager.ts index 43ea401..d5ca81c 100644 --- a/src/browser/browser-manager.ts +++ b/src/browser/browser-manager.ts @@ -21,6 +21,10 @@ import { resolveRelayEndpoint, sanitizeWsEndpoint } from "../relay/relay-endpoin import type { RelayStatus } from "../relay/relay-server"; import { ensureLocalEndpoint } from "../utils/endpoint-validation"; import { buildBlockerArtifacts, classifyBlockerSignal } from "../providers/blocker"; +import { + BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE, + createBrowserOutputArtifactDirectory +} from "../providers/browser-output-artifacts"; import { ChallengeOrchestrator, inspectChallengePlanFromRuntime, @@ -2009,14 +2013,23 @@ export class BrowserManager { throw new Error("Screenshot ref and fullPage options are mutually exclusive."); } return this.runTargetScoped(sessionId, options.targetId, async ({ managed, page, targetId: resolvedTargetId }) => { + let artifact: ReturnType | undefined; + let outputPath = options.path; + if (typeof outputPath !== "string") { + artifact = createBrowserOutputArtifactDirectory({ + workspaceRoot: this.worktree, + namespace: BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE + }); + outputPath = join(artifact.artifactPath, "capture.png"); + } const screenshotOptions: { type: "png"; - path?: string; + path: string; fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number }; } = { type: "png", - path: options.path + path: outputPath }; if (options.ref) { @@ -2033,30 +2046,26 @@ export class BrowserManager { } try { - if (options.path) { - await this.withLegacyExtensionOperationTimeout( - managed, - page.screenshot(screenshotOptions), - `page.screenshot: Timeout ${LEGACY_EXTENSION_OPERATION_TIMEOUT_MS}ms exceeded.` - ); - return { path: options.path }; - } - const buffer = await this.withLegacyExtensionOperationTimeout( + await this.withLegacyExtensionOperationTimeout( managed, page.screenshot(screenshotOptions), `page.screenshot: Timeout ${LEGACY_EXTENSION_OPERATION_TIMEOUT_MS}ms exceeded.` ); - return { base64: buffer.toString("base64") }; + return { + path: outputPath, + ...(artifact ? { artifact_path: artifact.artifactPath } : {}) + }; } catch (error) { const fallback = await this.captureScreenshotViaCdp(managed, page, error, options); if (!fallback) { throw error; } - if (options.path) { - await writeFile(options.path, Buffer.from(fallback.base64, "base64")); - return fallback.warnings ? { path: options.path, warnings: fallback.warnings } : { path: options.path }; - } - return fallback; + await writeFile(outputPath, Buffer.from(fallback.base64, "base64")); + return { + path: outputPath, + ...(artifact ? { artifact_path: artifact.artifactPath } : {}), + ...(fallback.warnings ? { warnings: fallback.warnings } : {}) + }; } }); } diff --git a/src/browser/manager-types.ts b/src/browser/manager-types.ts index f7545a8..140f476 100644 --- a/src/browser/manager-types.ts +++ b/src/browser/manager-types.ts @@ -76,6 +76,7 @@ export type BrowserScreenshotOptions = { export type BrowserScreenshotResult = { path?: string; + artifact_path?: string; base64?: string; warnings?: string[]; }; @@ -92,6 +93,7 @@ export type BrowserScreencastSession = { sessionId: string; targetId: string; outputDir: string; + artifact_path?: string; startedAt: string; intervalMs: number; maxFrames: number; @@ -110,6 +112,7 @@ export type BrowserScreencastResult = { sessionId: string; targetId: string; outputDir: string; + artifact_path?: string; startedAt: string; endedAt: string; endedReason: BrowserScreencastEndedReason; diff --git a/src/browser/ops-browser-manager.ts b/src/browser/ops-browser-manager.ts index 0266fd1..585f602 100644 --- a/src/browser/ops-browser-manager.ts +++ b/src/browser/ops-browser-manager.ts @@ -1,5 +1,6 @@ import { writeFile } from "fs/promises"; import { randomUUID } from "crypto"; +import { join } from "path"; import { requireChallengeOrchestrationConfig, type OpenDevBrowserConfig } from "../config"; import { createLogger, createRequestId } from "../core/logging"; import { resolveRelayEndpoint, sanitizeWsEndpoint } from "../relay/relay-endpoints"; @@ -48,6 +49,10 @@ import type { ConsoleTracker } from "../devtools/console-tracker"; import type { NetworkTracker } from "../devtools/network-tracker"; import { BrowserManager } from "./browser-manager"; import { BrowserScreencastRecorder } from "./screencast-recorder"; +import { + BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE, + createBrowserOutputArtifactDirectory +} from "../providers/browser-output-artifacts"; import type { RuntimePreviewBridgeInput, RuntimePreviewBridgeResult @@ -908,11 +913,21 @@ export class OpsBrowserManager implements BrowserManagerLike { const warnings = Array.isArray(result.warnings) ? result.warnings : (typeof result.warning === "string" ? [result.warning] : undefined); - if (options.path) { - await writeFile(options.path, Buffer.from(result.base64, "base64")); - return warnings ? { path: options.path, warnings } : { path: options.path }; + let artifact: ReturnType | undefined; + let outputPath = options.path; + if (typeof outputPath !== "string") { + artifact = createBrowserOutputArtifactDirectory({ + workspaceRoot: this.worktree, + namespace: BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE + }); + outputPath = join(artifact.artifactPath, "capture.png"); } - return warnings ? { base64: result.base64, warnings } : { base64: result.base64 }; + await writeFile(outputPath, Buffer.from(result.base64, "base64")); + return { + path: outputPath, + ...(artifact ? { artifact_path: artifact.artifactPath } : {}), + ...(warnings ? { warnings } : {}) + }; } async startScreencast( diff --git a/src/browser/screencast-recorder.ts b/src/browser/screencast-recorder.ts index 7e36851..16e3c60 100644 --- a/src/browser/screencast-recorder.ts +++ b/src/browser/screencast-recorder.ts @@ -1,6 +1,10 @@ import { copyFile, mkdir, readdir, writeFile } from "fs/promises"; import { randomUUID } from "crypto"; import { isAbsolute, join, resolve } from "path"; +import { + BROWSER_SCREENCAST_ARTIFACT_NAMESPACE, + createBrowserOutputArtifactDirectory +} from "../providers/browser-output-artifacts"; import type { BrowserScreencastEndedReason, BrowserScreencastResult, @@ -104,15 +108,17 @@ async function ensureEmptyDirectory(path: string): Promise { function resolveOutputDir( worktree: string, - sessionId: string, - screencastId: string, outputDir?: string -): string { +): { outputDir: string; artifactPath?: string } { if (typeof outputDir === "string" && outputDir.trim().length > 0) { const trimmed = outputDir.trim(); - return isAbsolute(trimmed) ? trimmed : resolve(worktree, trimmed); + return { outputDir: isAbsolute(trimmed) ? trimmed : resolve(worktree, trimmed) }; } - return join(worktree, ".opendevbrowser", "replays", "screencasts", sessionId, screencastId); + const artifact = createBrowserOutputArtifactDirectory({ + workspaceRoot: worktree, + namespace: BROWSER_SCREENCAST_ARTIFACT_NAMESPACE + }); + return { outputDir: artifact.artifactPath, artifactPath: artifact.artifactPath }; } function renderReplayHtml(manifest: ScreencastManifest): string { @@ -214,6 +220,7 @@ export class BrowserScreencastRecorder { readonly sessionId: string; readonly targetId: string; readonly outputDir: string; + readonly artifactPath?: string; readonly startedAt: string; readonly intervalMs: number; readonly maxFrames: number; @@ -239,7 +246,9 @@ export class BrowserScreencastRecorder { this.screencastId = args.screencastId ?? randomUUID(); this.sessionId = args.sessionId; this.targetId = args.targetId; - this.outputDir = resolveOutputDir(args.worktree, args.sessionId, this.screencastId, args.options?.outputDir); + const output = resolveOutputDir(args.worktree, args.options?.outputDir); + this.outputDir = output.outputDir; + this.artifactPath = output.artifactPath; this.intervalMs = resolveIntervalMs(args.options?.intervalMs); this.maxFrames = resolveMaxFrames(args.options?.maxFrames); this.startedAt = new Date().toISOString(); @@ -290,6 +299,7 @@ export class BrowserScreencastRecorder { sessionId: this.sessionId, targetId: this.targetId, outputDir: this.outputDir, + ...(this.artifactPath ? { artifact_path: this.artifactPath } : {}), startedAt: this.startedAt, intervalMs: this.intervalMs, maxFrames: this.maxFrames, @@ -421,6 +431,7 @@ export class BrowserScreencastRecorder { sessionId: this.sessionId, targetId: this.targetId, outputDir: this.outputDir, + ...(this.artifactPath ? { artifact_path: this.artifactPath } : {}), startedAt: this.startedAt, endedAt, endedReason: reason, diff --git a/src/cli/commands/inspiredesign.ts b/src/cli/commands/inspiredesign.ts index 1f0aaa5..be62ff1 100644 --- a/src/cli/commands/inspiredesign.ts +++ b/src/cli/commands/inspiredesign.ts @@ -10,6 +10,7 @@ import { } from "../utils/parse"; import { buildWorkflowCompletionMessage } from "../utils/workflow-message"; import { isChallengeAutomationMode, type ChallengeAutomationMode } from "../../challenges/types"; +import { validateProviderUrlSiteRecipeCompatibility } from "../../guidance/recipes/site-recipe-validation"; import { resolveInspiredesignCaptureMode } from "../../inspiredesign/capture-mode"; import type { InspiredesignVisualEvidenceMode } from "../../inspiredesign/visual-evidence"; import type { WorkflowBrowserMode } from "../../providers/types"; @@ -42,6 +43,22 @@ const VISUAL_EVIDENCE_VALUES = new Set(["off", "auto", "required"]); const HARVEST_DEFAULT_MAX_REFERENCES = 5; const MAX_REFERENCES_LIMIT = 10; +const readInspiredesignReadiness = (data: unknown): string | undefined => { + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; + const meta = (data as Record).meta; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) return undefined; + const nextStepGuidance = (meta as Record).nextStepGuidance; + if (!nextStepGuidance || typeof nextStepGuidance !== "object" || Array.isArray(nextStepGuidance)) return undefined; + const readiness = (nextStepGuidance as Record).readiness; + return typeof readiness === "string" && readiness.length > 0 ? readiness : undefined; +}; + +const buildInspiredesignCompletionMessage = (data: unknown): string => { + const baseMessage = buildWorkflowCompletionMessage("Inspiredesign workflow", data); + const readiness = readInspiredesignReadiness(data); + return readiness ? `${baseMessage} readiness=${readiness}` : baseMessage; +}; + const requireValue = (rawArgs: string[], index: number, flag: string): string => { const value = rawArgs[index + 1]; if (!value) { @@ -264,7 +281,16 @@ export async function runInspiredesignCommand(args: ParsedArgs) { } const isHarvest = subcommand === "harvest"; if (parsed.providers && parsed.providers.length > 0 && !parsed.query) { - throw createUsageError("--provider requires --query"); + if (!isHarvest) { + throw createUsageError("--provider requires --query or compatible harvest --url recovery"); + } + const compatibility = validateProviderUrlSiteRecipeCompatibility({ + providers: parsed.providers, + urls: parsed.urls ?? [] + }); + if (!compatibility.ok) { + throw createUsageError(compatibility.message); + } } if (isHarvest && !parsed.query && (!parsed.urls || parsed.urls.length === 0)) { throw createUsageError("inspiredesign harvest requires --query or --url"); @@ -293,7 +319,7 @@ export async function runInspiredesignCommand(args: ParsedArgs) { return { success: true, - message: buildWorkflowCompletionMessage("Inspiredesign workflow", data), + message: buildInspiredesignCompletionMessage(data), data }; } diff --git a/src/cli/daemon-commands.ts b/src/cli/daemon-commands.ts index 34ab4c2..8cff6f5 100644 --- a/src/cli/daemon-commands.ts +++ b/src/cli/daemon-commands.ts @@ -1,4 +1,7 @@ import { randomUUID } from "crypto"; +import { mkdtemp, readFile, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; import type { OpenDevBrowserCore } from "../core"; import { buildBrowserReviewResult } from "../browser/review-surface"; import { @@ -70,6 +73,31 @@ const resolveDaemonWorkflowOutputDir = ( outputDir?: string ): string => resolveWorkflowArtifactRoot(outputDir, { workspaceRoot: core.cacheRoot }); +const captureScreenshotBuffer = async ( + core: OpenDevBrowserCore, + sessionId: string +): Promise => { + let captureDir: string | null = null; + try { + captureDir = await mkdtemp(join(tmpdir(), "odb-daemon-shot-")); + const capturePath = join(captureDir, "capture.png"); + const screenshot = await core.manager.screenshot(sessionId, { path: capturePath }); + if (typeof screenshot.path === "string" && screenshot.path.length > 0) { + return await readFile(screenshot.path); + } + if (typeof screenshot.base64 === "string" && screenshot.base64.length > 0) { + return Buffer.from(screenshot.base64, "base64"); + } + return null; + } finally { + if (captureDir) { + await rm(captureDir, { recursive: true, force: true }).catch(() => { + // Best effort cleanup. + }); + } + } +}; + export async function handleDaemonCommand(core: OpenDevBrowserCore, request: DaemonCommandRequest): Promise { const params = request.params ?? {}; const bindingId = optionalString(params.bindingId); @@ -921,14 +949,12 @@ export async function handleDaemonCommand(core: OpenDevBrowserCore, request: Dae }); try { await core.manager.goto(session.sessionId, url, "load", captureTimeoutMs); - const screenshot = await Promise.race([ - core.manager.screenshot(session.sessionId), + return await Promise.race([ + captureScreenshotBuffer(core, session.sessionId), new Promise((resolve) => { setTimeout(() => resolve(null), captureTimeoutMs); }) ]); - if (!screenshot || typeof screenshot.base64 !== "string" || screenshot.base64.length === 0) return null; - return Buffer.from(screenshot.base64, "base64"); } catch { return null; } finally { diff --git a/src/guidance/context.ts b/src/guidance/context.ts index f149d89..930cf2e 100644 --- a/src/guidance/context.ts +++ b/src/guidance/context.ts @@ -118,6 +118,13 @@ const reasonCodeForInspiredesign = (source: InspiredesignGuidanceSource): string return "design_ready"; }; +const dedupeInspiredesignReferenceUrls = (source: InspiredesignGuidanceSource): string[] => { + const urls = [...(source.urls ?? []), ...source.discovery.acceptedUrls] + .map((url) => url.trim()) + .filter((url) => url.length > 0); + return [...new Set(urls)]; +}; + const resolveInspiredesignSiteRecipe = (source: InspiredesignGuidanceSource): string | undefined => { const providerRecipe = source.requestedProviders .map((providerId) => resolveSiteRecipeForProvider(providerId)) @@ -133,12 +140,14 @@ export const createInspiredesignGuidanceContext = ( source: InspiredesignGuidanceSource ): GuidanceContext => { const siteRecipeId = resolveInspiredesignSiteRecipe(source); + const referenceUrls = dedupeInspiredesignReferenceUrls(source); return { workflow: "inspiredesign", reasonCode: reasonCodeForInspiredesign(source), requestedProviders: source.requestedProviders, ...(siteRecipeId ? { siteRecipeId } : {}), ...(source.query ? { query: source.query } : {}), + ...(referenceUrls.length > 0 ? { referenceUrls } : {}), ...(source.browserMode ? { browserMode: source.browserMode } : {}), ...(source.cookiePolicy ? { cookiePolicy: source.cookiePolicy } : {}), ...(typeof source.useCookies === "boolean" ? { useCookies: source.useCookies } : {}), diff --git a/src/guidance/recipes/generic.ts b/src/guidance/recipes/generic.ts index 2591a66..7924f71 100644 --- a/src/guidance/recipes/generic.ts +++ b/src/guidance/recipes/generic.ts @@ -1,4 +1,5 @@ import { buildCanvasPlanSetParamsExample, buildCanvasRepairGuidance } from "../../canvas/repair-examples"; +import { resolveSiteRecipeForUrl } from "./site-registry"; import type { CanvasGenerationPlanIssue } from "../../canvas/types"; import type { GuidanceCommandExample, @@ -61,12 +62,27 @@ const inspiredesignCookieFlags = (context: GuidanceContext): string => { return context.useCookies === true ? " --use-cookies" : ""; }; +const pinterestRecoveryUrls = (context: GuidanceContext): string[] => { + if (!isPinterestScopedRecovery(context)) return []; + const urls = context.referenceUrls ?? []; + return [...new Set(urls.filter((url) => resolveSiteRecipeForUrl(url)?.id === "social/pinterest"))]; +}; + const inspiredesignHarvestCommand = (context: GuidanceContext): GuidanceCommandExample => { const brief = typeof context.details?.brief === "string" ? context.details.brief : DEFAULT_INSPIREDESIGN_BRIEF; const query = context.query ?? DEFAULT_INSPIREDESIGN_QUERY; const provider = selectedInspiredesignProvider(context); const browserMode = inspiredesignBrowserMode(context); const cookieFlags = inspiredesignCookieFlags(context); + const urls = pinterestRecoveryUrls(context); + if (urls.length > 0) { + const urlFlags = urls.map((url) => `--url ${quote(url)}`).join(" "); + return { + id: "inspiredesign-harvest-url-recovery", + label: "Recover Inspired Design harvest with explicit Pinterest URLs", + command: `npx opendevbrowser inspiredesign harvest --brief ${quote(brief)} --provider social/pinterest ${urlFlags} --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json` + }; + } return { id: "inspiredesign-harvest-rerun", label: "Rerun Inspired Design harvest with explicit evidence settings", diff --git a/src/guidance/recipes/site-recipe-validation.ts b/src/guidance/recipes/site-recipe-validation.ts new file mode 100644 index 0000000..e842a19 --- /dev/null +++ b/src/guidance/recipes/site-recipe-validation.ts @@ -0,0 +1,76 @@ +import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "./site-registry"; +import type { SiteRecipe } from "../types"; + +export type ProviderUrlSiteRecipeCompatibilityResult = + | { ok: true; recipeId: string } + | { ok: false; message: string }; + +type ProviderUrlSiteRecipeCompatibilityInput = { + providers: string[]; + urls: string[]; +}; + +const normalizeNonBlank = (values: string[]): string[] => values + .map((value) => value.trim()) + .filter((value) => value.length > 0); + +const incompatible = (message: string): ProviderUrlSiteRecipeCompatibilityResult => ({ ok: false, message }); + +const supportsBrowserNativeRecovery = (recipe: SiteRecipe | undefined): recipe is SiteRecipe => ( + typeof recipe?.browserNativeDiscovery?.buildSearchUrl === "function" +); + +export const validateProviderUrlSiteRecipeCompatibility = ({ + providers, + urls +}: ProviderUrlSiteRecipeCompatibilityInput): ProviderUrlSiteRecipeCompatibilityResult => { + const providerIds = normalizeNonBlank(providers); + const referenceUrls = normalizeNonBlank(urls); + + if (providerIds.length === 0) { + return incompatible("Provider-scoped URL recovery requires at least one provider."); + } + if (referenceUrls.length === 0) { + return incompatible("Provider-scoped URL recovery requires at least one URL."); + } + + const providerRecipes = providerIds.map((providerId) => ({ + providerId, + recipe: resolveSiteRecipeForProvider(providerId) + })); + const missingProviderRecipe = providerRecipes.find((entry) => entry.recipe === undefined); + if (missingProviderRecipe) { + return incompatible(`Provider ${missingProviderRecipe.providerId} does not support URL-only site recipe recovery.`); + } + const nonNativeProviderRecipe = providerRecipes.find((entry) => !supportsBrowserNativeRecovery(entry.recipe)); + if (nonNativeProviderRecipe) { + return incompatible(`Provider ${nonNativeProviderRecipe.providerId} does not support browser-native URL-only site recipe recovery.`); + } + + const urlRecipes = referenceUrls.map((url) => ({ + url, + recipe: resolveSiteRecipeForUrl(url) + })); + const missingUrlRecipe = urlRecipes.find((entry) => entry.recipe === undefined); + if (missingUrlRecipe) { + return incompatible(`URL ${missingUrlRecipe.url} does not match a browser-native site recipe for provider-scoped recovery.`); + } + const nonNativeUrlRecipe = urlRecipes.find((entry) => !supportsBrowserNativeRecovery(entry.recipe)); + if (nonNativeUrlRecipe) { + return incompatible(`URL ${nonNativeUrlRecipe.url} does not match a browser-native site recipe for provider-scoped recovery.`); + } + + const recipeIds = new Set([ + ...providerRecipes.map((entry) => entry.recipe?.id), + ...urlRecipes.map((entry) => entry.recipe?.id) + ]); + if (recipeIds.size !== 1) { + return incompatible("Provider-scoped URL recovery requires every provider and URL to resolve to the same site recipe."); + } + + const recipeId = providerRecipes[0]?.recipe?.id; + if (!recipeId) { + return incompatible("Provider-scoped URL recovery could not resolve a site recipe."); + } + return { ok: true, recipeId }; +}; diff --git a/src/guidance/types.ts b/src/guidance/types.ts index 55f2392..d435268 100644 --- a/src/guidance/types.ts +++ b/src/guidance/types.ts @@ -89,6 +89,7 @@ export type GuidanceContext = { requestedProviders?: string[]; siteRecipeId?: string; query?: string; + referenceUrls?: string[]; browserMode?: string; cookiePolicy?: string; useCookies?: boolean; diff --git a/src/inspiredesign/contract.ts b/src/inspiredesign/contract.ts index 45aeff7..3f70b7e 100644 --- a/src/inspiredesign/contract.ts +++ b/src/inspiredesign/contract.ts @@ -365,6 +365,7 @@ export type InspiredesignPacket = { visualEvidence: InspiredesignVisualEvidenceJson[]; screenshotIndex: InspiredesignScreenshotIndexEntry[]; rankedReferences: InspiredesignReferencePatternBoard["references"]; + referencePatternBoard: InspiredesignReferencePatternBoard; metaPromptMarkdown: string; }; @@ -2325,6 +2326,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): visualEvidence, screenshotIndex, rankedReferences: designReferencePatternBoard.references, + referencePatternBoard, metaPromptMarkdown, evidence: buildEvidencePayload({ brief, diff --git a/src/inspiredesign/reference-pattern-board.ts b/src/inspiredesign/reference-pattern-board.ts index 0835cfb..c6dc752 100644 --- a/src/inspiredesign/reference-pattern-board.ts +++ b/src/inspiredesign/reference-pattern-board.ts @@ -76,6 +76,10 @@ export type InspiredesignReferencePatternBoard = { reason: string; fetchStatus: ReferenceStatus; captureStatus: "off" | "captured" | "failed"; + captured?: true; + diagnosticReasons?: string[]; + capturedButRejectedReason?: string; + evidenceGap?: string; }>; synthesis: { dominantDirection: string; @@ -831,17 +835,37 @@ const rejectionReasonForReference = (reference: ReferenceInput): string => { return "Reference evidence was diagnostic, empty, or too weak for creative synthesis."; }; +const hasCapturedEvidence = (reference: ReferenceInput): boolean => ( + reference.captureStatus === "captured" || reference.capture?.visual?.status === "captured" +); + +const capturedButRejectedReason = (diagnosticReasons: string[]): string => ( + diagnosticReasons.length > 0 + ? `Captured browser evidence was rejected because it only exposed diagnostic signals: ${diagnosticReasons.join(", ")}.` + : "Captured browser evidence was rejected because it did not contain usable creative reference evidence." +); + const buildRejectedReferences = ( references: ReferenceInput[] ): InspiredesignReferencePatternBoard["rejectedReferences"] => references .filter((reference) => !hasInspiredesignUsableReferenceEvidence(reference)) - .map((reference) => ({ - id: reference.id, - url: reference.url, - reason: rejectionReasonForReference(reference), - fetchStatus: reference.fetchStatus, - captureStatus: reference.captureStatus - })); + .map((reference) => { + const diagnosticReasons = referenceDiagnosticReasons(reference); + const captured = hasCapturedEvidence(reference); + return { + id: reference.id, + url: reference.url, + reason: rejectionReasonForReference(reference), + fetchStatus: reference.fetchStatus, + captureStatus: reference.captureStatus, + ...(captured ? { captured: true as const } : {}), + ...(diagnosticReasons.length > 0 ? { diagnosticReasons } : {}), + ...(captured ? { + capturedButRejectedReason: capturedButRejectedReason(diagnosticReasons), + evidenceGap: "Design-facing artifacts require creative layout evidence; diagnostic browser chrome is kept only as rejection metadata." + } : {}) + }; + }); const buildQualitySummary = ( references: ReferenceInput[], @@ -890,6 +914,54 @@ export const isInspiredesignDesignReference = ( ) ); +const buildNotReadyRejectedReference = ( + reference: InspiredesignReferencePatternBoard["references"][number] +): InspiredesignReferencePatternBoard["rejectedReferences"][number] => { + const captured = reference.capturedVia.length > 0; + return { + id: reference.id, + url: reference.url, + reason: reference.intentMatched + ? "Reference evidence did not meet the design-ready ranking threshold." + : "Reference evidence did not match the requested design intent.", + fetchStatus: reference.capturedVia.includes("fetch") ? "captured" : "skipped", + captureStatus: reference.capturedVia.some((method) => method !== "fetch") ? "captured" : "off", + ...(captured ? { + captured: true as const, + capturedButRejectedReason: "Captured reference evidence did not satisfy design-ready ranking gates.", + evidenceGap: "Design-facing artifacts require design-ready creative evidence; non-ready captures are kept only as rejection metadata." + } : {}) + }; +}; + +const mergeRejectedReferences = ( + rejectedReferences: InspiredesignReferencePatternBoard["rejectedReferences"] +): InspiredesignReferencePatternBoard["rejectedReferences"] => { + const seen = new Set(); + return rejectedReferences.filter((reference) => { + if (seen.has(reference.id)) return false; + seen.add(reference.id); + return true; + }); +}; + +export const buildInspiredesignRankedArtifactPatternBoard = ( + designBoard: InspiredesignReferencePatternBoard, + sourceBoard: InspiredesignReferencePatternBoard +): InspiredesignReferencePatternBoard => { + const designReferenceIds = new Set(designBoard.references.map((reference) => reference.id)); + const notReadyReferences = sourceBoard.references + .filter((reference) => !designReferenceIds.has(reference.id)) + .map(buildNotReadyRejectedReference); + return { + ...designBoard, + rejectedReferences: mergeRejectedReferences([ + ...sourceBoard.rejectedReferences, + ...notReadyReferences + ]) + }; +}; + export const buildInspiredesignDesignReferencePatternBoard = ( board: InspiredesignReferencePatternBoard, designVectors: InspiredesignDesignVectors diff --git a/src/providers/browser-output-artifacts.ts b/src/providers/browser-output-artifacts.ts new file mode 100644 index 0000000..b5bae6e --- /dev/null +++ b/src/providers/browser-output-artifacts.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "crypto"; +import { mkdirSync } from "fs"; +import { join } from "path"; +import { resolveWorkflowArtifactRoot } from "./workflow-output-root"; + +export const BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE = "screenshot"; +export const BROWSER_SCREENCAST_ARTIFACT_NAMESPACE = "screencast"; + +export type BrowserOutputArtifactDirectoryInput = { + workspaceRoot?: string; + namespace: string; +}; + +export type BrowserOutputArtifactDirectory = { + artifactPath: string; + namespace: string; + runId: string; +}; + +const SAFE_BROWSER_ARTIFACT_NAMESPACE_PATTERN = /^[a-z0-9_-]+$/; + +export function createBrowserOutputArtifactDirectory( + input: BrowserOutputArtifactDirectoryInput +): BrowserOutputArtifactDirectory { + const namespace = input.namespace.trim(); + if (namespace.length === 0) { + throw new Error("Browser output artifact namespace cannot be empty."); + } + if (!SAFE_BROWSER_ARTIFACT_NAMESPACE_PATTERN.test(namespace)) { + throw new Error("Browser output artifact namespace can only contain lowercase letters, numbers, underscores, and hyphens."); + } + + const root = resolveWorkflowArtifactRoot(undefined, { workspaceRoot: input.workspaceRoot }); + const runId = randomUUID(); + const artifactPath = join(root, namespace, runId); + mkdirSync(artifactPath, { recursive: true, mode: 0o700 }); + + return { artifactPath, namespace, runId }; +} diff --git a/src/providers/workflows.ts b/src/providers/workflows.ts index 82fe41e..c47190f 100644 --- a/src/providers/workflows.ts +++ b/src/providers/workflows.ts @@ -31,6 +31,7 @@ import { type InspiredesignReferenceEvidence } from "../inspiredesign/contract"; import { + buildInspiredesignRankedArtifactPatternBoard, hasInspiredesignUsableReferenceEvidence, summarizeInspiredesignReferenceQuality, type InspiredesignReferencePatternBoard @@ -43,6 +44,7 @@ import { type NextStepGuidance, type SiteRecipe } from "../guidance"; +import { validateProviderUrlSiteRecipeCompatibility } from "../guidance/recipes/site-recipe-validation"; import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "../guidance/recipes/site-registry"; import { mergeInspiredesignReferenceUrls, @@ -1710,7 +1712,13 @@ const normalizeInspiredesignInput = (input: InspiredesignRunInput): Inspiredesig throw new Error("Inspiredesign workflow query is only supported when harvest is true."); } if (providers.length > 0 && !query) { - throw new Error("Inspiredesign workflow providers require query."); + if (input.harvest !== true) { + throw new Error("Inspiredesign workflow providers require query unless harvest uses compatible URL recovery."); + } + const compatibility = validateProviderUrlSiteRecipeCompatibility({ providers, urls }); + if (!compatibility.ok) { + throw new Error(`Inspiredesign workflow ${compatibility.message}`); + } } if (input.harvest === true && !query && urls.length === 0) { throw new Error("Inspiredesign harvest requires query or URL references."); @@ -4451,7 +4459,10 @@ export const runInspiredesignWorkflow = async ( visualEvidence: packet.visualEvidence, screenshotIndex: packet.screenshotIndex, rankedReferences: packet.rankedReferences, - referencePatternBoard: packet.generationPlan.referencePatternBoard, + referencePatternBoard: buildInspiredesignRankedArtifactPatternBoard( + packet.generationPlan.referencePatternBoard, + packet.referencePatternBoard + ), metaPromptMarkdown: packet.metaPromptMarkdown, nextStepGuidance, meta: metaWithGuidance diff --git a/src/public-surface/generated-manifest.json b/src/public-surface/generated-manifest.json index 1475b08..64a7435 100644 --- a/src/public-surface/generated-manifest.json +++ b/src/public-surface/generated-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "2026-04-04", - "generatedAt": "2026-05-20T18:36:56.052Z", + "generatedAt": "2026-05-21T13:16:09.774Z", "cli": { "groups": [ { @@ -586,7 +586,8 @@ "examples": [ "npx opendevbrowser inspiredesign run --brief \"Extract a reusable dashboard design contract from live references\" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign --output-format json", "npx opendevbrowser inspiredesign harvest --brief \"Synthesize a premium docs workspace\" --query \"best docs product landing pages\" --provider web/default --max-references 5 --visual-evidence required --browser-mode managed --output-format json", - "npx opendevbrowser inspiredesign harvest --brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json" + "npx opendevbrowser inspiredesign harvest --brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json", + "npx opendevbrowser inspiredesign harvest --brief \"Fashion design studio landing page with atelier motion references\" --provider social/pinterest --url \"https://www.pinterest.com/pin/27654985208435505/\" --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json" ], "notes": [ "Any inspiredesign --url forces deep capture for DOM/layout evidence; without URLs, --capture-mode defaults to off.", @@ -594,8 +595,10 @@ "inspiredesign harvest keeps the daemon method as inspiredesign.run, requires --query or at least one --url, defaults to path output, requires visual evidence, and caps discovery at 5 references unless --max-references changes it.", "Inspect nextStepGuidance.readiness before continuing. Only readiness=ready makes Canvas continuation the primary action.", "Do not proceed when nextStepGuidance.doNotProceedIf matches zero references, empty ranked references, missing required screenshots, provider unavailability, or diagnostic-only captures.", - "Pinterest is modeled as a browser-native site recipe for social/pinterest, not as a default full social provider. Use extension mode, cookies, and recovery-first guidance when session evidence is not ready.", + "CLI completion text includes readiness= when the workflow reports nextStepGuidance.readiness, so success output is not confused with design readiness.", + "Pinterest is modeled as a browser-native site recipe for social/pinterest, not as a default full social provider. Compatible Pinterest --url recovery can run with --provider social/pinterest even when --query is omitted.", "Harvest JSON is metadata-only: screenshots are artifact PNG files referenced by relative paths, hashes, viewport metadata, and warnings.", + "ranked-references.json includes rejectedReferences for captured-but-rejected diagnostics such as interface_chrome_shell without promoting those captures into design references.", "Load opendevbrowser-motion-design before turning harvest motion posture into implementation timing, scroll choreography, reduced-motion behavior, or temporal proof." ], "groupId": "provider_workflows", @@ -1375,9 +1378,13 @@ "--timeout-ms" ], "examples": [ + "npx opendevbrowser screenshot --session-id s1 --output-format json", "npx opendevbrowser screenshot --session-id s1 --path ./artifacts/page.png --full-page --output-format json" ], - "notes": [], + "notes": [ + "When --path is omitted, screenshot writes .opendevbrowser/screenshot//capture.png and returns path plus artifact_path.", + "Explicit --path remains caller-controlled and does not create the omitted-output screenshot artifact directory." + ], "groupId": "diagnostics_annotation", "groupTitle": "Diagnostics & Annotation", "groupSummary": "Collect session-centric diagnostics, trace proof, and annotation payloads." @@ -1495,9 +1502,13 @@ "--timeout-ms" ], "examples": [ + "npx opendevbrowser screencast-start --session-id s1 --interval-ms 750 --max-frames 40 --output-format json", "npx opendevbrowser screencast-start --session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json" ], - "notes": [], + "notes": [ + "When --output-dir is omitted, screencast-start writes replay files under .opendevbrowser/screencast/ and returns artifact_path.", + "Explicit --output-dir remains caller-controlled and keeps the existing replay file names inside that directory." + ], "groupId": "browser_replay", "groupTitle": "Browser Replay", "groupSummary": "Capture temporal replay artifacts through the public browser replay lane for a browser target." @@ -2558,7 +2569,7 @@ }, { "name": "opendevbrowser_inspiredesign_run", - "description": "Run the inspiredesign workflow directly, including harvest query discovery and visual evidence capture.", + "description": "Run the inspiredesign workflow directly, including provider-scoped URL recovery, harvest query discovery, readiness guidance, and visual evidence capture.", "cliEquivalent": "inspiredesign", "example": "npx opendevbrowser inspiredesign run --brief \"Extract a reusable dashboard design contract from live references\" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign --output-format json" }, @@ -2588,15 +2599,15 @@ }, { "name": "opendevbrowser_screenshot", - "description": "Capture a page screenshot.", + "description": "Capture a page screenshot and persist omitted outputs under .opendevbrowser/screenshot//capture.png.", "cliEquivalent": "screenshot", - "example": "npx opendevbrowser screenshot --session-id s1 --path ./artifacts/page.png --full-page --output-format json" + "example": "npx opendevbrowser screenshot --session-id s1 --output-format json" }, { "name": "opendevbrowser_screencast_start", - "description": "Start a browser replay screencast capture.", + "description": "Start a browser replay screencast capture and persist omitted outputs under .opendevbrowser/screencast/.", "cliEquivalent": "screencast-start", - "example": "npx opendevbrowser screencast-start --session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json" + "example": "npx opendevbrowser screencast-start --session-id s1 --interval-ms 750 --max-frames 40 --output-format json" }, { "name": "opendevbrowser_screencast_stop", diff --git a/src/public-surface/generated-manifest.ts b/src/public-surface/generated-manifest.ts index 24343d5..e0d709c 100644 --- a/src/public-surface/generated-manifest.ts +++ b/src/public-surface/generated-manifest.ts @@ -13,11 +13,11 @@ import type { } from "./source"; export const PUBLIC_SURFACE_MANIFEST_SCHEMA_VERSION = "2026-04-04" as const; -export const PUBLIC_SURFACE_MANIFEST_GENERATED_AT = "2026-05-20T18:36:56.052Z" as const; +export const PUBLIC_SURFACE_MANIFEST_GENERATED_AT = "2026-05-21T13:16:09.774Z" as const; export const PUBLIC_SURFACE_MANIFEST = { "schemaVersion": "2026-04-04", - "generatedAt": "2026-05-20T18:36:56.052Z", + "generatedAt": "2026-05-21T13:16:09.774Z", "cli": { "groups": [ { @@ -603,7 +603,8 @@ export const PUBLIC_SURFACE_MANIFEST = { "examples": [ "npx opendevbrowser inspiredesign run --brief \"Extract a reusable dashboard design contract from live references\" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign --output-format json", "npx opendevbrowser inspiredesign harvest --brief \"Synthesize a premium docs workspace\" --query \"best docs product landing pages\" --provider web/default --max-references 5 --visual-evidence required --browser-mode managed --output-format json", - "npx opendevbrowser inspiredesign harvest --brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json" + "npx opendevbrowser inspiredesign harvest --brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json", + "npx opendevbrowser inspiredesign harvest --brief \"Fashion design studio landing page with atelier motion references\" --provider social/pinterest --url \"https://www.pinterest.com/pin/27654985208435505/\" --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json" ], "notes": [ "Any inspiredesign --url forces deep capture for DOM/layout evidence; without URLs, --capture-mode defaults to off.", @@ -611,8 +612,10 @@ export const PUBLIC_SURFACE_MANIFEST = { "inspiredesign harvest keeps the daemon method as inspiredesign.run, requires --query or at least one --url, defaults to path output, requires visual evidence, and caps discovery at 5 references unless --max-references changes it.", "Inspect nextStepGuidance.readiness before continuing. Only readiness=ready makes Canvas continuation the primary action.", "Do not proceed when nextStepGuidance.doNotProceedIf matches zero references, empty ranked references, missing required screenshots, provider unavailability, or diagnostic-only captures.", - "Pinterest is modeled as a browser-native site recipe for social/pinterest, not as a default full social provider. Use extension mode, cookies, and recovery-first guidance when session evidence is not ready.", + "CLI completion text includes readiness= when the workflow reports nextStepGuidance.readiness, so success output is not confused with design readiness.", + "Pinterest is modeled as a browser-native site recipe for social/pinterest, not as a default full social provider. Compatible Pinterest --url recovery can run with --provider social/pinterest even when --query is omitted.", "Harvest JSON is metadata-only: screenshots are artifact PNG files referenced by relative paths, hashes, viewport metadata, and warnings.", + "ranked-references.json includes rejectedReferences for captured-but-rejected diagnostics such as interface_chrome_shell without promoting those captures into design references.", "Load opendevbrowser-motion-design before turning harvest motion posture into implementation timing, scroll choreography, reduced-motion behavior, or temporal proof." ], "groupId": "provider_workflows", @@ -1392,9 +1395,13 @@ export const PUBLIC_SURFACE_MANIFEST = { "--timeout-ms" ], "examples": [ + "npx opendevbrowser screenshot --session-id s1 --output-format json", "npx opendevbrowser screenshot --session-id s1 --path ./artifacts/page.png --full-page --output-format json" ], - "notes": [], + "notes": [ + "When --path is omitted, screenshot writes .opendevbrowser/screenshot//capture.png and returns path plus artifact_path.", + "Explicit --path remains caller-controlled and does not create the omitted-output screenshot artifact directory." + ], "groupId": "diagnostics_annotation", "groupTitle": "Diagnostics & Annotation", "groupSummary": "Collect session-centric diagnostics, trace proof, and annotation payloads." @@ -1512,9 +1519,13 @@ export const PUBLIC_SURFACE_MANIFEST = { "--timeout-ms" ], "examples": [ + "npx opendevbrowser screencast-start --session-id s1 --interval-ms 750 --max-frames 40 --output-format json", "npx opendevbrowser screencast-start --session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json" ], - "notes": [], + "notes": [ + "When --output-dir is omitted, screencast-start writes replay files under .opendevbrowser/screencast/ and returns artifact_path.", + "Explicit --output-dir remains caller-controlled and keeps the existing replay file names inside that directory." + ], "groupId": "browser_replay", "groupTitle": "Browser Replay", "groupSummary": "Capture temporal replay artifacts through the public browser replay lane for a browser target." @@ -2575,7 +2586,7 @@ export const PUBLIC_SURFACE_MANIFEST = { }, { "name": "opendevbrowser_inspiredesign_run", - "description": "Run the inspiredesign workflow directly, including harvest query discovery and visual evidence capture.", + "description": "Run the inspiredesign workflow directly, including provider-scoped URL recovery, harvest query discovery, readiness guidance, and visual evidence capture.", "cliEquivalent": "inspiredesign", "example": "npx opendevbrowser inspiredesign run --brief \"Extract a reusable dashboard design contract from live references\" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign --output-format json" }, @@ -2605,15 +2616,15 @@ export const PUBLIC_SURFACE_MANIFEST = { }, { "name": "opendevbrowser_screenshot", - "description": "Capture a page screenshot.", + "description": "Capture a page screenshot and persist omitted outputs under .opendevbrowser/screenshot//capture.png.", "cliEquivalent": "screenshot", - "example": "npx opendevbrowser screenshot --session-id s1 --path ./artifacts/page.png --full-page --output-format json" + "example": "npx opendevbrowser screenshot --session-id s1 --output-format json" }, { "name": "opendevbrowser_screencast_start", - "description": "Start a browser replay screencast capture.", + "description": "Start a browser replay screencast capture and persist omitted outputs under .opendevbrowser/screencast/.", "cliEquivalent": "screencast-start", - "example": "npx opendevbrowser screencast-start --session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json" + "example": "npx opendevbrowser screencast-start --session-id s1 --interval-ms 750 --max-frames 40 --output-format json" }, { "name": "opendevbrowser_screencast_stop", diff --git a/src/public-surface/source.ts b/src/public-surface/source.ts index 03885ad..4e55729 100644 --- a/src/public-surface/source.ts +++ b/src/public-surface/source.ts @@ -733,7 +733,8 @@ const CLI_COMMAND_EXAMPLES = { inspiredesign: [ cliExample("inspiredesign run", "--brief \"Extract a reusable dashboard design contract from live references\" --url https://linear.app --browser-mode managed --use-cookies --challenge-automation-mode browser_with_helper --include-prototype-guidance --output-dir /tmp/inspiredesign --output-format json"), cliExample("inspiredesign harvest", "--brief \"Synthesize a premium docs workspace\" --query \"best docs product landing pages\" --provider web/default --max-references 5 --visual-evidence required --browser-mode managed --output-format json"), - cliExample("inspiredesign harvest", "--brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json") + cliExample("inspiredesign harvest", "--brief \"Premium digital photography studio landing page\" --query \"Pinterest premium digital photography studio landing page cinematic parallax portfolio\" --provider social/pinterest --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json"), + cliExample("inspiredesign harvest", "--brief \"Fashion design studio landing page with atelier motion references\" --provider social/pinterest --url \"https://www.pinterest.com/pin/27654985208435505/\" --max-references 5 --visual-evidence required --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --mode json --output-format json") ], artifacts: [cliExample("artifacts cleanup", "--expired-only --output-dir /tmp/opendevbrowser --output-format json")], "macro-resolve": [cliExample("macro-resolve", "--expression '@community.search(\"browser automation failures\", 4)' --execute --browser-mode extension --use-cookies --cookie-policy required --challenge-automation-mode browser_with_helper --output-format json")], @@ -780,13 +781,19 @@ const CLI_COMMAND_EXAMPLES = { "session-inspector-plan": [cliExample("session-inspector-plan", "--session-id s1 --target-id page-1 --challenge-automation-mode browser_with_helper --output-format json")], "session-inspector-audit": [cliExample("session-inspector-audit", "--session-id s1 --target-id page-1 --reason \"trace challenge state\" --include-urls --request-id req-session-audit-001 --challenge-automation-mode browser_with_helper --output-format json")], perf: [cliExample("perf", "--session-id s1 --output-format json")], - screenshot: [cliExample("screenshot", "--session-id s1 --path ./artifacts/page.png --full-page --output-format json")], + screenshot: [ + cliExample("screenshot", "--session-id s1 --output-format json"), + cliExample("screenshot", "--session-id s1 --path ./artifacts/page.png --full-page --output-format json") + ], dialog: [cliExample("dialog", "--session-id s1 --action status --output-format json")], "console-poll": [cliExample("console-poll", "--session-id s1 --max 50 --output-format json")], "network-poll": [cliExample("network-poll", "--session-id s1 --max 50 --output-format json")], "debug-trace-snapshot": [cliExample("debug-trace-snapshot", "--session-id s1 --max 50 --request-id req-trace-001 --output-format json")], annotate: [cliExample("annotate", "--session-id s1 --transport auto --context \"review call to action spacing\" --include-screenshots true --output-format json")], - "screencast-start": [cliExample("screencast-start", "--session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json")], + "screencast-start": [ + cliExample("screencast-start", "--session-id s1 --interval-ms 750 --max-frames 40 --output-format json"), + cliExample("screencast-start", "--session-id s1 --output-dir ./artifacts/replay --interval-ms 750 --max-frames 40 --output-format json") + ], "screencast-stop": [cliExample("screencast-stop", "--session-id s1 --screencast-id cast-1 --output-format json")], "desktop-status": [cliExample("desktop-status", "--timeout-ms 5000 --output-format json")], "desktop-windows": [cliExample("desktop-windows", "--reason \"inventory browser-adjacent windows\" --output-format json")], @@ -822,8 +829,10 @@ const CLI_COMMAND_NOTES: Partial when the workflow reports nextStepGuidance.readiness, so success output is not confused with design readiness.", + "Pinterest is modeled as a browser-native site recipe for social/pinterest, not as a default full social provider. Compatible Pinterest --url recovery can run with --provider social/pinterest even when --query is omitted.", "Harvest JSON is metadata-only: screenshots are artifact PNG files referenced by relative paths, hashes, viewport metadata, and warnings.", + "ranked-references.json includes rejectedReferences for captured-but-rejected diagnostics such as interface_chrome_shell without promoting those captures into design references.", "Load opendevbrowser-motion-design before turning harvest motion posture into implementation timing, scroll choreography, reduced-motion behavior, or temporal proof." ], "macro-resolve": [ @@ -836,6 +845,14 @@ const CLI_COMMAND_NOTES: Partial/capture.png and returns path plus artifact_path.", + "Explicit --path remains caller-controlled and does not create the omitted-output screenshot artifact directory." + ], + "screencast-start": [ + "When --output-dir is omitted, screencast-start writes replay files under .opendevbrowser/screencast/ and returns artifact_path.", + "Explicit --output-dir remains caller-controlled and keeps the existing replay file names inside that directory." + ], annotate: [ "Use --stored when you want the last delivered annotation payload without starting a new capture." ], @@ -926,13 +943,13 @@ export const TOOL_SURFACE_ENTRIES: readonly ToolSurfaceDefinition[] = [ { name: "opendevbrowser_research_run", description: "Run the research workflow directly.", cliEquivalent: "research" }, { name: "opendevbrowser_shopping_run", description: "Run the shopping workflow directly.", cliEquivalent: "shopping" }, { name: "opendevbrowser_product_video_run", description: "Run the product-video asset workflow directly.", cliEquivalent: "product-video" }, - { name: "opendevbrowser_inspiredesign_run", description: "Run the inspiredesign workflow directly, including harvest query discovery and visual evidence capture.", cliEquivalent: "inspiredesign" }, + { name: "opendevbrowser_inspiredesign_run", description: "Run the inspiredesign workflow directly, including provider-scoped URL recovery, harvest query discovery, readiness guidance, and visual evidence capture.", cliEquivalent: "inspiredesign" }, { name: "opendevbrowser_canvas", description: "Execute a typed design-canvas command surface call.", cliEquivalent: "canvas" }, { name: "opendevbrowser_clone_page", description: "Export the active page into React code.", cliEquivalent: "clone-page" }, { name: "opendevbrowser_clone_component", description: "Export a component by ref into React code.", cliEquivalent: "clone-component" }, { name: "opendevbrowser_perf", description: "Collect browser performance metrics.", cliEquivalent: "perf" }, - { name: "opendevbrowser_screenshot", description: "Capture a page screenshot.", cliEquivalent: "screenshot" }, - { name: "opendevbrowser_screencast_start", description: "Start a browser replay screencast capture.", cliEquivalent: "screencast-start" }, + { name: "opendevbrowser_screenshot", description: "Capture a page screenshot and persist omitted outputs under .opendevbrowser/screenshot//capture.png.", cliEquivalent: "screenshot" }, + { name: "opendevbrowser_screencast_start", description: "Start a browser replay screencast capture and persist omitted outputs under .opendevbrowser/screencast/.", cliEquivalent: "screencast-start" }, { name: "opendevbrowser_screencast_stop", description: "Stop a browser replay screencast capture.", cliEquivalent: "screencast-stop" }, { name: "opendevbrowser_dialog", description: "Inspect or handle a JavaScript dialog.", cliEquivalent: "dialog" }, { name: "opendevbrowser_desktop_status", description: "Inspect public read-only desktop observation availability.", cliEquivalent: "desktop-status" }, diff --git a/src/tools/inspiredesign_run.ts b/src/tools/inspiredesign_run.ts index 1cbd404..61cc198 100644 --- a/src/tools/inspiredesign_run.ts +++ b/src/tools/inspiredesign_run.ts @@ -6,6 +6,7 @@ import { resolveProviderRuntime } from "./workflow-runtime"; import { resolveWorkflowToolOutputDir } from "./workflow-output"; import { CHALLENGE_AUTOMATION_MODES } from "../challenges/types"; import { DEFAULT_WORKFLOW_TRANSPORT_TIMEOUT_MS } from "../cli/transport-timeouts"; +import { validateProviderUrlSiteRecipeCompatibility } from "../guidance/recipes/site-recipe-validation"; import { captureInspiredesignReferenceFromManager } from "../inspiredesign/capture"; import { resolveInspiredesignCaptureMode } from "../inspiredesign/capture-mode"; @@ -49,11 +50,20 @@ export function createInspiredesignRunTool(deps: ToolDeps): ToolDefinition { if (args.query && args.harvest !== true) { throw new Error("query is only supported when harvest is true."); } + const isHarvest = args.harvest === true; if (args.providers && args.providers.length > 0 && !args.query) { - throw new Error("providers require query."); + if (!isHarvest) { + throw new Error("providers require query unless harvest uses compatible URL recovery."); + } + const compatibility = validateProviderUrlSiteRecipeCompatibility({ + providers: args.providers, + urls: args.urls ?? [] + }); + if (!compatibility.ok) { + throw new Error(compatibility.message); + } } const cookieSource = deps.config.get().providers?.cookieSource; - const isHarvest = args.harvest === true; if (isHarvest && !args.query && (!args.urls || args.urls.length === 0)) { throw new Error("inspiredesign harvest requires query or URLs."); } diff --git a/src/tools/product_video_run.ts b/src/tools/product_video_run.ts index d9e1eee..ed54852 100644 --- a/src/tools/product_video_run.ts +++ b/src/tools/product_video_run.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; import { tool } from "@opencode-ai/plugin"; import type { ToolDefinition } from "@opencode-ai/plugin"; import type { ToolDeps } from "./deps"; @@ -13,13 +16,19 @@ const challengeAutomationModeSchema = z.enum(CHALLENGE_AUTOMATION_MODES); async function captureScreenshotBuffer(deps: ToolDeps, url: string): Promise { let sessionId: string | null = null; + let captureDir: string | null = null; try { const launched = await deps.manager.launch({ headless: true, startUrl: url }); sessionId = launched.sessionId; - const screenshot = await deps.manager.screenshot(sessionId); + captureDir = await mkdtemp(join(tmpdir(), "odb-product-video-shot-")); + const capturePath = join(captureDir, "capture.png"); + const screenshot = await deps.manager.screenshot(sessionId, { path: capturePath }); + if (typeof screenshot.path === "string" && screenshot.path.length > 0) { + return await readFile(screenshot.path); + } if (typeof screenshot.base64 === "string" && screenshot.base64.length > 0) { return Buffer.from(screenshot.base64, "base64"); } @@ -32,6 +41,11 @@ async function captureScreenshotBuffer(deps: ToolDeps, url: string): Promise { + // Best effort cleanup. + }); + } } } diff --git a/tests/browser-manager.test.ts b/tests/browser-manager.test.ts index 9c2c5d8..b7f6b75 100644 --- a/tests/browser-manager.test.ts +++ b/tests/browser-manager.test.ts @@ -4062,6 +4062,33 @@ describe("BrowserManager", () => { }); }); + it("uses browser artifacts for omitted screencast output", async () => { + const nodes = [ + { ref: "r1", role: "button", name: "OK", tag: "button", selector: "[data-odb-ref=\"r1\"]" } + ]; + const { context, page } = createBrowserBundle(nodes); + + findChromeExecutable.mockResolvedValue("/bin/chrome"); + launchPersistentContext.mockResolvedValue(context); + usePathAwareScreenshot(page); + + const worktree = await mkdtemp(join(tmpdir(), "odb-manager-screencast-artifact-")); + const { BrowserManager } = await import("../src/browser/browser-manager"); + const manager = new BrowserManager(worktree, resolveConfig({})); + const launch = await manager.launch({ profile: "default" }); + + const screencast = await manager.startScreencast(launch.sessionId, { + intervalMs: 250, + maxFrames: 1 + }); + const result = await manager.stopScreencast(launch.sessionId, screencast.screencastId); + + expect(screencast.outputDir.startsWith(join(worktree, ".opendevbrowser", "screencast"))).toBe(true); + expect(screencast.artifact_path).toBe(screencast.outputDir); + expect(result.artifact_path).toBe(screencast.outputDir); + await expect(readFile(join(screencast.outputDir, "frames", "000001.png"), "utf8")).resolves.toBe("about:blank"); + }); + it("captures later screencast frames after same-target navigation", async () => { const nodes = [ { ref: "r1", role: "button", name: "OK", tag: "button", selector: "[data-odb-ref=\"r1\"]" } @@ -5765,8 +5792,12 @@ describe("BrowserManager", () => { const perf = await manager.perfMetrics(launch.sessionId); expect(perf.metrics[0]?.name).toBe("Nodes"); + usePathAwareScreenshot(page); const shot = await manager.screenshot(launch.sessionId); - expect(shot.base64).toBe(Buffer.from("image").toString("base64")); + expect(shot.base64).toBeUndefined(); + expect(shot.artifact_path?.startsWith(join("/tmp/project", ".opendevbrowser", "screenshot"))).toBe(true); + expect(shot.path).toBe(join(shot.artifact_path ?? "", "capture.png")); + await expect(readFile(shot.path ?? "", "utf8")).resolves.toBe("about:blank"); await manager.screenshot(launch.sessionId, { path: "/tmp/example.png" }); expect(page.screenshot).toHaveBeenCalledWith(expect.objectContaining({ path: "/tmp/example.png" })); @@ -5809,10 +5840,11 @@ describe("BrowserManager", () => { const shot = await manager.screenshot(result.sessionId, { targetId: result.activeTargetId }); expect(cdpSession.send).toHaveBeenCalledWith("Page.captureScreenshot", { format: "png" }); - expect(shot).toEqual({ - base64: Buffer.from("fallback-image").toString("base64"), - warnings: ["cdp_capture_fallback"] - }); + expect(shot.base64).toBeUndefined(); + expect(shot.artifact_path?.startsWith(join("/tmp/project", ".opendevbrowser", "screenshot"))).toBe(true); + expect(shot.path).toBe(join(shot.artifact_path ?? "", "capture.png")); + expect(shot.warnings).toEqual(["cdp_capture_fallback"]); + await expect(readFile(shot.path ?? "", "utf8")).resolves.toBe("fallback-image"); }); it("falls back to CDP screenshot capture when legacy relay screenshots hang", async () => { @@ -5853,10 +5885,12 @@ describe("BrowserManager", () => { try { const shotPromise = manager.screenshot(result.sessionId, { targetId: result.activeTargetId }); await vi.advanceTimersByTimeAsync(5000); - await expect(shotPromise).resolves.toEqual({ - base64: Buffer.from("fallback-image-hang").toString("base64"), - warnings: ["cdp_capture_fallback"] - }); + const shot = await shotPromise; + expect(shot.base64).toBeUndefined(); + expect(shot.artifact_path?.startsWith(join("/tmp/project", ".opendevbrowser", "screenshot"))).toBe(true); + expect(shot.path).toBe(join(shot.artifact_path ?? "", "capture.png")); + expect(shot.warnings).toEqual(["cdp_capture_fallback"]); + await expect(readFile(shot.path ?? "", "utf8")).resolves.toBe("fallback-image-hang"); } finally { vi.useRealTimers(); } @@ -5891,8 +5925,12 @@ describe("BrowserManager", () => { const launch = await manager.launch({ profile: "default" }); await manager.snapshot(launch.sessionId); + usePathAwareScreenshot(page); const refShot = await manager.screenshot(launch.sessionId, { ref: "r1" }); - expect(refShot.base64).toBe(Buffer.from("image").toString("base64")); + expect(refShot.base64).toBeUndefined(); + expect(refShot.artifact_path?.startsWith(join("/tmp/project", ".opendevbrowser", "screenshot"))).toBe(true); + expect(refShot.path).toBe(join(refShot.artifact_path ?? "", "capture.png")); + await expect(readFile(refShot.path ?? "", "utf8")).resolves.toBe("about:blank"); expect(locator.scrollIntoViewIfNeeded).toHaveBeenCalled(); expect(screenshotClipDeclarations[0]).not.toContain("window.scrollX"); expect(screenshotClipDeclarations[0]).not.toContain("window.scrollY"); diff --git a/tests/browser-output-artifacts.test.ts b/tests/browser-output-artifacts.test.ts new file mode 100644 index 0000000..a82b07c --- /dev/null +++ b/tests/browser-output-artifacts.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + BROWSER_SCREENCAST_ARTIFACT_NAMESPACE, + BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE, + createBrowserOutputArtifactDirectory +} from "../src/providers/browser-output-artifacts"; + +const cleanupPaths: string[] = []; + +async function makeWorkspace(): Promise { + const workspace = await mkdtemp(join(tmpdir(), "odb-browser-artifacts-")); + cleanupPaths.push(workspace); + return workspace; +} + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const target = cleanupPaths.pop(); + if (target) { + await rm(target, { recursive: true, force: true }); + } + } +}); + +describe("browser output artifacts", () => { + it("creates omitted screenshot artifact directories under the workflow root", async () => { + const workspace = await makeWorkspace(); + const artifact = createBrowserOutputArtifactDirectory({ + workspaceRoot: workspace, + namespace: BROWSER_SCREENSHOT_ARTIFACT_NAMESPACE + }); + + expect(artifact.namespace).toBe("screenshot"); + expect(artifact.artifactPath).toBe(join(workspace, ".opendevbrowser", "screenshot", artifact.runId)); + expect((await stat(artifact.artifactPath)).isDirectory()).toBe(true); + }); + + it("creates omitted screencast artifact directories under the workflow root", async () => { + const workspace = await makeWorkspace(); + const artifact = createBrowserOutputArtifactDirectory({ + workspaceRoot: workspace, + namespace: BROWSER_SCREENCAST_ARTIFACT_NAMESPACE + }); + + expect(artifact.namespace).toBe("screencast"); + expect(artifact.artifactPath).toBe(join(workspace, ".opendevbrowser", "screencast", artifact.runId)); + expect((await stat(artifact.artifactPath)).isDirectory()).toBe(true); + }); + + it("rejects unsafe namespaces", async () => { + const workspace = await makeWorkspace(); + + expect(() => createBrowserOutputArtifactDirectory({ + workspaceRoot: workspace, + namespace: " " + })).toThrow("Browser output artifact namespace cannot be empty."); + + expect(() => createBrowserOutputArtifactDirectory({ + workspaceRoot: workspace, + namespace: "../escape" + })).toThrow("Browser output artifact namespace can only contain lowercase letters, numbers, underscores, and hyphens."); + }); +}); diff --git a/tests/browser-screencast-recorder.test.ts b/tests/browser-screencast-recorder.test.ts index b469037..3a87283 100644 --- a/tests/browser-screencast-recorder.test.ts +++ b/tests/browser-screencast-recorder.test.ts @@ -68,6 +68,8 @@ describe("BrowserScreencastRecorder", () => { const session = await recorder.start(); expect(session.intervalMs).toBe(250); expect(session.maxFrames).toBe(2); + expect(session.outputDir.startsWith(path.join(worktree, ".opendevbrowser", "screencast"))).toBe(true); + expect(session.artifact_path).toBe(session.outputDir); await vi.advanceTimersByTimeAsync(250); @@ -84,6 +86,8 @@ describe("BrowserScreencastRecorder", () => { expect(result).toMatchObject({ sessionId: "session-1", targetId: "target-1", + outputDir: session.outputDir, + artifact_path: session.outputDir, endedReason: "max_frames_reached", frameCount: 2 }); @@ -94,6 +98,9 @@ describe("BrowserScreencastRecorder", () => { expect(frameFiles).toEqual(["000001.png", "000002.png"]); expect(replayHtml).toContain(result.screencastId); expect(replayHtml).toContain("frames/000001.png"); + expect(await stat(path.join(result.outputDir, "replay.json"))).toMatchObject({ isFile: expect.any(Function) }); + expect(await stat(path.join(result.outputDir, "replay.html"))).toMatchObject({ isFile: expect.any(Function) }); + expect(await stat(path.join(result.outputDir, "frames"))).toMatchObject({ isDirectory: expect.any(Function) }); expect(await stat(result.previewPath ?? "")).toMatchObject({ isFile: expect.any(Function) }); }); @@ -245,7 +252,9 @@ describe("BrowserScreencastRecorder", () => { }; expect(session.outputDir).toBe(path.resolve(worktree, "casts/test-run")); + expect(session.artifact_path).toBeUndefined(); expect(session.warnings).toEqual(["relative-warning"]); + expect(result.artifact_path).toBeUndefined(); expect(result.endedReason).toBe("max_frames_reached"); expect(result.frameCount).toBe(1); expect(manifest.initialPage).toEqual({ url: "https://example.com/replay" }); diff --git a/tests/cli-screencast.test.ts b/tests/cli-screencast.test.ts index e4e19b2..0641ea2 100644 --- a/tests/cli-screencast.test.ts +++ b/tests/cli-screencast.test.ts @@ -81,6 +81,36 @@ describe("screencast CLI commands", () => { }); }); + it("returns omitted screencast artifact output from daemon calls", async () => { + callDaemon.mockResolvedValue({ + screencastId: "cast-omitted", + sessionId: "s1", + outputDir: "/workspace/.opendevbrowser/screencast/run-1", + artifact_path: "/workspace/.opendevbrowser/screencast/run-1" + }); + + const result = await runScreencastStart(makeArgs("screencast-start", [ + "--session-id", + "s1" + ])); + + expect(callDaemon).toHaveBeenCalledWith("page.screencast.start", { + sessionId: "s1" + }, { + timeoutMs: DEFAULT_SCREENSHOT_TRANSPORT_TIMEOUT_MS + }); + expect(result).toEqual({ + success: true, + message: "Screencast started.", + data: { + screencastId: "cast-omitted", + sessionId: "s1", + outputDir: "/workspace/.opendevbrowser/screencast/run-1", + artifact_path: "/workspace/.opendevbrowser/screencast/run-1" + } + }); + }); + it("stops a screencast with the default transport timeout", async () => { callDaemon.mockResolvedValue({ screencastId: "cast-1", endedReason: "stopped" }); diff --git a/tests/cli-screenshot.test.ts b/tests/cli-screenshot.test.ts index 64cae9c..3ec0ede 100644 --- a/tests/cli-screenshot.test.ts +++ b/tests/cli-screenshot.test.ts @@ -112,6 +112,27 @@ describe("screenshot CLI command", () => { }); }); + it("returns omitted screenshot artifact output from daemon calls", async () => { + callDaemon.mockResolvedValue({ + path: "/workspace/.opendevbrowser/screenshot/run-1/capture.png", + artifact_path: "/workspace/.opendevbrowser/screenshot/run-1" + }); + + const result = await runScreenshot(makeArgs([ + "--session-id", + "s1" + ])); + + expect(result).toEqual({ + success: true, + message: "Screenshot captured.", + data: { + path: "/workspace/.opendevbrowser/screenshot/run-1/capture.png", + artifact_path: "/workspace/.opendevbrowser/screenshot/run-1" + } + }); + }); + it("passes target-id through screenshot calls", async () => { callDaemon.mockResolvedValue({ path: "/tmp/capture.png" }); @@ -130,7 +151,7 @@ describe("screenshot CLI command", () => { }); it("passes ref and full-page through screenshot calls", async () => { - callDaemon.mockResolvedValue({ base64: "image" }); + callDaemon.mockResolvedValue({ path: "/tmp/capture.png" }); await runScreenshot(makeArgs([ "--session-id", diff --git a/tests/cli-workflows.test.ts b/tests/cli-workflows.test.ts index 9af0001..8228dcc 100644 --- a/tests/cli-workflows.test.ts +++ b/tests/cli-workflows.test.ts @@ -592,12 +592,58 @@ describe("workflow CLI commands", () => { ]))).rejects.toThrow("--query is only supported by inspiredesign harvest"); }); - it("rejects inspiredesign providers without query", async () => { + it("accepts compatible Pinterest provider URL recovery", async () => { + callDaemon.mockResolvedValue({ ok: true }); + + await runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--provider=social/pinterest", + "--url=https://www.pinterest.com/pin/27654985208435505/" + ])); + + expect(callDaemon).toHaveBeenCalledWith("inspiredesign.run", expect.objectContaining({ + harvest: true, + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"], + captureMode: "deep" + })); + }); + + it("rejects inspiredesign providers without query or compatible URLs", async () => { await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ "harvest", "--brief=Build a docs landing page contract", "--provider=web/default" - ]))).rejects.toThrow("--provider requires --query"); + ]))).rejects.toThrow("Provider-scoped URL recovery requires at least one URL"); + + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--provider=web/default", + "--url=https://www.pinterest.com/pin/27654985208435505/" + ]))).rejects.toThrow("Provider web/default does not support URL-only site recipe recovery"); + }); + + it("surfaces inspiredesign readiness in completion messages", async () => { + for (const readiness of ["diagnostic_only", "ready"]) { + callDaemon.mockResolvedValueOnce({ + ok: true, + meta: { + nextStepGuidance: { + readiness + } + } + }); + + const result = await runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=premium docs references" + ])); + + expect(result.message).toContain(`readiness=${readiness}`); + } }); it("rejects invalid inspiredesign harvest bounds and visual modes", async () => { diff --git a/tests/daemon-commands.integration.test.ts b/tests/daemon-commands.integration.test.ts index 35f3469..f465451 100644 --- a/tests/daemon-commands.integration.test.ts +++ b/tests/daemon-commands.integration.test.ts @@ -134,6 +134,7 @@ const makeCore = (overrides: { debugTraceSnapshot: vi.fn(), cookieImport: vi.fn(), cookieList: vi.fn(), + screenshot: vi.fn(), startScreencast: vi.fn(), stopScreencast: vi.fn() }; @@ -382,6 +383,31 @@ describe("daemon-commands integration", () => { expect(core.desktopRuntime.accessibilitySnapshot).toHaveBeenCalledWith("accessibility", "window-1"); }); + it("routes omitted screenshot output through daemon without base64 payloads", async () => { + const core = makeCore(); + core.manager.status.mockResolvedValue({ mode: "managed", activeTargetId: "target-1" }); + core.manager.screenshot.mockResolvedValue({ + path: "/workspace/.opendevbrowser/screenshot/run-1/capture.png", + artifact_path: "/workspace/.opendevbrowser/screenshot/run-1" + }); + + const response = await handleDaemonCommand(core, { + name: "page.screenshot", + params: { + sessionId: "session-1", + clientId: "client-1", + targetId: "target-1" + } + }); + + expect(response).toEqual({ + path: "/workspace/.opendevbrowser/screenshot/run-1/capture.png", + artifact_path: "/workspace/.opendevbrowser/screenshot/run-1" + }); + expect(response).not.toHaveProperty("base64"); + expect(core.manager.screenshot).toHaveBeenCalledWith("session-1", { targetId: "target-1" }); + }); + it("rejects invalid daemon numeric bounds before manager calls", async () => { const core = makeCore(); core.manager.status.mockResolvedValue({ mode: "managed", activeTargetId: null }); @@ -2126,7 +2152,17 @@ describe("daemon-commands integration", () => { harvest: true, providers: ["web/default"] } - })).rejects.toThrow("providers require query"); + })).rejects.toThrow("Provider-scoped URL recovery requires at least one URL"); + + await expect(handleDaemonCommand(core, { + name: "inspiredesign.run", + params: { + brief: "Workspace design", + harvest: true, + providers: ["web/default"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + } + })).rejects.toThrow("Provider web/default does not support URL-only site recipe recovery"); await expect(handleDaemonCommand(core, { name: "inspiredesign.run", @@ -2157,6 +2193,20 @@ describe("daemon-commands integration", () => { })).rejects.toThrow("harvest requires query or URL references"); }); + it("accepts daemon inspiredesign Pinterest provider URL recovery", async () => { + const core = makeCore(); + + await handleDaemonCommand(core, { + name: "inspiredesign.run", + params: { + brief: "Workspace design", + harvest: true, + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + } + }); + }); + it("uses core cache root for omitted daemon inspiredesign output roots", async () => { const core = makeCore(); const workflowSpy = vi.spyOn(workflowModule, "runInspiredesignWorkflow").mockResolvedValue({ diff --git a/tests/guidance-site-recipe-validation.test.ts b/tests/guidance-site-recipe-validation.test.ts new file mode 100644 index 0000000..d0763b0 --- /dev/null +++ b/tests/guidance-site-recipe-validation.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { validateProviderUrlSiteRecipeCompatibility } from "../src/guidance/recipes/site-recipe-validation"; + +const PIN_URL = "https://www.pinterest.com/pin/27654985208435505/"; +const browserNativeDiscovery = { + buildSearchUrl: (query: string): string => `https://example.com/search?q=${encodeURIComponent(query)}` +}; + +describe("site recipe URL compatibility validation", () => { + it("accepts canonical Pinterest provider and Pinterest URLs", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: [PIN_URL] + })).toEqual({ ok: true, recipeId: "social/pinterest" }); + }); + + it("accepts Pinterest provider alias and Pinterest URLs", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["pinterest"], + urls: [PIN_URL] + })).toEqual({ ok: true, recipeId: "social/pinterest" }); + }); + + it("rejects Pinterest providers paired with non-Pinterest URLs", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: ["https://example.com/reference"] + })).toEqual({ + ok: false, + message: "URL https://example.com/reference does not match a browser-native site recipe for provider-scoped recovery." + }); + }); + + it("rejects generic providers paired with URLs", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["web/default"], + urls: [PIN_URL] + })).toEqual({ + ok: false, + message: "Provider web/default does not support URL-only site recipe recovery." + }); + }); + + it("rejects missing providers and URLs with clear messages", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: [], + urls: [PIN_URL] + })).toEqual({ + ok: false, + message: "Provider-scoped URL recovery requires at least one provider." + }); + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: [] + })).toEqual({ + ok: false, + message: "Provider-scoped URL recovery requires at least one URL." + }); + }); + + it("rejects multiple-provider mismatches", () => { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest", "web/default"], + urls: [PIN_URL] + })).toEqual({ + ok: false, + message: "Provider web/default does not support URL-only site recipe recovery." + }); + }); + + it("rejects provider and URL recipes that resolve to different site recipes", async () => { + vi.resetModules(); + vi.doMock("../src/guidance/recipes/site-registry", () => ({ + resolveSiteRecipeForProvider: vi.fn(() => ({ id: "social/pinterest", browserNativeDiscovery })), + resolveSiteRecipeForUrl: vi.fn(() => ({ id: "social/example", browserNativeDiscovery })) + })); + + const { validateProviderUrlSiteRecipeCompatibility: validateWithMismatchedRecipes } = await import( + "../src/guidance/recipes/site-recipe-validation" + ); + + expect(validateWithMismatchedRecipes({ + providers: ["social/pinterest"], + urls: [PIN_URL] + })).toEqual({ + ok: false, + message: "Provider-scoped URL recovery requires every provider and URL to resolve to the same site recipe." + }); + + vi.doUnmock("../src/guidance/recipes/site-registry"); + vi.resetModules(); + }); + + it("rejects matching site recipes without browser-native discovery support", async () => { + vi.resetModules(); + vi.doMock("../src/guidance/recipes/site-registry", () => ({ + resolveSiteRecipeForProvider: vi.fn(() => ({ id: "social/example" })), + resolveSiteRecipeForUrl: vi.fn(() => ({ id: "social/example" })) + })); + + const { validateProviderUrlSiteRecipeCompatibility: validateWithoutBrowserNativeDiscovery } = await import( + "../src/guidance/recipes/site-recipe-validation" + ); + + expect(validateWithoutBrowserNativeDiscovery({ + providers: ["social/example"], + urls: ["https://example.com/reference"] + })).toEqual({ + ok: false, + message: "Provider social/example does not support browser-native URL-only site recipe recovery." + }); + + vi.doUnmock("../src/guidance/recipes/site-registry"); + vi.resetModules(); + }); + + it("rejects unresolved recipe ids from malformed registry responses", async () => { + vi.resetModules(); + vi.doMock("../src/guidance/recipes/site-registry", () => ({ + resolveSiteRecipeForProvider: vi.fn(() => ({ browserNativeDiscovery })), + resolveSiteRecipeForUrl: vi.fn(() => ({ browserNativeDiscovery })) + })); + + const { validateProviderUrlSiteRecipeCompatibility: validateWithMalformedRegistry } = await import( + "../src/guidance/recipes/site-recipe-validation" + ); + + expect(validateWithMalformedRegistry({ + providers: ["social/pinterest"], + urls: [PIN_URL] + })).toEqual({ + ok: false, + message: "Provider-scoped URL recovery could not resolve a site recipe." + }); + + vi.doUnmock("../src/guidance/recipes/site-registry"); + vi.resetModules(); + }); +}); diff --git a/tests/ops-browser-manager.test.ts b/tests/ops-browser-manager.test.ts index b40eedc..6c2bc73 100644 --- a/tests/ops-browser-manager.test.ts +++ b/tests/ops-browser-manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { mkdtemp } from "fs/promises"; +import { mkdtemp, readFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import type { OpenDevBrowserConfig } from "../src/config"; @@ -2820,14 +2820,16 @@ describe("OpsBrowserManager", () => { json: async () => ({ relayPort: 8787, pairingRequired: false, instanceId: "relay-1", epoch: 1 }) })); - const manager = new OpsBrowserManager({ connectRelay: vi.fn() } as never, makeConfig()); + const worktree = await mkdtemp(join(tmpdir(), "odb-ops-screenshot-warning-")); + const manager = new OpsBrowserManager({ connectRelay: vi.fn() } as never, makeConfig(), worktree); await manager.connectRelay("ws://127.0.0.1:8787/ops"); const result = await manager.screenshot("ops-9"); - expect(result).toEqual({ - base64: Buffer.from("image").toString("base64"), - warnings: ["visible_only_fallback"] - }); + expect(result.base64).toBeUndefined(); + expect(result.artifact_path?.startsWith(join(worktree, ".opendevbrowser", "screenshot"))).toBe(true); + expect(result.path).toBe(join(result.artifact_path ?? "", "capture.png")); + expect(result.warnings).toEqual(["visible_only_fallback"]); + await expect(readFile(result.path ?? "", "utf8")).resolves.toBe("image"); }); it("routes upload and dialog through ops requests", async () => { @@ -3015,23 +3017,28 @@ describe("OpsBrowserManager", () => { json: async () => ({ relayPort: 8787, pairingRequired: false, instanceId: "relay-1", epoch: 1 }) })); - const manager = new OpsBrowserManager({ connectRelay: vi.fn() } as never, makeConfig()); + const worktree = await mkdtemp(join(tmpdir(), "odb-ops-screenshot-lanes-")); + const manager = new OpsBrowserManager({ connectRelay: vi.fn() } as never, makeConfig(), worktree); await manager.connectRelay("ws://127.0.0.1:8787/ops"); - await expect(manager.screenshot("ops-lanes", { + const refShot = await manager.screenshot("ops-lanes", { targetId: "tab-1", ref: "r4" - })).resolves.toEqual({ - base64: Buffer.from("lane-image").toString("base64"), - warnings: ["captured"] }); + expect(refShot.base64).toBeUndefined(); + expect(refShot.artifact_path?.startsWith(join(worktree, ".opendevbrowser", "screenshot"))).toBe(true); + expect(refShot.path).toBe(join(refShot.artifact_path ?? "", "capture.png")); + expect(refShot.warnings).toEqual(["captured"]); + await expect(readFile(refShot.path ?? "", "utf8")).resolves.toBe("lane-image"); - await expect(manager.screenshot("ops-lanes", { + const fullPageShot = await manager.screenshot("ops-lanes", { fullPage: true - })).resolves.toEqual({ - base64: Buffer.from("lane-image").toString("base64"), - warnings: ["captured"] }); + expect(fullPageShot.base64).toBeUndefined(); + expect(fullPageShot.artifact_path?.startsWith(join(worktree, ".opendevbrowser", "screenshot"))).toBe(true); + expect(fullPageShot.path).toBe(join(fullPageShot.artifact_path ?? "", "capture.png")); + expect(fullPageShot.warnings).toEqual(["captured"]); + await expect(readFile(fullPageShot.path ?? "", "utf8")).resolves.toBe("lane-image"); await expect(manager.dialog("ops-lanes")).resolves.toEqual({ dialog: { open: false, targetId: "tab-1" } diff --git a/tests/providers-inspiredesign-contract.test.ts b/tests/providers-inspiredesign-contract.test.ts index 9c6c263..aa251ff 100644 --- a/tests/providers-inspiredesign-contract.test.ts +++ b/tests/providers-inspiredesign-contract.test.ts @@ -25,6 +25,7 @@ import { buildInspiredesignNextStep } from "../src/inspiredesign/handoff"; import { + buildInspiredesignRankedArtifactPatternBoard, buildInspiredesignReferencePatternBoard, hasInspiredesignUsableReferenceEvidence } from "../src/inspiredesign/reference-pattern-board"; @@ -1228,7 +1229,7 @@ describe("inspiredesign packet + renderer", () => { } }))).toBe(false); - expect(hasInspiredesignUsableReferenceEvidence(makeReference({ + const chromeOnlyReference = makeReference({ url: "https://www.pinterest.com/pin/955748352150564605/", fetchStatus: "failed", captureStatus: "captured", @@ -1243,7 +1244,21 @@ describe("inspiredesign packet + renderer", () => { warnings: [] } } - }))).toBe(false); + }); + const chromeOnlyBoard = buildInspiredesignReferencePatternBoard( + "chrome-only", + makeBriefFormat(), + [chromeOnlyReference], + "Design a premium landing page prototype for a design agency studio." + ); + + expect(hasInspiredesignUsableReferenceEvidence(chromeOnlyReference)).toBe(false); + expect(chromeOnlyBoard.rejectedReferences[0]).toEqual(expect.objectContaining({ + captured: true, + diagnosticReasons: expect.arrayContaining(["interface_chrome_shell"]), + capturedButRejectedReason: expect.stringContaining("interface_chrome_shell"), + evidenceGap: expect.stringContaining("diagnostic browser chrome") + })); }); it("keeps screenshot-backed Pinterest pin references usable when page chrome surrounds clean pin metadata", () => { @@ -3092,7 +3107,10 @@ describe("inspiredesign packet + renderer", () => { visualEvidence: packet.visualEvidence, screenshotIndex: packet.screenshotIndex, rankedReferences: packet.rankedReferences, - referencePatternBoard: packet.generationPlan.referencePatternBoard, + referencePatternBoard: buildInspiredesignRankedArtifactPatternBoard( + packet.generationPlan.referencePatternBoard, + packet.referencePatternBoard + ), metaPromptMarkdown: packet.metaPromptMarkdown, meta: { requestId: "ranked-artifact" } }); @@ -3100,7 +3118,7 @@ describe("inspiredesign packet + renderer", () => { expect(rankedReferencesFile?.content).toMatchObject({ references: [expect.objectContaining({ id: "usable-reference", rank: 1 })], - rejectedReferences: [], + rejectedReferences: [expect.objectContaining({ id: "rejected-reference" })], qualitySummary: expect.objectContaining({ rejectedReferenceCount: 1 }), synthesis: expect.objectContaining({ dominantDirection: expect.any(String), diff --git a/tests/providers-inspiredesign-workflow.test.ts b/tests/providers-inspiredesign-workflow.test.ts index cc29443..b54c03a 100644 --- a/tests/providers-inspiredesign-workflow.test.ts +++ b/tests/providers-inspiredesign-workflow.test.ts @@ -910,6 +910,22 @@ describe("inspiredesign workflow", () => { })); }); + it("accepts Pinterest provider URL recovery without query", async () => { + const output = await runInspiredesignWorkflow(toRuntime({}), { + brief: "Design a visual harvest landing page", + harvest: true, + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"], + mode: "json" + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.selection).toEqual(expect.objectContaining({ + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505"] + })); + }); + it("rejects invalid workflow harvest discovery inputs without clamping", async () => { const runtime = toRuntime({}); await expect(runInspiredesignWorkflow(runtime, { @@ -920,7 +936,13 @@ describe("inspiredesign workflow", () => { brief: "Design a visual harvest landing page", harvest: true, providers: ["web/default"] - })).rejects.toThrow("providers require query"); + })).rejects.toThrow("Provider-scoped URL recovery requires at least one URL"); + await expect(runInspiredesignWorkflow(runtime, { + brief: "Design a visual harvest landing page", + harvest: true, + providers: ["web/default"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + })).rejects.toThrow("Provider web/default does not support URL-only site recipe recovery"); await expect(runInspiredesignWorkflow(runtime, { brief: "Design a visual harvest landing page", harvest: true, @@ -2103,8 +2125,8 @@ describe("inspiredesign workflow", () => { records: [ normalizeRecord("social/pinterest", "social", { url: input.url, - title: "Pinterest navigation shell", - content: "Pin card your profile when autocomplete results are available" + title: "[r1] link \"Skip to content\" [r2] link \"Your profile\" [r3] button \"Accounts\" [r4] link \"Home\"", + content: "[r1] link \"Skip to content\" [r2] link \"Your profile\" [r3] button \"Accounts\" [r4] link \"Home\" [r5] link \"Your boards\" [r6] button \"Settings & Support\" [r7] button \"Updates\" [r8] button \"Messages\"" }) ] }); @@ -2115,7 +2137,7 @@ describe("inspiredesign workflow", () => { } writeFileSync(options.visualEvidencePath, Buffer.from("png bytes")); return { - ...makeCapture("Pin card your profile when autocomplete results are available"), + ...makeCapture("[r1] link \"Skip to content\" [r2] link \"Your profile\" [r3] button \"Accounts\" [r4] link \"Home\""), visual: { status: "captured", kind: "viewport", @@ -2148,6 +2170,7 @@ describe("inspiredesign workflow", () => { const rankedReferences = JSON.parse(readFileSync(join(artifactPath, "ranked-references.json"), "utf8")) as { qualitySummary: { rankedReferenceCount: number; rejectedReferenceCount: number; missingScreenshotCount: number }; references: unknown[]; + rejectedReferences: Array<{ captured?: boolean; diagnosticReasons?: string[]; capturedButRejectedReason?: string }>; }; const screenshotIndex = JSON.parse(readFileSync(join(artifactPath, "screenshot-index.json"), "utf8")) as { screenshots: Array<{ path: string }>; @@ -2173,7 +2196,14 @@ describe("inspiredesign workflow", () => { rejectedReferenceCount: 5, missingScreenshotCount: 0 }, - references: [] + references: [], + rejectedReferences: expect.arrayContaining([ + expect.objectContaining({ + captured: true, + diagnosticReasons: expect.arrayContaining(["interface_chrome_shell"]), + capturedButRejectedReason: expect.stringContaining("interface_chrome_shell") + }) + ]) }); expect(screenshotIndex.screenshots).toHaveLength(5); expect(designMarkdown).toContain( @@ -2182,6 +2212,9 @@ describe("inspiredesign workflow", () => { expect(designMarkdown).not.toContain("Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`"); expect(handoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); expect(handoff.nextStepGuidance.readiness).toBe("diagnostic_only"); + expect(handoff.nextStepGuidance.commands[0]?.command).toContain("--provider social/pinterest --url"); + expect(handoff.nextStepGuidance.commands[0]?.command).toContain(pinUrls[0]); + expect(handoff.nextStepGuidance.commands[0]?.command).not.toContain("--query"); }); it("preserves generic recovery when mixed standard provider search throws", async () => { diff --git a/tests/tools-workflows-branches.test.ts b/tests/tools-workflows-branches.test.ts index 6f96ce2..6adbce1 100644 --- a/tests/tools-workflows-branches.test.ts +++ b/tests/tools-workflows-branches.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; import { describe, expect, it, vi } from "vitest"; import { ConfigStore, resolveConfig } from "../src/config"; import { buildMacroRuntimeInit } from "../src/macros/execute-runtime"; @@ -207,6 +210,31 @@ describe("workflow tool branch coverage", () => { }); const { createProductVideoRunTool } = await import("../src/tools/product_video_run"); + const captureFileDir = await mkdtemp(join(tmpdir(), "odb-product-video-path-shot-")); + try { + const capturePath = join(captureFileDir, "capture.png"); + await writeFile(capturePath, Buffer.from([4, 5, 6])); + const depsWithPathScreenshot = makeDeps({ + manager: { + launch: vi.fn().mockResolvedValue({ sessionId: "session-path" }), + screenshot: vi.fn().mockResolvedValue({ path: capturePath }), + disconnect: vi.fn().mockResolvedValue(undefined) + } + }); + const toolWithPathScreenshot = createProductVideoRunTool(depsWithPathScreenshot as never); + + const pathScreenshotResult = parse(await toolWithPathScreenshot.execute({ + product_url: "https://example.com/product", + include_screenshots: true + } as never)); + + expect(pathScreenshotResult.ok).toBe(true); + expect(depsWithPathScreenshot.manager.screenshot).toHaveBeenCalledTimes(1); + expect(depsWithPathScreenshot.manager.disconnect).toHaveBeenCalledTimes(1); + } finally { + await rm(captureFileDir, { recursive: true, force: true }); + } + const toolA = createProductVideoRunTool(depsWithEmptyScreenshot as never); const emptyScreenshotResult = parse(await toolA.execute({ diff --git a/tests/tools-workflows.test.ts b/tests/tools-workflows.test.ts index e53e5ea..151d1f3 100644 --- a/tests/tools-workflows.test.ts +++ b/tests/tools-workflows.test.ts @@ -592,7 +592,24 @@ describe("workflow tools", () => { expect(deps.providerRuntime.search).not.toHaveBeenCalled(); }); - it("rejects inspiredesign tool providers without a query", async () => { + it("accepts inspiredesign tool Pinterest provider URL recovery", async () => { + const deps = makeDeps(); + const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run"); + const tool = createInspiredesignRunTool(deps as never); + + const response = parse(await tool.execute({ + brief: "Design a premium docs website", + harvest: true, + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + } as never)); + + expect(response.ok).toBe(true); + expect(deps.providerRuntime.search).not.toHaveBeenCalled(); + expect(deps.providerRuntime.fetch).toHaveBeenCalled(); + }); + + it("rejects inspiredesign tool providers without a query or compatible URL", async () => { const deps = makeDeps(); const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run"); const tool = createInspiredesignRunTool(deps as never); @@ -606,9 +623,22 @@ describe("workflow tools", () => { expect(response.ok).toBe(false); expect(response.error).toEqual({ code: "inspiredesign_run_failed", - message: "providers require query." + message: "Provider-scoped URL recovery requires at least one URL." }); expect(deps.providerRuntime.search).not.toHaveBeenCalled(); + + const genericUrlResponse = parse(await tool.execute({ + brief: "Design a premium docs website", + harvest: true, + providers: ["web/default"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + } as never)); + + expect(genericUrlResponse.ok).toBe(false); + expect(genericUrlResponse.error).toEqual({ + code: "inspiredesign_run_failed", + message: "Provider web/default does not support URL-only site recipe recovery." + }); }); it("rejects inspiredesign tool harvest without query or URLs", async () => {