Merge pull request #3 from RooCodeInc/cte/events-api

This commit is contained in:
Chris Estreich 2025-05-14 09:59:51 -07:00 committed by GitHub
commit 23087b29f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 379 additions and 290 deletions

View file

@ -6,6 +6,6 @@ set -u
if [ -n "$POSTGRES_DATABASES" ]; then
for db in $(echo $POSTGRES_DATABASES | tr ',' ' '); do
echo "Creating $db..."
psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE $db;"
psql -U postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $db;"
done
fi

1
.env
View file

@ -1,6 +1,7 @@
BILLING_PLAN_ENV=dev
VSCODE_EXTENSION_BASE_URL=vscode://RooVeterinaryInc.roo-cline
CURSOR_EXTENSION_BASE_URL=cursor://RooVeterinaryInc.roo-cline
# Clerk
# https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects

View file

@ -1,4 +1,4 @@
import '../src/styles/global.css';
import '../src/styles/globals.css';
import type { Preview } from '@storybook/react';

View file

@ -28,7 +28,7 @@ export default [
},
},
{
ignores: ['dist/**', '.next'],
ignores: ['dist/**', '.next', 'storybook-static'],
},
{
...pluginReact.configs.flat.recommended,

View file

@ -0,0 +1,8 @@
CREATE TABLE "events" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"type" text NOT NULL,
"timestamp" bigint NOT NULL,
"properties" json NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -1,12 +0,0 @@
CREATE TABLE "organization" (
"id" text PRIMARY KEY NOT NULL,
"stripe_customer_id" text,
"stripe_subscription_id" text,
"stripe_subscription_price_id" text,
"stripe_subscription_status" text,
"stripe_subscription_current_period_end" bigint,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "stripe_customer_id_idx" ON "organization" USING btree ("stripe_customer_id");

View file

@ -1,55 +1,47 @@
{
"id": "013accf9-070a-47cd-9b49-1318a1c8a237",
"id": "5ab333f5-a980-4564-a919-3d15608505d0",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.organization": {
"name": "organization",
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "events_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stripe_customer_id": {
"name": "stripe_customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripe_subscription_id": {
"name": "stripe_subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripe_subscription_price_id": {
"name": "stripe_subscription_price_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripe_subscription_status": {
"name": "stripe_subscription_status",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripe_subscription_current_period_end": {
"name": "stripe_subscription_current_period_end",
"timestamp": {
"name": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"properties": {
"name": "properties",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "now()"
"notNull": true
},
"created_at": {
"name": "created_at",
@ -57,25 +49,16 @@
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"stripe_customer_id_idx": {
"name": "stripe_customer_id_idx",
"columns": [
{
"expression": "stripe_customer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1747031111132,
"tag": "0000_misty_leo",
"when": 1747204609794,
"tag": "0000_hesitant_kylun",
"breakpoints": true
}
]

View file

@ -1,25 +0,0 @@
import { getSignInToken } from '@/lib/server/clerk';
import { Env } from '@/lib/server/env';
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import { type NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const authObj = await auth();
const userId = authObj.userId;
const state = request.nextUrl.searchParams.get('state') || '';
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const signInToken = await getSignInToken(authObj.userId);
if (!signInToken) {
throw new Error("couldn't sign in");
}
const url = new URL(`${Env.VSCODE_EXTENSION_BASE_URL}/auth/clerk/callback`);
url.searchParams.append('state', state);
url.searchParams.append('code', signInToken);
redirect(url.href);
}

View file

@ -0,0 +1,28 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { eventSchema } from '@/schemas';
import { db } from '@/db';
import { eventsTable } from '@/db/schema';
export async function POST(request: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json(
{ error: 'Unauthorized request' },
{ status: 401 },
);
}
const payload = await request.json();
const result = eventSchema.safeParse(payload);
if (!result.success) {
return NextResponse.json({ success: false });
}
const [record] = await db.insert(eventsTable).values(result.data).returning();
return NextResponse.json({ success: true, id: record?.id });
}

View file

@ -4,13 +4,12 @@ import { NextResponse } from 'next/server';
import { logger } from '@/lib/server/logger';
/**
* API endpoint for testing Clerk authentication
* Verifies/parses JWT and logs authenticated user information
* API endpoint for testing Clerk authentication.
* Verifies/parses JWT and logs authenticated user information.
*/
export async function GET() {
const authObj = await auth();
// If not authenticated
if (!authObj.userId) {
return NextResponse.json(
{ error: 'Unauthorized request' },
@ -18,35 +17,18 @@ export async function GET() {
);
}
// Get the JWT token
// Get the JWT token.
const token = await authObj.getToken();
// Extract user information from the auth object
// Only include properties that exist on the auth object
const userInfo = {
userId: authObj.userId,
sessionId: authObj.sessionId,
orgId: authObj.orgId,
orgRole: authObj.orgRole,
// Note: To get additional user data like email, firstName, lastName,
// you would need to use Clerk's methods like clerkClient.users.getUser()
};
// Extract user information from the auth object.
// Only include properties that exist on the auth object.
// Note: To get additional user data like email, firstName, lastName,
// you would need to use Clerk's methods like clerkClient.users.getUser().
const { userId, sessionId, orgId, orgRole } = authObj;
const userInfo = { userId, sessionId, orgId, orgRole };
// Log the user information
logger.info({
event: 'ping_endpoint_accessed',
userInfo: {
userId: authObj.userId,
sessionId: authObj.sessionId,
orgId: authObj.orgId,
orgRole: authObj.orgRole,
},
hasToken: !!token, // Just log if token exists, not the actual token for security
});
// Just log if token exists, not the actual token for security.
logger.info({ event: 'ping_endpoint_accessed', userInfo, hasToken: !!token });
// Return the user information
return NextResponse.json({
authenticated: true,
userInfo,
});
return NextResponse.json(userInfo);
}

View file

@ -0,0 +1,51 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button, Card, CardContent, CardFooter } from '@/components/ui';
import { Logo } from '@/components/layout';
interface DeepLinkProps {
vsCodeUrl: string;
cursorUrl: string;
}
export const DeepLink = ({ vsCodeUrl, cursorUrl }: DeepLinkProps) => {
const [redirectAttempted, setRedirectAttempted] = useState(false);
useEffect(() => {
window.location.href = vsCodeUrl;
const timer = setTimeout(() => setRedirectAttempted(true), 2000);
return () => clearTimeout(timer);
}, [vsCodeUrl]);
return (
<div className="flex flex-col justify-center gap-4 max-w-md h-screen mx-auto">
<Link href="/" className="text-center">
<Logo />
</Link>
<Card>
<CardContent className="flex flex-col gap-4">
<div>
You&apos;ve successfully authenticated. We&apos;re attempting to
open VSCode with your credentials.
</div>
<div className="flex flex-row justify-between gap-4">
<Button variant="outline" className="flex-1" asChild>
<Link href={vsCodeUrl}>Open in VSCode</Link>
</Button>
<Button variant="outline" className="flex-1" asChild>
<Link href={cursorUrl}>Open in Cursor</Link>
</Button>
</div>
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
{redirectAttempted
? 'You can close this window after VSCode opens.'
: 'Redirecting automatically...'}
</CardFooter>
</Card>
</div>
);
};

View file

@ -0,0 +1,31 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import { getSignInToken } from '@/lib/server/clerk';
import { Env } from '@/lib/server/env';
import { DeepLink } from './DeepLink';
export default async function Page(params: {
searchParams: { state?: string };
}) {
const { state } = await params.searchParams;
if (!state) {
redirect(`/sign-in`);
}
const { userId } = await auth();
const code = userId ? await getSignInToken(userId) : undefined;
if (!code) {
redirect(`/sign-in?state=${state}`);
}
const searchParams = new URLSearchParams({ state, code });
const path = `/auth/clerk/callback?${searchParams.toString()}`;
const vsCodeUrl = new URL(path, Env.VSCODE_EXTENSION_BASE_URL);
const cursorUrl = new URL(path, Env.CURSOR_EXTENSION_BASE_URL);
return <DeepLink vsCodeUrl={vsCodeUrl.href} cursorUrl={cursorUrl.href} />;
}

View file

@ -1,5 +1,7 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
@ -10,13 +12,22 @@ import { Logo } from '@/components/layout';
export const SignIn = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
const searchParams = useSearchParams();
const forceRedirectUrl = useMemo(() => {
const state = searchParams.get('state');
return state ? `/extension/sign-in?state=${state}` : undefined;
}, [searchParams]);
return (
<div className="flex flex-col gap-8 h-screen w-full items-center justify-center">
<Link href="/">
<Logo />
</Link>
<ClerkSignIn appearance={{ baseTheme }} />
<ClerkSignIn
appearance={{ baseTheme }}
forceRedirectUrl={forceRedirectUrl}
/>
</div>
);
};

View file

@ -1,5 +1,7 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
@ -10,13 +12,22 @@ import { Logo } from '@/components/layout';
export const SignUp = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
const searchParams = useSearchParams();
const forceRedirectUrl = useMemo(() => {
const state = searchParams.get('state');
return state ? `/extension/sign-in?state=${state}` : undefined;
}, [searchParams]);
return (
<div className="flex flex-col gap-8 h-screen w-full items-center justify-center">
<Link href="/">
<Logo />
</Link>
<ClerkSignUp appearance={{ baseTheme }} />
<ClerkSignUp
appearance={{ baseTheme }}
forceRedirectUrl={forceRedirectUrl}
/>
</div>
);
};

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {

View file

@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-sm border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View file

@ -1,3 +1,4 @@
export * from './button';
export * from './card';
export * from './dropdown-menu';
export * from './separator';

View file

@ -3,30 +3,39 @@ import {
pgTable,
text,
timestamp,
uniqueIndex,
json,
integer,
} from 'drizzle-orm/pg-core';
export const organizationSchema = pgTable(
'organization',
{
id: text('id').primaryKey(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
stripeSubscriptionPriceId: text('stripe_subscription_price_id'),
stripeSubscriptionStatus: text('stripe_subscription_status'),
stripeSubscriptionCurrentPeriodEnd: bigint(
'stripe_subscription_current_period_end',
{ mode: 'number' },
),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
},
(table) => ({
stripeCustomerIdIdx: uniqueIndex('stripe_customer_id_idx').on(
table.stripeCustomerId,
),
}),
);
// export const organizationTable = pgTable(
// 'organizations',
// {
// id: integer().primaryKey().generatedAlwaysAsIdentity(),
// stripeCustomerId: text('stripe_customer_id'),
// stripeSubscriptionId: text('stripe_subscription_id'),
// stripeSubscriptionPriceId: text('stripe_subscription_price_id'),
// stripeSubscriptionStatus: text('stripe_subscription_status'),
// stripeSubscriptionCurrentPeriodEnd: bigint(
// 'stripe_subscription_current_period_end',
// { mode: 'number' },
// ),
// updatedAt: timestamp('updated_at', { mode: 'date' })
// .defaultNow()
// .$onUpdate(() => new Date())
// .notNull(),
// createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
// },
// (table) => [uniqueIndex('stripe_customer_id_idx').on(table.stripeCustomerId)],
// );
export const eventsTable = pgTable('events', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
type: text('type').notNull(),
timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
properties: json('properties').notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

View file

@ -10,6 +10,7 @@ export const Env = createEnv({
STRIPE_WEBHOOK_SECRET: z.string().min(1),
BILLING_PLAN_ENV: z.enum(['dev', 'test', 'prod']),
VSCODE_EXTENSION_BASE_URL: z.string().min(1),
CURSOR_EXTENSION_BASE_URL: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().optional(),
@ -34,6 +35,7 @@ export const Env = createEnv({
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
BILLING_PLAN_ENV: process.env.BILLING_PLAN_ENV,
VSCODE_EXTENSION_BASE_URL: process.env.VSCODE_EXTENSION_BASE_URL,
CURSOR_EXTENSION_BASE_URL: process.env.CURSOR_EXTENSION_BASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,

53
src/schemas/index.ts Normal file
View file

@ -0,0 +1,53 @@
import { z } from 'zod';
export const providerNames = [
'anthropic',
'glama',
'openrouter',
'bedrock',
'vertex',
'openai',
'ollama',
'vscode-lm',
'lmstudio',
'gemini',
'openai-native',
'mistral',
'deepseek',
'unbound',
'requesty',
'human-relay',
'fake-ai',
'xai',
'groq',
'chutes',
'litellm',
] as const;
export const eventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('task_created'),
timestamp: z.number(),
properties: z.object({
taskId: z.string(),
provider: z.enum(providerNames),
modelId: z.string(),
prompt: z.string(),
mode: z.string(),
}),
}),
z.object({
type: z.literal('completion'),
timestamp: z.number(),
properties: z.object({
taskId: z.string(),
provider: z.enum(providerNames),
modelId: z.string(),
inputTokens: z.number(),
outputTokens: z.number(),
cost: z.number(),
}),
}),
]);
export type Event = z.infer<typeof eventSchema>;

View file

@ -1,14 +0,0 @@
import type { EnumValues } from './enum';
export const ORG_ROLE = {
ADMIN: 'org:admin',
MEMBER: 'org:member',
} as const;
export type OrgRole = EnumValues<typeof ORG_ROLE>;
export const ORG_PERMISSION = {
// Add Organization Permissions here.
} as const;
export type OrgPermission = EnumValues<typeof ORG_PERMISSION>;

View file

@ -1,28 +0,0 @@
import type { EnumValues } from './enum';
export const PLAN_ID = {
FREE: 'free',
PREMIUM: 'premium',
ENTERPRISE: 'enterprise',
} as const;
export type PlanId = EnumValues<typeof PLAN_ID>;
export const BILLING_INTERVAL = {
MONTH: 'month',
YEAR: 'year',
} as const;
export type BillingInterval = EnumValues<typeof BILLING_INTERVAL>;
export const SUBSCRIPTION_STATUS = {
ACTIVE: 'active',
PENDING: 'pending',
} as const;
export type IStripeSubscription = {
stripeSubscriptionId: string | null;
stripeSubscriptionPriceId: string | null;
stripeSubscriptionStatus: string | null;
stripeSubscriptionCurrentPeriodEnd: number | null;
};

View file

@ -1 +0,0 @@
export type EnumValues<Type> = Type[keyof Type];

View file

@ -1,8 +0,0 @@
import type { OrgPermission, OrgRole } from '@/types/auth';
declare global {
interface ClerkAuthorization {
permission: OrgPermission;
role: OrgRole;
}
}

View file

@ -1,34 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('I18n', () => {
test.describe('Language Switching', () => {
test('should switch language from English to French using dropdown and verify text on the homepage', async ({
page,
}) => {
await page.goto('/');
await expect(
page.getByText('The perfect SaaS template to build'),
).toBeVisible();
await page.getByRole('button', { name: 'lang-switcher' }).click();
await page.getByText('Français').click();
await expect(
page.getByText('Le parfait SaaS template pour construire'),
).toBeVisible();
});
test('should switch language from English to French using URL and verify text on the sign-in page', async ({
page,
}) => {
await page.goto('/sign-in');
await expect(page.getByText('Email address')).toBeVisible();
await page.goto('/fr/sign-in');
await expect(page.getByText('Adresse e-mail')).toBeVisible();
});
});
});

View file

@ -1,25 +0,0 @@
import { expect, test } from '@playwright/test';
// Checkly is a tool used to monitor deployed environments, such as production or preview environments.
// It runs end-to-end tests with the `.check.e2e.ts` extension after each deployment to ensure that the environment is up and running.
// With Checkly, you can monitor your production environment and run `*.check.e2e.ts` tests regularly at a frequency of your choice.
// If the tests fail, Checkly will notify you via email, Slack, or other channels of your choice.
// On the other hand, E2E tests ending with `*.e2e.ts` are only run before deployment.
// You can run them locally or on CI to ensure that the application is ready for deployment.
// BaseURL needs to be explicitly defined in the test file.
// Otherwise, Checkly runtime will throw an exception: `CHECKLY_INVALID_URL: Only URL's that start with http(s)`
// You can't use `goto` function directly with a relative path like with other *.e2e.ts tests.
// Check the example at https://feedback.checklyhq.com/changelog/new-changelog-436
test.describe('Sanity', () => {
test.describe('Static pages', () => {
test('should display the homepage', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/`);
await expect(
page.getByText('The perfect SaaS template to build'),
).toBeVisible();
});
});
});

View file

@ -1,26 +0,0 @@
import percySnapshot from '@percy/playwright';
import { expect, test } from '@playwright/test';
test.describe('Visual testing', () => {
test.describe('Static pages', () => {
test('should take screenshot of the homepage', async ({ page }) => {
await page.goto('/');
await expect(
page.getByText('The perfect SaaS template to build'),
).toBeVisible();
await percySnapshot(page, 'Homepage');
});
test('should take screenshot of the French homepage', async ({ page }) => {
await page.goto('/fr');
await expect(
page.getByText('Le parfait SaaS template pour construire'),
).toBeVisible();
await percySnapshot(page, 'Homepage - French');
});
});
});

View file

@ -1 +0,0 @@
Place your integration tests in this folder by creating files with the `*.spec.ts` extension. These files will be automatically detected by Playwright. For a real-world example, you can check out the Next.js Boilerplate Pro at https://nextjs-boilerplate.com/pro-saas-starter-kit