@@ -35,14 +35,78 @@ import { NextRequest, NextResponse } from "next/server";
3535import { db } from "@/lib/db" ;
3636import { getS3Service } from "@/services/s3" ;
3737import { EncryptionService } from "@/lib/encryption" ;
38- import { ChatRole , ChatStatus , ArtifactType } from "@prisma/client" ;
38+ import { ChatRole , ChatStatus , ArtifactType , WorkflowStatus } from "@prisma/client" ;
3939import { validateVideoSize , getMaxVideoSizeMB } from "@/lib/video-validation" ;
4040import { timingSafeEqual } from "@/lib/encryption" ;
4141
4242export const fetchCache = "force-no-store" ;
4343
4444const encryptionService = EncryptionService . getInstance ( ) ;
4545
46+ type DerivedTestOutcome = {
47+ // Raw Playwright status ("passed" | "failed" | "timedOut" | "interrupted" | "skipped")
48+ rawStatus : string | null ;
49+ outcome : "passed" | "failed" | null ;
50+ workflowStatus : WorkflowStatus | null ;
51+ failedStep : { sourceCode ?: string ; location ?: unknown ; message ?: string } | null ;
52+ } ;
53+
54+ /**
55+ * Derive a pass/fail result from the timestamps.json that staklink uploads with the
56+ * recording. Its Playwright reporter writes a top-level `status` per test plus per-step
57+ * `actions[].error` (see staklink playwright.ts TimestampReporter). We map that to a task
58+ * workflow status so authoritative pod replays surface a real pass/fail instead of being
59+ * stuck IN_PROGRESS. Legacy payloads without a `status` leave the task status untouched.
60+ */
61+ export function deriveTestOutcome ( timestampsJson : unknown ) : DerivedTestOutcome {
62+ const entries = Array . isArray ( timestampsJson ) ? timestampsJson : [ timestampsJson ] ;
63+ let anyKnown = false ;
64+ let anyFailed = false ;
65+ let rawStatus : string | null = null ;
66+ let failedStep : DerivedTestOutcome [ "failedStep" ] = null ;
67+
68+ for ( const entry of entries ) {
69+ const status =
70+ entry && typeof ( entry as { status ?: unknown } ) . status === "string"
71+ ? ( ( entry as { status : string } ) . status )
72+ : null ;
73+ if ( ! status ) continue ;
74+ anyKnown = true ;
75+ const normalized = status . toLowerCase ( ) ;
76+ if ( normalized === "passed" || normalized === "skipped" ) continue ;
77+
78+ // failed | timedOut | interrupted -> failure
79+ anyFailed = true ;
80+ rawStatus = status ;
81+ const actions = ( entry as { actions ?: unknown } ) . actions ;
82+ if ( ! failedStep && Array . isArray ( actions ) ) {
83+ const errored = actions . find (
84+ ( a ) => a && typeof a === "object" && ( a as { error ?: unknown } ) . error ,
85+ ) as { sourceCode ?: string ; location ?: unknown ; error ?: { message ?: string } } | undefined ;
86+ if ( errored ) {
87+ failedStep = {
88+ sourceCode : errored . sourceCode ,
89+ location : errored . location ,
90+ message : errored . error ?. message ,
91+ } ;
92+ }
93+ }
94+ }
95+
96+ if ( ! anyKnown ) {
97+ return { rawStatus : null , outcome : null , workflowStatus : null , failedStep : null } ;
98+ }
99+ if ( anyFailed ) {
100+ return { rawStatus, outcome : "failed" , workflowStatus : WorkflowStatus . FAILED , failedStep } ;
101+ }
102+ return {
103+ rawStatus : "passed" ,
104+ outcome : "passed" ,
105+ workflowStatus : WorkflowStatus . COMPLETED ,
106+ failedStep : null ,
107+ } ;
108+ }
109+
46110export async function POST ( request : NextRequest , { params } : { params : Promise < { taskId : string } > } ) {
47111 console . log ( "Recording webhook received" ) ;
48112 try {
@@ -143,6 +207,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
143207 return NextResponse . json ( { error : "Invalid timestamps JSON" } , { status : 400 } ) ;
144208 }
145209
210+ // Step 4b: Derive pass/fail from the Playwright timestamps
211+ const outcome = deriveTestOutcome ( timestampsJson ) ;
212+ const recordingMessage =
213+ outcome . outcome === "passed"
214+ ? "Playwright test passed"
215+ : outcome . outcome === "failed"
216+ ? "Playwright test failed"
217+ : "Playwright recording uploaded" ;
218+
146219 // Step 5: Get Swarm for S3 Path
147220 const swarm = await db . swarm . findUnique ( {
148221 where : { workspaceId : task . workspaceId } ,
@@ -174,7 +247,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
174247 chatMessage = await db . chatMessage . create ( {
175248 data : {
176249 taskId,
177- message : "Playwright recording uploaded" ,
250+ message : recordingMessage ,
178251 role : ChatRole . ASSISTANT ,
179252 status : ChatStatus . SENT ,
180253 artifacts : {
@@ -189,6 +262,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
189262 contentType : "video/webm" ,
190263 duration : null ,
191264 uploadedAt : new Date ( ) . toISOString ( ) ,
265+ testStatus : outcome . rawStatus ,
266+ failedStep : outcome . failedStep ,
192267 } ,
193268 icon : "video" ,
194269 } ,
@@ -212,11 +287,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
212287 return NextResponse . json ( { error : "Internal error" } , { status : 500 } ) ;
213288 }
214289
215- // Step 8: Invalidate API Key (One-time Use)
290+ // Step 8: Invalidate API Key (One-time Use) + record pass/fail on the task.
291+ // When the timestamps carry a Playwright status, transition the task out of
292+ // IN_PROGRESS so an authoritative pod replay surfaces a real result.
216293 try {
217294 await db . task . update ( {
218295 where : { id : taskId } ,
219- data : { agentPassword : null } ,
296+ data : {
297+ agentPassword : null ,
298+ ...( outcome . workflowStatus ? { workflowStatus : outcome . workflowStatus } : { } ) ,
299+ } ,
220300 } ) ;
221301 } catch ( error ) {
222302 console . error ( "Failed to invalidate API key:" , error ) ;
@@ -231,6 +311,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
231311 s3Key : s3Key ,
232312 messageId : chatMessage . id ,
233313 artifactIds : chatMessage . artifacts . map ( ( a ) => a . id ) ,
314+ testStatus : outcome . rawStatus ,
234315 } ,
235316 } ,
236317 { status : 201 } ,
0 commit comments