Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c510699
docs(browserbase): add SALE-45 screenshot automation improvements spec
Marfuen Apr 22, 2026
10aba5a
docs(browserbase): add SALE-45 implementation plan
Marfuen Apr 22, 2026
ba64f28
test(browserbase): add failing tests for screenshot overlay renderer
Marfuen Apr 22, 2026
2a1666b
feat(browserbase): add screenshot overlay renderer with audit metadat…
Marfuen Apr 22, 2026
067261a
fix(browserbase): add overlay JSDoc, height guard, and XML control-ch…
Marfuen Apr 22, 2026
426e8c8
feat(browserbase): bake audit overlay into captured screenshots
Marfuen Apr 22, 2026
5963a2c
test(browserbase): add failing test for getScreenshotRedirectUrl
Marfuen Apr 22, 2026
0d9f8e8
feat(browserbase): add getScreenshotRedirectUrl with org scope
Marfuen Apr 22, 2026
03b14a8
test(browserbase): add failing test for screenshot redirect endpoint
Marfuen Apr 22, 2026
a66150f
feat(browserbase): add GET runs/:runId/screenshot redirect endpoint
Marfuen Apr 22, 2026
4d15388
test(run-item): add failing test for stable full-size screenshot URL
Marfuen Apr 22, 2026
769c603
fix(run-item): point full-size link at stable redirect endpoint
Marfuen Apr 22, 2026
4f31bb4
fix(browserbase): surface user-readable error for timeouts and generi…
Marfuen Apr 22, 2026
84f9e3a
test(browserbase): add failing tests for toRunErrorMessage helper
Marfuen Apr 22, 2026
1f3faff
feat(browserbase): add toRunErrorMessage helper for user-readable run…
Marfuen Apr 22, 2026
0c226ac
refactor(browserbase): use toRunErrorMessage across all run catch blocks
Marfuen Apr 22, 2026
bc19ab1
fix(browserbase): address review findings (needsReauth pass-through, …
Marfuen Apr 22, 2026
5c0f7e8
refactor(run-item): migrate lucide-react to @trycompai/design-system …
Marfuen Apr 22, 2026
4d2eb8a
fix(browserbase): use shared s3Client so uploads pick up APP_AWS_* creds
Marfuen Apr 23, 2026
3df9a8c
chore(docs): regenerate openapi.json
Marfuen Apr 23, 2026
1e09657
feat(browserbase): redesign screenshot overlay with Comp AI branding
Marfuen Apr 23, 2026
cd3e50a
feat(browserbase): add screenshot download button
Marfuen Apr 23, 2026
759b480
feat(browserbase): configurable evaluation criteria per automation
Marfuen Apr 23, 2026
69ba9c3
Merge branch 'main' into mariano/sale-45-screenshot-automation-featur…
Marfuen Apr 23, 2026
5b585de
fix(browser-automations): drop zod .default('') causing RHF resolver …
Marfuen Apr 23, 2026
37e5472
chore(docs): regenerate openapi.json for screenshot download param
Marfuen Apr 23, 2026
56f70bc
Merge branch 'mariano/sale-45-screenshot-automation-feature-improveme…
Marfuen Apr 23, 2026
ff83c69
fix(browserbase): install DejaVu Sans so overlay text renders in prod…
Marfuen Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Use Node.js runtime for production
FROM node:20-slim

# Install required packages (wget for healthcheck, curl/bash for bun install, libc compat for prisma)
RUN apt-get update && apt-get install -y --no-install-recommends wget curl openssl && rm -rf /var/lib/apt/lists/*
# Install required packages:
# - wget for healthcheck, curl/bash for bun install, openssl for prisma
# - fontconfig + fonts-dejavu-core so sharp/librsvg can render text in the
# browser-automation screenshot overlay (node:*-slim ships with zero fonts)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget curl openssl fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

Expand Down
7 changes: 5 additions & 2 deletions apps/api/Dockerfile.multistage
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,11 @@ RUN groupadd --system nestjs && useradd --system --gid nestjs --create-home nest
WORKDIR /app
RUN chown nestjs:nestjs /app

# Install runtime dependencies + AWS RDS CA certificate bundle
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
# Install runtime dependencies + AWS RDS CA certificate bundle.
# fontconfig + fonts-dejavu-core are needed so sharp/librsvg can render text
# in the browser-automation screenshot overlay (node:*-slim has no fonts).
RUN apt-get update && apt-get install -y --no-install-recommends \
wget ca-certificates fontconfig fonts-dejavu-core \
&& wget -q -O /usr/local/share/aws-rds-ca-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

Expand Down
105 changes: 105 additions & 0 deletions apps/api/src/browserbase/browserbase.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// apps/api/src/browserbase/browserbase.controller.spec.ts
jest.mock('@db', () => ({
db: {},
Prisma: {
PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error {
code: string;
constructor(message: string, { code }: { code: string }) {
super(message);
this.code = code;
}
},
},
}));

jest.mock('../auth/auth.server', () => ({
auth: {
api: {
getSession: jest.fn(),
},
},
}));

jest.mock('@trycompai/auth', () => ({
statement: {},
BUILT_IN_ROLE_PERMISSIONS: {},
}));

import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import type { Response } from 'express';
import { BrowserbaseController } from './browserbase.controller';
import { BrowserbaseService } from './browserbase.service';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';

describe('BrowserbaseController.redirectToScreenshot', () => {
let controller: BrowserbaseController;
let service: jest.Mocked<Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>>;

beforeEach(async () => {
service = {
getScreenshotRedirectUrl: jest.fn(),
} as jest.Mocked<Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>>;

const moduleRef = await Test.createTestingModule({
controllers: [BrowserbaseController],
providers: [{ provide: BrowserbaseService, useValue: service }],
})
.overrideGuard(HybridAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(PermissionGuard)
.useValue({ canActivate: () => true })
.compile();

controller = moduleRef.get(BrowserbaseController);
});

const makeRes = () => {
const res: Partial<Response> = { redirect: jest.fn() };
return res as Response & { redirect: jest.Mock };
};

it('302-redirects to the freshly minted presigned URL', async () => {
service.getScreenshotRedirectUrl.mockResolvedValue(
'https://s3.example.com/fresh-signed',
);
const res = makeRes();

await controller.redirectToScreenshot('bar_1', 'org_1', res);

expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({
runId: 'bar_1',
organizationId: 'org_1',
download: false,
});
expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed');
});

it('passes download=true to the service when the query param is "true"', async () => {
service.getScreenshotRedirectUrl.mockResolvedValue(
'https://s3.example.com/fresh-signed-attachment',
);
const res = makeRes();

await controller.redirectToScreenshot('bar_1', 'org_1', res, 'true');

expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({
runId: 'bar_1',
organizationId: 'org_1',
download: true,
});
});

it('propagates NotFoundException when the service throws', async () => {
service.getScreenshotRedirectUrl.mockRejectedValue(
new NotFoundException('Screenshot not found'),
);
const res = makeRes();

await expect(
controller.redirectToScreenshot('bar_missing', 'org_1', res),
).rejects.toBeInstanceOf(NotFoundException);
expect(res.redirect).not.toHaveBeenCalled();
});
});
27 changes: 27 additions & 0 deletions apps/api/src/browserbase/browserbase.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
Param,
Patch,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import {
ApiOperation,
ApiParam,
Expand Down Expand Up @@ -371,4 +374,28 @@ export class BrowserbaseController {
runId,
)) as BrowserAutomationRunResponseDto | null;
}

@Get('runs/:runId/screenshot')
@RequirePermission('task', 'read')
@ApiOperation({
summary: 'Redirect to a freshly signed screenshot URL',
description:
'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL. Pass ?download=true to force an attachment download.',
})
@ApiParam({ name: 'runId', description: 'Run ID' })
@ApiResponse({ status: 302, description: 'Redirect to signed S3 URL' })
@ApiResponse({ status: 404, description: 'Run or screenshot not found' })
async redirectToScreenshot(
@Param('runId') runId: string,
@OrganizationId() organizationId: string,
@Res() res: Response,
@Query('download') download?: string,
): Promise<void> {
const url = await this.browserbaseService.getScreenshotRedirectUrl({
runId,
organizationId,
download: download === 'true' || download === '1',
});
res.redirect(302, url);
}
}
128 changes: 128 additions & 0 deletions apps/api/src/browserbase/browserbase.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// apps/api/src/browserbase/browserbase.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { BrowserbaseService } from './browserbase.service';

jest.mock('@db', () => ({
db: {
browserAutomationRun: {
findUnique: jest.fn(),
},
},
}));

jest.mock('@/app/s3', () => ({
getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/signed'),
s3Client: { send: jest.fn() },
BUCKET_NAME: 'test-bucket',
}));

import { db } from '@db';
import { getSignedUrl } from '@/app/s3';

describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
let service: BrowserbaseService;

beforeEach(async () => {
jest.clearAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [BrowserbaseService],
}).compile();
service = moduleRef.get(BrowserbaseService);
});

it('returns a freshly minted presigned URL for an in-scope run', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
id: 'bar_1',
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
automation: { task: { organizationId: 'org_1' } },
});

const url = await service.getScreenshotRedirectUrl({
runId: 'bar_1',
organizationId: 'org_1',
});

expect(url).toBe('https://s3.example.com/signed');
expect(db.browserAutomationRun.findUnique).toHaveBeenCalledWith({
where: { id: 'bar_1' },
include: { automation: { include: { task: true } } },
});
});

it('throws NotFoundException when the run does not exist', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue(null);

await expect(
service.getScreenshotRedirectUrl({
runId: 'bar_missing',
organizationId: 'org_1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});

it('throws NotFoundException when the run belongs to a different org', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
id: 'bar_1',
screenshotUrl: 'browser-automations/org_2/bau_1/bar_1.jpg',
automation: { task: { organizationId: 'org_2' } },
});

await expect(
service.getScreenshotRedirectUrl({
runId: 'bar_1',
organizationId: 'org_1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});

it('throws NotFoundException when the run has no screenshot', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
id: 'bar_1',
screenshotUrl: null,
automation: { task: { organizationId: 'org_1' } },
});

await expect(
service.getScreenshotRedirectUrl({
runId: 'bar_1',
organizationId: 'org_1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});

it('signs the URL without Content-Disposition when download is falsy', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
id: 'bar_1',
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
automation: { task: { organizationId: 'org_1' } },
});

await service.getScreenshotRedirectUrl({
runId: 'bar_1',
organizationId: 'org_1',
});

const command = (getSignedUrl as jest.Mock).mock.calls[0][1];
expect(command.input.ResponseContentDisposition).toBeUndefined();
});

it('signs the URL with attachment Content-Disposition when download is true', async () => {
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
id: 'bar_1',
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
automation: { task: { organizationId: 'org_1' } },
});

await service.getScreenshotRedirectUrl({
runId: 'bar_1',
organizationId: 'org_1',
download: true,
});

const command = (getSignedUrl as jest.Mock).mock.calls[0][1];
expect(command.input.ResponseContentDisposition).toBe(
'attachment; filename="screenshot-bar_1.jpg"',
);
});
});
Loading
Loading