diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index eebb07129..b745aefea 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -3,6 +3,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useSession } from 'next-auth/react'; import { Download, GitPullRequest, Hammer, Play, Rocket } from 'lucide-react'; import { formatSeconds, parseTimestampToSeconds, extractYouTubeId } from '@/lib/timestamp'; import { applyPackStackChecks, compileLinkedSop, type LinkedSop } from '@/lib/linked-sop'; @@ -16,7 +17,7 @@ import { import { clsx } from 'clsx'; import Nav from '@/components/Nav'; import { StudioAuthNavLink } from '@/components/StudioAuthNavLink'; -import { dashboardPersistenceSucceeded, useDashboardStore } from '@/store/dashboard-store'; +import { dashboardPersistenceSucceeded, useDashboardStore, type Video } from '@/store/dashboard-store'; import GroundedSpecReview from '@/components/GroundedSpecReview'; import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'; import { @@ -107,7 +108,7 @@ import { } from '@/lib/gate-transition'; import { CANONICAL_STUDIO_PATH } from '@/lib/auth-paths'; import type { ExtractedEvent } from '@/lib/types'; -import type { VideoPackArchitecture, VideoPackArtifact } from '@/lib/video-pack-types'; +import type { VideoPackArchitecture, VideoPackArtifact, VideoPackStackTool } from '@/lib/video-pack-types'; import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; import StudioIdeShell from '@/components/studio/StudioIdeShell'; import StudioIdeChat from '@/components/studio/StudioIdeChat'; @@ -117,6 +118,16 @@ import { sopStepsFromPack, } from '@/lib/emit-app-builder-sandbox'; +function actPackKey(video: Video | undefined): string | null { + if (!video?.videoPack) return null; + return JSON.stringify([ + video.id, + video.url, + video.videoPack.packId, + video.videoPack.sourceHash, + ]); +} + const FIXTURE = 'https://www.youtube.com/watch?v=auJzb1D-fag'; function gateDecisionChipClass(decision: GateDecision): string { @@ -196,11 +207,13 @@ async function tryExtractEvents(input: { function PackWorkbench({ architecture, artifacts, + tools, onExport, canExport, }: { architecture: VideoPackArchitecture | null; artifacts: VideoPackArtifact[]; + tools: VideoPackStackTool[]; onExport: () => void; canExport: boolean; }) { @@ -262,6 +275,16 @@ function PackWorkbench({ ))} ) : null} + {tools.length > 0 ? ( + + ) : null} ); } @@ -302,6 +325,7 @@ export default function OneLoopStudio({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const { data: session } = useSession(); const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -309,6 +333,8 @@ export default function OneLoopStudio({ const [actBusy, setActBusy] = useState(false); const runAbortRef = useRef(null); const [workflowActions, setWorkflowActions] = useState(null); + const [workflowPackKey, setWorkflowPackKey] = useState(null); + const actAttemptRef = useRef(0); const [actRunId, setActRunId] = useState(null); const [usedSameRun, setUsedSameRun] = useState(false); const [buildBusy, setBuildBusy] = useState(false); @@ -338,6 +364,9 @@ export default function OneLoopStudio({ const selectedVideoId = useDashboardStore((s) => s.selectedVideoId); const videos = useDashboardStore((s) => s.videos); const selected = videos.find((v) => v.id === selectedVideoId); + const selectedActPackKey = actPackKey(selected); + const scopedWorkflowActions = workflowPackKey === selectedActPackKey ? workflowActions : null; + const scopedActRunId = workflowPackKey === selectedActPackKey ? actRunId : null; const scopedPackLiveUrl = studioPackLiveReceiptForSelection({ selectedVideoId, receiptVideoId: packLiveReceiptVideoId, @@ -387,6 +416,15 @@ export default function OneLoopStudio({ linkedSop?.entities, ); + useEffect(() => { + actAttemptRef.current += 1; + setWorkflowActions(null); + setWorkflowPackKey(null); + setActRunId(null); + setActBusy(false); + setUsedSameRun(false); + }, [selectedActPackKey]); + useEffect(() => { setCompletedChecks([]); setApprovedSpecIds([]); @@ -406,12 +444,13 @@ export default function OneLoopStudio({ return () => window.clearTimeout(timer); }, [exportToast]); - const holdReason = deployHoldReason(linkedSop, completedChecks, 'anonymous'); + const viewer = session?.user ? 'signed-in' : 'anonymous'; + const holdReason = deployHoldReason(linkedSop, completedChecks, viewer); const officialTemplate = pickOfficialTemplate(linkedSop); const stackCheckViews = stackChecks.map((item) => { - const status = stackCheckStatus(item, completedChecks, 'anonymous'); + const status = stackCheckStatus(item, completedChecks, viewer); return { - ...studioStackCheckView(item, completedChecks, 'anonymous'), + ...studioStackCheckView(item, completedChecks, viewer), status, statusLabel: stackCheckStatusLabel(status), }; @@ -503,6 +542,11 @@ export default function OneLoopStudio({ setMessage('Cancelling analysis…'); }, []); + const retryTranscript = () => { + const next = (selected?.url || url).trim(); + if (next) void runAnalysis(next); + }; + useEffect(() => { applyStudioQueryAutoStart({ query: studioQueryFromSearchParams(searchParams), @@ -570,6 +614,17 @@ export default function OneLoopStudio({ artifactCount: packFormation.artifacts.length, toolCount: packFormation.tools.length, }); + const eventsEmptyMessage = selected + ? studioEventsEmptyMessage({ + busy: transcriptWorking, + hasCompletedRun: selected.status !== 'processing', + eventCount, + hasTranscript: Boolean(selected.transcript?.trim()), + hasArchitecture: Boolean(packFormation.architecture), + artifactCount: packFormation.artifacts.length, + toolCount: packFormation.tools.length, + }) + : undefined; const hasPayload = studioCanExport({ transcript: selected?.transcript, eventCount, @@ -619,15 +674,29 @@ export default function OneLoopStudio({ const canAttemptBuildLive = Boolean( getYouTubeId(url || selected?.url || '') && (selected || url.trim()), ); + const canActOnSelectedPack = Boolean( + selected?.status === 'complete' && + selected?.videoPack && + selectedActPackKey && + getYouTubeId(selected.url) === selected.videoPack.videoId && + (!url.trim() || getYouTubeId(url) === selected.videoPack.videoId), + ); const act = async () => { - const next = (selected?.url || url).trim(); - if (!getYouTubeId(next)) { + if (!canActOnSelectedPack || !selected || !selectedActPackKey) { setMessage('Analyze a video before acting.'); return; } + const next = selected.url.trim(); + const attempt = ++actAttemptRef.current; + const isCurrentAct = () => { + const state = useDashboardStore.getState(); + const current = state.videos.find((video) => video.id === state.selectedVideoId); + return attempt === actAttemptRef.current && actPackKey(current) === selectedActPackKey; + }; setActBusy(true); setWorkflowActions(null); + setWorkflowPackKey(selectedActPackKey); setActRunId(null); setMessage('Acting on this run\'s transcript…'); try { @@ -641,6 +710,7 @@ export default function OneLoopStudio({ videoUrl: next, videoId: selected.id, }); + if (!isCurrentAct()) return; if (enriched) { updateVideo(selected.id, { events: enriched }); events = enriched; @@ -664,6 +734,7 @@ export default function OneLoopStudio({ setUsedSameRun(Boolean(payload.transcript || payload.events?.length)); const started = await startVideoToActions(payload); + if (!isCurrentAct()) return; if (!started.ok || !started.runId) { if (started.status === 401 || started.status === 403) { router.push(`/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`); @@ -683,6 +754,7 @@ export default function OneLoopStudio({ attempts: 24, delayMs: 2000, }); + if (!isCurrentAct()) return; if (polled.runStatus === 'completed' && polled.result) { setWorkflowActions(polled.result); const sameRun = @@ -696,9 +768,9 @@ export default function OneLoopStudio({ setMessage(polled.error || `Act status: ${polled.runStatus || 'unknown'}.`); } } catch (err) { - setMessage(err instanceof Error ? err.message : 'Act failed.'); + if (isCurrentAct()) setMessage(err instanceof Error ? err.message : 'Act failed.'); } finally { - setActBusy(false); + if (isCurrentAct()) setActBusy(false); } }; @@ -715,7 +787,7 @@ export default function OneLoopStudio({ const actions = actionsFromStudioRun({ insightActions, events: selected?.events, - workflowActions: workflowActions?.actions, + workflowActions: scopedWorkflowActions?.actions, }); if ( actions.length === 0 && @@ -940,7 +1012,7 @@ export default function OneLoopStudio({ const hasSummaryPane = Boolean(summaryText.trim()) && !summaryRedundant; const hasTranscriptPane = Boolean(selected?.transcript?.trim()) || transcriptWorking || showTranscriptRetry; - const hasActionsPane = Boolean(showAgentWorkflowUi && (actRunId || workflowActions)); + const hasActionsPane = Boolean(showAgentWorkflowUi && (scopedActRunId || scopedWorkflowActions)); const workbenchTabs = studioWorkbenchTabs({ hasTranscript: hasTranscriptPane, hasActions: hasActionsPane, @@ -1033,6 +1105,27 @@ export default function OneLoopStudio({ ) : null}
+ {showTranscriptRetry ? ( + + ) : null} + {showAgentWorkflowUi ? ( + + ) : null} - {showTranscriptRetry ? ( - - ) : null}

{statusText} @@ -1145,8 +1228,22 @@ export default function OneLoopStudio({ outputPane={ void exportPkg()} + canExport={hasPayload} + /> + ) : undefined + } + showTranscriptRetry={showTranscriptRetry} + onRetryTranscript={retryTranscript} stackChecks={stackCheckViews} - reviewActions={(workflowActions?.actions ?? []).map((action) => { + reviewActions={(scopedWorkflowActions?.actions ?? []).map((action) => { const card = studioActionCard(action); return { tool: action.tool, diff --git a/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx index 3983306d1..ef83fa4bf 100644 --- a/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx +++ b/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx @@ -12,6 +12,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: navigation.push }), useSearchParams: () => new URLSearchParams(), })); +vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) })); vi.mock('@/components/Nav', () => ({ default: () => null })); vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); vi.mock('@/lib/use-youtube-player', () => ({ diff --git a/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx index 5ffe8f1b2..4cebc5e6c 100644 --- a/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx +++ b/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx @@ -10,6 +10,7 @@ vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), usePathname: () => '/studio', })); +vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) })); vi.mock('@/components/Nav', () => ({ default: () => null })); vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); vi.mock('@/lib/use-youtube-player', () => ({ diff --git a/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx new file mode 100644 index 000000000..c51bf15ed --- /dev/null +++ b/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import OneLoopStudio from '@/components/OneLoopStudio'; +import { useDashboardStore, type Video } from '@/store/dashboard-store'; +import { pollVideoToActions, startVideoToActions } from '@/lib/studio-workflow'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/studio', +})); +const mockSession = vi.hoisted(() => ({ current: null as null | { user: { email: string } } })); +vi.mock('next-auth/react', () => ({ useSession: () => ({ data: mockSession.current, status: mockSession.current ? 'authenticated' : 'unauthenticated' }) })); +vi.mock('@/lib/studio-workflow', () => ({ startVideoToActions: vi.fn(), pollVideoToActions: vi.fn() })); +vi.mock('@/components/Nav', () => ({ default: () => null })); +vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); +vi.mock('@/lib/use-youtube-player', () => ({ + useYouTubePlayer: () => ({ + containerRef: { current: null }, + ready: true, + failed: false, + seekTo: vi.fn(), + }), +})); + +const dogfoodVideo: Video = { + id: 'pBsT6v-ciO8', + title: 'AI gold rushes', + url: 'https://www.youtube.com/watch?v=pBsT6v-ciO8', + status: 'complete', + progress: 100, + transcript: + 'What do you think are the three mini gold rushes happening inside AI right now that no one is talking about? SeaDance 2.5 and Cluely Desktop.', + events: [], +}; + +beforeEach(() => { + mockSession.current = null; + vi.mocked(startVideoToActions).mockReset(); + vi.mocked(pollVideoToActions).mockReset(); + vi.stubGlobal('React', React); + vi.spyOn(useDashboardStore.persist, 'rehydrate').mockImplementation(() => undefined); + useDashboardStore.setState({ videos: [], selectedVideoId: null }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('OneLoopStudio playflow CTAs after the IDE rebuild', () => { + it('shows Retry transcript when analysis failed and does not leave a silent wait', async () => { + const { reviewPackFixture } = await import('@/test/grounded-spec-fixture'); + const pack = reviewPackFixture(); + useDashboardStore.setState({ + videos: [ + { + ...dogfoodVideo, + status: 'failed', + transcript: '', + failure: { + stage: 'analysis', + message: 'Captions were not available.', + retryable: true, + failedAt: '2026-10-17T00:00:00.000Z', + }, + videoPack: { + packId: pack.id, + videoId: 'pBsT6v-ciO8', + sourceUrl: dogfoodVideo.url, + sourceHash: pack.provenance.source_hash, + version: pack.version, + pack: { ...pack, video_id: 'pBsT6v-ciO8', source_url: dogfoodVideo.url }, + }, + }, + ], + selectedVideoId: dogfoodVideo.id, + }); + + render(); + + expect(screen.getByTestId('studio-transcript-retry').textContent).toMatch(/retry transcript/i); + expect(screen.getByTestId('studio-ide-status').textContent.toLowerCase()).not.toMatch( + /reviewed actions|durable workflows/, + ); + }); + + it('keeps Export and Build live enabled for anonymous SeaDance/Cluely packs with empty events[]', async () => { + const { reviewPackFixture } = await import('@/test/grounded-spec-fixture'); + const pack = reviewPackFixture(); + pack.architecture = { + summary: 'Five-stage monetization pipeline.', + stages: [{ id: 'stage_1', name: 'Target Lead Extraction' }], + mermaid: null, + }; + pack.artifacts = [ + { + path_hint: 'interfaces/agency_pipeline.ts', + purpose: 'Lead and retainer contracts.', + interface: 'export interface AgencyClientPipeline {}', + }, + { + path_hint: 'interfaces/cluely_architecture.ts', + purpose: 'Micro-app factory.', + interface: 'export interface ProductSuiteManager {}', + }, + ]; + pack.stack = { + tools: [ + { name: 'SeaDance 2.5', check: 'seadance --version' }, + { name: 'Cluely Desktop', check: 'curl -I https://cluely.com' }, + ], + }; + + useDashboardStore.setState({ + videos: [ + { + ...dogfoodVideo, + videoPack: { + packId: pack.id, + videoId: 'pBsT6v-ciO8', + sourceUrl: dogfoodVideo.url, + sourceHash: pack.provenance.source_hash, + version: pack.version, + pack: { ...pack, video_id: 'pBsT6v-ciO8', source_url: dogfoodVideo.url }, + }, + }, + ], + selectedVideoId: dogfoodVideo.id, + }); + + render(); + + expect(screen.getByTestId('studio-ide-ship').hasAttribute('disabled')).toBe(false); + expect(screen.getByTestId('studio-ide-ship').textContent).toMatch(/ship this/i); + expect(screen.getByTestId('studio-build-live-button').hasAttribute('disabled')).toBe(false); + expect(screen.getByTestId('studio-act-button').textContent).toMatch(/act on this run/i); + + expect(screen.getByTestId('studio-ide-output-events').textContent).toMatch(/no extracted events/i); + expect(screen.getByTestId('studio-ide-output-events').textContent.toLowerCase()).not.toContain( + 'insights', + ); + expect(screen.getByTestId('pack-workbench')).toBeTruthy(); + expect(screen.getByTestId('pack-architecture').textContent).toMatch(/five-stage/i); + + fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools')); + const tools = screen.getByTestId('studio-ide-output-tools'); + expect(tools.textContent).toMatch(/SeaDance/i); + expect(tools.textContent).toMatch(/Cluely/i); + expect(tools.textContent).toMatch(/cannot run this check/i); + }); + + it('requires a stored analyzed pack before Act is enabled', () => { + useDashboardStore.setState({ videos: [dogfoodVideo], selectedVideoId: dogfoodVideo.id }); + render(); + expect(screen.getByTestId('studio-act-button').hasAttribute('disabled')).toBe(true); + fireEvent.click(screen.getByTestId('studio-act-button')); + expect(startVideoToActions).not.toHaveBeenCalled(); + }); + + it('shows runnable stack checks as pending for a signed-in viewer', async () => { + const { reviewPackFixture } = await import('@/test/grounded-spec-fixture'); + const pack = reviewPackFixture(); + pack.stack = { tools: [{ name: 'Vercel' }] }; + mockSession.current = { user: { email: 'viewer@example.com' } }; + useDashboardStore.setState({ + videos: [{ ...dogfoodVideo, videoPack: { + packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url, + sourceHash: pack.provenance.source_hash, version: pack.version, + pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url }, + } }], + selectedVideoId: dogfoodVideo.id, + }); + render(); + fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools')); + expect(screen.getByTestId('studio-stack-checks').textContent).toMatch(/pending/i); + expect(screen.getByTestId('studio-stack-checks').textContent).not.toMatch(/signed out/i); + }); + + it('shows named tools in the promoted workbench for a tool-only pack', async () => { + const { reviewPackFixture } = await import('@/test/grounded-spec-fixture'); + const pack = reviewPackFixture(); + pack.architecture = null; + pack.artifacts = []; + pack.stack = { tools: [{ name: 'Vercel', evidence: 'Mentioned in the video' }] }; + useDashboardStore.setState({ + videos: [{ ...dogfoodVideo, videoPack: { + packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url, + sourceHash: pack.provenance.source_hash, version: pack.version, + pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url }, + } }], + selectedVideoId: dogfoodVideo.id, + }); + render(); + expect(screen.getByTestId('pack-tools').textContent).toMatch(/Vercel/); + expect(screen.getByTestId('pack-tools').textContent).toMatch(/Mentioned in the video/); + }); + + it('discards late Act results after switching to another stored pack', async () => { + const { reviewPackFixture } = await import('@/test/grounded-spec-fixture'); + const pack = reviewPackFixture(); + const rowA: Video = { ...dogfoodVideo, videoPack: { + packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url, + sourceHash: pack.provenance.source_hash, version: pack.version, + pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url }, + } }; + const otherUrl = 'https://www.youtube.com/watch?v=auJzb1D-fag'; + const rowB: Video = { ...rowA, id: 'auJzb1D-fag', url: otherUrl, + videoPack: { ...rowA.videoPack!, packId: 'other-pack', videoId: 'auJzb1D-fag', sourceUrl: otherUrl, + pack: { ...pack, id: 'other-pack', video_id: 'auJzb1D-fag', source_url: otherUrl } }, + }; + useDashboardStore.setState({ videos: [rowA, rowB], selectedVideoId: rowA.id }); + vi.mocked(startVideoToActions).mockResolvedValue({ ok: true, status: 200, runId: 'run-a' }); + let finishPoll!: (value: Awaited>) => void; + vi.mocked(pollVideoToActions).mockImplementation(() => new Promise((resolve) => { finishPoll = resolve; })); + render(); + fireEvent.click(screen.getByTestId('studio-act-button')); + await waitFor(() => expect(pollVideoToActions).toHaveBeenCalled()); + useDashboardStore.setState({ selectedVideoId: rowB.id }); + finishPoll({ ok: true, status: 200, runId: 'run-a', runStatus: 'completed', + result: { url: rowA.url, transcriptChars: 0, actionCount: 1, + actions: [{ tool: 'Old pack action', status: 'completed', result: 'from A' }] } }); + await waitFor(() => expect(screen.getByTestId('studio-act-button').textContent).toMatch(/Act on this run/)); + expect(screen.queryByText('Old pack action')).toBeNull(); + expect(screen.queryByText('from A')).toBeNull(); + }); + +}); diff --git a/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx index fb1369b51..456e9b43c 100644 --- a/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx +++ b/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx @@ -9,6 +9,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), useSearchParams: () => new URLSearchParams(), })); +vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) })); vi.mock('@/components/Nav', () => ({ default: () => null })); vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); vi.mock('@/lib/use-youtube-player', () => ({ diff --git a/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx b/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx new file mode 100644 index 000000000..62fe7c77d --- /dev/null +++ b/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import StudioIdeOutput from '@/components/studio/StudioIdeOutput'; +import type { Video } from '@/store/dashboard-types'; +import { studioEventsEmptyMessage } from '@/lib/studio-pipeline-status'; + +const emptyEventsVideo: Video = { + id: 'pBsT6v-ciO8', + title: 'AI gold rushes', + url: 'https://www.youtube.com/watch?v=pBsT6v-ciO8', + status: 'complete', + progress: 100, + transcript: 'SeaDance 2.5 and Cluely Desktop from the video.', + events: [], +}; + +afterEach(() => { + cleanup(); +}); + +describe('StudioIdeOutput playflow (B/E/F + events vs Insights)', () => { + it('labels the Events tab Events, never Insights, and tells the truth when events[] is empty', () => { + const emptyMessage = studioEventsEmptyMessage({ + busy: false, + hasCompletedRun: true, + eventCount: 0, + hasArchitecture: true, + artifactCount: 2, + toolCount: 4, + }); + + render( + Architecture from this pack} + />, + ); + + expect(screen.getByTestId('studio-ide-output-tab-events').textContent).toBe('Events'); + expect(screen.queryByRole('tab', { name: /insights/i })).toBeNull(); + expect(screen.getByTestId('studio-ide-output-events').textContent).toMatch(/no extracted events/i); + expect(screen.getByTestId('studio-ide-output-events').textContent.toLowerCase()).not.toContain( + 'insights', + ); + expect(screen.getByTestId('pack-workbench').textContent).toMatch(/architecture/i); + }); + + it('shows skip/unknown stack-check labels so SeaDance/Cluely do not look like a production hold', () => { + render( + , + ); + + fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools')); + const tools = screen.getByTestId('studio-ide-output-tools'); + expect(tools.textContent).toMatch(/SeaDance/i); + expect(tools.textContent).toMatch(/Cluely/i); + expect(tools.textContent).toMatch(/cannot run this check/i); + expect(tools.textContent.toLowerCase()).not.toMatch(/held until/); + }); + + it('renders review_action as a card with a clear CTA, not Run tools → review_action', () => { + render( + , + ); + + fireEvent.click(screen.getByTestId('studio-ide-output-tab-intent')); + const intent = screen.getByTestId('studio-ide-output-intent'); + expect(intent.textContent).toMatch(/review this result/i); + expect(intent.textContent).toMatch(/needs review/i); + expect(intent.textContent).toContain('Check the pack evidence before you ship.'); + expect(intent.textContent.toLowerCase()).not.toContain('run tools'); + expect(intent.textContent).not.toMatch(/review_action/); + }); + + it('offers Retry transcript in the Transcript tab when the run failed', () => { + const onRetry = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('studio-ide-output-tab-transcript')); + fireEvent.click(screen.getByTestId('studio-transcript-retry')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/studio/StudioIdeOutput.tsx b/apps/web/src/components/studio/StudioIdeOutput.tsx index ae4cc5b42..16c40bf22 100644 --- a/apps/web/src/components/studio/StudioIdeOutput.tsx +++ b/apps/web/src/components/studio/StudioIdeOutput.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import type { Video } from '@/store/dashboard-types'; import type { ExtractedEvent } from '@/lib/types'; import { studioActionCard } from '@/lib/studio-pipeline-status'; @@ -42,11 +42,19 @@ const SECTIONS: Array<{ id: OutputSection; label: string }> = [ export default function StudioIdeOutput({ video, specReview, + eventsEmptyMessage, + packWorkbench, + showTranscriptRetry, + onRetryTranscript, stackChecks = [], reviewActions = [], }: { video: Video | undefined; - specReview?: React.ReactNode; + specReview?: ReactNode; + eventsEmptyMessage?: string; + packWorkbench?: ReactNode; + showTranscriptRetry?: boolean; + onRetryTranscript?: () => void; stackChecks?: StudioStackCheckView[]; reviewActions?: StudioReviewActionInput[]; }) { @@ -56,6 +64,10 @@ export default function StudioIdeOutput({ video?.videoPack || video?.events?.length || video?.insights || + video?.transcript?.trim() || + video?.failure || + eventsEmptyMessage || + packWorkbench || stackChecks.length > 0 || reviewActions.length > 0, ); @@ -110,29 +122,39 @@ export default function StudioIdeOutput({ ) : ( <> {active === 'events' && ( -

    +
    {events.length === 0 ? ( - + <> + + {packWorkbench} + ) : ( - events.map((e) => ( -
  • -
    - - - {e.type} - - {e.timestamp ? ( - {e.timestamp} +
      + {events.map((e) => ( +
    • +
      + + + {e.type} + + {e.timestamp ? ( + {e.timestamp} + ) : null} +
      +

      {e.title}

      + {e.description ? ( +

      {e.description}

      ) : null} -
    -

    {e.title}

    - {e.description ? ( -

    {e.description}

    - ) : null} -
  • - )) + + ))} +
)} - + )} {active === 'lingo' && ( @@ -319,16 +341,28 @@ export default function StudioIdeOutput({ )} {active === 'transcript' && ( -
- {video?.transcript?.trim() ? ( -

- {video.transcript} -

- ) : video?.failure?.message ? ( -

{video.failure.message}

- ) : ( -

Nothing yet.

- )} +
+
+ {video?.transcript?.trim() ? ( +

+ {video.transcript} +

+ ) : video?.failure?.message ? ( +

{video.failure.message}

+ ) : ( +

Nothing yet.

+ )} +
+ {showTranscriptRetry && onRetryTranscript ? ( + + ) : null}
)} diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index 05167ecba..4927f6d63 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -532,6 +532,9 @@ describe('studio-pipeline-status', () => { it('softens reviewed/durable overclaim copy and pads the sticky footer', () => { expect(STUDIO_PRODUCT_TAGLINE.toLowerCase()).not.toMatch(/reviewed actions|durable workflows/); expect(STUDIO_PRODUCT_TAGLINE).toMatch(/video pack/i); + expect( + studioPasteOutcomeMessage({ hasUsableTranscript: true }).toLowerCase(), + ).not.toContain('run tools'); const footer = readFileSync(join(process.cwd(), 'src/components/Footer.tsx'), 'utf8'); const studio = readFileSync(join(process.cwd(), 'src/components/OneLoopStudio.tsx'), 'utf8'); diff --git a/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts b/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts index 29cccc1a8..6a4a5dfe2 100644 --- a/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts +++ b/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts @@ -76,9 +76,10 @@ describe('UVAI is one product surface', () => { expect(studio).not.toMatch(/router\.push\(['"`]\/dashboard/); }); - it('does not hold anonymous Deploy behind non-runnable stack checks', () => { + it('uses the actual viewer for stack-check holds on the Studio page', () => { const studio = readSource('components/OneLoopStudio.tsx'); - expect(studio).toContain("deployHoldReason(linkedSop, completedChecks, 'anonymous')"); + expect(studio).toContain("const viewer = session?.user ? 'signed-in' : 'anonymous'"); + expect(studio).toContain('deployHoldReason(linkedSop, completedChecks, viewer)'); expect(studio).toContain('stackCheckStatus'); expect(studio).toContain('stackCheckStatusLabel'); expect(studio).toContain('studioShipCta'); diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index 2bd869c7e..9dc5acfef 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -276,7 +276,7 @@ export function studioPasteOutcomeMessage(input: { packCitation?: string | null; }): string { if (input.hasUsableTranscript) { - return 'Ready — run tools, export, or save from this page.'; + return 'Ready — act, export, or save from this page.'; } if (input.packCitation) { return `Identity pack ${input.packCitation}. No usable transcript.`; diff --git a/apps/web/src/lib/studio-playflow-PLAN.md b/apps/web/src/lib/studio-playflow-PLAN.md index ab68c22ec..feeb4c26c 100644 --- a/apps/web/src/lib/studio-playflow-PLAN.md +++ b/apps/web/src/lib/studio-playflow-PLAN.md @@ -1,29 +1,26 @@ -# TASK: Restore Studio Ship this playflow after IDE shell +# TASK: Restore remaining Studio IDE playflow after Ship this ## 1. Goal & Scope -* **Objective:** Get UVAI Studio playflow back toward ≥9/10 by restoring a discoverable **Ship this** path and rewiring leftover B/E/G helpers the IDE shell left unused. -* **Context:** Kill A (#1692) merged. B–G helpers exist (`studioTranscriptStage`, `studioActionCard`, `deployHoldReason` skip/unknown, export toast, player overlay). The 2026-10-07 IDE refactor deleted `studio-deploy-button` and stopped rendering Ship this, stack-check honesty, review_action cards, and transcript retry. Dogfood kill F still stands: no Ship this; seadance/cluely-class checks must not hold anonymous ship. +* **Objective:** Keep #2437’s **Ship this** ZIP CTA, and restore the leftover dogfood rails this branch still owns: honest empty Events (never Insights), pack workbench, Act on this run, transcript-tab retry, and softened “run tools” copy. +* **Context:** #2437 (`560f07120`) already closed #2435 with Ship this + skip/unknown stack rows + review cards + toolbar retry. This branch layers Events honesty and the flag-gated Act CTA on that landing. C (player) and D (export toast at `bottom-4`) stay. No Origin G.A.T.E., ClipToAction, fake events, or Ship checkout. * **Scope:** - * `apps/web/src/lib/studio-pipeline-status.ts` — `studioShipEnabled` / `studioShipCta` (exportability gates ship; holdReason never does) - * `apps/web/src/lib/official-templates.ts` — stack-check row view (skip/unknown) - * `apps/web/src/components/OneLoopStudio.tsx` — Ship this CTA, transcript retry, toast position, pass checks/actions - * `apps/web/src/components/studio/StudioIdeOutput.tsx` — stack checks + `studioActionCard` - * *Initial check:* Extend existing helpers. Do not restore `studio-deploy-button`, origin deploy kickoff, fake events, or G.A.T.E. contract changes. + * `apps/web/src/components/OneLoopStudio.tsx` — Act on this run, honest empty-events + pack workbench into output; keep Ship this + * `apps/web/src/components/studio/StudioIdeOutput.tsx` — Events empty copy, pack workbench, transcript-tab retry on main’s stackChecks/reviewActions API + * Focused Vitest for the dogfood path + * *Initial check:* Do not restore `studio-deploy-button`. Do not replace Ship this with a second Export button. ## 2. Execution Plan -- [x] Failing tests for Ship this enablement, skip/unknown checks, review cards, retry, toast position -- [x] Ship this CTA enabled by `studioCanExport`, never by non-runnable stack checks -- [x] Tools tab shows honest skip/unknown; Intent tab renders `review_action` cards -- [x] Transcript retry control when `studioCanRetryTranscript` -- [x] Export toast no longer uses sticky-footer `bottom-20` -- [x] Focused Vitest +- [x] Merge origin/main (#2437 Ship this) and keep complementary Events/Act wiring +- [x] Honest Events empty + pack workbench; Tools skip/unknown; Intent review cards +- [x] Soften remaining “run tools” paste-ready copy +- [x] Verify focused Vitest after the merge ## 3. Definition of Done (Success Verification) -* **Expected Outcome:** Anonymous Studio on a seadance/cluely pack can click **Ship this** (ZIP export). Stack checks read skip/unknown and do not disable the button. No live hostname claim. Transcript retry and review cards are visible. -* **Verification Method:** `cd apps/web && npx vitest run src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx` -* **Proof Artifact:** `cd apps/web && npm test -- src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx` → **5 files, 49 passed**. Related: OneLoopStudio.build-live + job-strip + linked-sop → **3 files, 16 passed**. `tsc --noEmit` and eslint on touched files passed. +* **Expected Outcome:** On `/studio?video=…pBsT6v-ciO8` Events says no extracted events (not Insights), architecture/artifacts visible, SeaDance/Cluely Unknown, **Ship this** enabled, failed transcript has Retry, Act cards say Review this result. +* **Verification Method:** `npm --workspace=apps/web exec vitest run src/components/__tests__/OneLoopStudio.playflow.test.tsx src/components/__tests__/StudioIdeOutput.playflow.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/__tests__/OneLoopStudio.build-live.test.tsx src/components/__tests__/OneLoopStudio.job-strip.test.tsx src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts` +* **Proof Artifact:** 9 files, 62 passed after merging `origin/main` (#2437). ## 4. Post-Task Reflection -* **What was done:** Restored a discoverable **Ship this** toolbar CTA that downloads the pack ZIP. Anonymous/unknown SeaDance/Cluely stack checks render as skip/unknown and never disable the button. Intent tab renders `review_action` cards. Transcript retry is visible on retryable failure. Export toast sits at `bottom-4` (no sticky footer). Origin deploy button was not restored. -* **Why it was needed:** IDE shell left playflow helpers unwired; dogfood kill F was a disabled Deploy with no Ship this. -* **How it was tested:** Focused Vitest (49 + 16), type-check, eslint. No production live claim. GitHub issue create was 403 so this cut has no new issue number. +* **What was done:** Merged #2437’s Ship this landing and kept the remaining playflow: Events honesty, pack workbench, Act on this run (`agentWorkflowUi`), transcript-tab retry, and “act, export, or save” paste copy. +* **Why it was needed:** #2437 restored Ship this / stack rows / review cards; empty `events[]` packs still needed an honest Events pane and the flag-gated Act CTA. +* **How it was tested:** Focused Vitest after merge: 9 files, 62 passed. diff --git a/package-lock.json b/package-lock.json index 2784aabd0..a65df8ddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7065,16 +7065,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -7083,13 +7083,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -7120,9 +7120,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -7133,13 +7133,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -7147,14 +7147,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7163,9 +7163,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -7173,13 +7173,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -15757,9 +15757,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -16676,19 +16676,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -16716,12 +16716,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"