Skip to content

Commit e6950f7

Browse files
committed
feat: staktrak journey pass-fail
1 parent a6baf38 commit e6950f7

2 files changed

Lines changed: 186 additions & 4 deletions

File tree

src/__tests__/integration/api/tasks-taskid-recording.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,107 @@ describe("POST /api/tasks/[taskId]/recording - Integration Tests", () => {
510510
expect(timestampsContent.text).toContain("input");
511511
});
512512

513+
test("marks task COMPLETED and message 'passed' when timestamps status is passed", async () => {
514+
const validPassword = "test-password";
515+
const { task } = await createTestSetup({ agentPassword: validPassword });
516+
517+
const request = createMultipartRequest(task.id, {
518+
apiKey: validPassword,
519+
timestampsJson: {
520+
testTitle: "sample journey",
521+
status: "passed",
522+
duration: 3000,
523+
actions: [{ title: "click", sourceCode: "await page.click('.button')" }],
524+
},
525+
});
526+
527+
const response = await POST(request, { params: Promise.resolve({ taskId: task.id }) });
528+
const data = await expectSuccess(response, 201);
529+
expect(data.data.testStatus).toBe("passed");
530+
531+
const message = await db.chatMessage.findUnique({
532+
where: { id: data.data.messageId },
533+
include: { artifacts: true },
534+
});
535+
expect(message!.message).toBe("Playwright test passed");
536+
537+
const media = message!.artifacts.find((a) => a.type === "MEDIA");
538+
expect((media!.content as Record<string, unknown>).testStatus).toBe("passed");
539+
540+
const updatedTask = await db.task.findUnique({
541+
where: { id: task.id },
542+
select: { workflowStatus: true },
543+
});
544+
expect(updatedTask!.workflowStatus).toBe("COMPLETED");
545+
});
546+
547+
test("marks task FAILED and surfaces the failing step when timestamps status is failed", async () => {
548+
const validPassword = "test-password";
549+
const { task } = await createTestSetup({ agentPassword: validPassword });
550+
551+
const request = createMultipartRequest(task.id, {
552+
apiKey: validPassword,
553+
timestampsJson: {
554+
testTitle: "sample journey",
555+
status: "failed",
556+
duration: 4200,
557+
actions: [
558+
{ title: "goto", sourceCode: "await page.goto('/')" },
559+
{
560+
title: "expect",
561+
sourceCode: "await expect(el).toBeVisible()",
562+
location: { file: "e2e/journey.spec.ts", line: 12, column: 3 },
563+
error: { message: "locator not found", stack: "Error: locator not found" },
564+
},
565+
],
566+
},
567+
});
568+
569+
const response = await POST(request, { params: Promise.resolve({ taskId: task.id }) });
570+
const data = await expectSuccess(response, 201);
571+
expect(data.data.testStatus).toBe("failed");
572+
573+
const message = await db.chatMessage.findUnique({
574+
where: { id: data.data.messageId },
575+
include: { artifacts: true },
576+
});
577+
expect(message!.message).toBe("Playwright test failed");
578+
579+
const media = message!.artifacts.find((a) => a.type === "MEDIA");
580+
const content = media!.content as Record<string, unknown>;
581+
expect(content.testStatus).toBe("failed");
582+
expect(content.failedStep).toMatchObject({
583+
sourceCode: "await expect(el).toBeVisible()",
584+
message: "locator not found",
585+
});
586+
587+
const updatedTask = await db.task.findUnique({
588+
where: { id: task.id },
589+
select: { workflowStatus: true },
590+
});
591+
expect(updatedTask!.workflowStatus).toBe("FAILED");
592+
});
593+
594+
test("treats a timedOut status as a failure", async () => {
595+
const validPassword = "test-password";
596+
const { task } = await createTestSetup({ agentPassword: validPassword });
597+
598+
const request = createMultipartRequest(task.id, {
599+
apiKey: validPassword,
600+
timestampsJson: { status: "timedOut", actions: [] },
601+
});
602+
603+
const response = await POST(request, { params: Promise.resolve({ taskId: task.id }) });
604+
const data = await expectSuccess(response, 201);
605+
expect(data.data.testStatus).toBe("timedOut");
606+
607+
const updatedTask = await db.task.findUnique({
608+
where: { id: task.id },
609+
select: { workflowStatus: true },
610+
});
611+
expect(updatedTask!.workflowStatus).toBe("FAILED");
612+
});
613+
513614
test("invalidates agentPassword after successful upload", async () => {
514615
const validPassword = "test-password";
515616
const { task } = await createTestSetup({ agentPassword: validPassword });

src/app/api/tasks/[taskId]/recording/route.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,78 @@ import { NextRequest, NextResponse } from "next/server";
3535
import { db } from "@/lib/db";
3636
import { getS3Service } from "@/services/s3";
3737
import { EncryptionService } from "@/lib/encryption";
38-
import { ChatRole, ChatStatus, ArtifactType } from "@prisma/client";
38+
import { ChatRole, ChatStatus, ArtifactType, WorkflowStatus } from "@prisma/client";
3939
import { validateVideoSize, getMaxVideoSizeMB } from "@/lib/video-validation";
4040
import { timingSafeEqual } from "@/lib/encryption";
4141

4242
export const fetchCache = "force-no-store";
4343

4444
const 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+
46110
export 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

Comments
 (0)