Move screenshots to the server pt 1

This commit is contained in:
Nate Kelley 2025-10-06 11:51:41 -06:00
parent 9b780e03c4
commit 721b20af8a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
25 changed files with 470 additions and 123 deletions

View File

@ -15,6 +15,7 @@ declare global {
SLACK_APP_SUPPORT_URL: string;
SERVER_URL: string;
NODE_ENV?: 'development' | 'production' | 'test';
VITE_PUBLIC_URL: string;
}
}
}

View File

@ -47,6 +47,7 @@
"lodash-es": "catalog:",
"pino": "^9.10.0",
"pino-pretty": "^13.1.1",
"playwright": "^1.55.1",
"tsup": "catalog:",
"yaml": "^2.8.1",
"zod": "catalog:"

View File

@ -19,6 +19,8 @@ const requiredEnv = {
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
SLACK_APP_SUPPORT_URL: process.env.SLACK_APP_SUPPORT_URL,
SERVER_URL: process.env.SERVER_URL,
//WEB_URL
VITE_PUBLIC_URL: process.env.VITE_PUBLIC_URL,
};
// Validate environment variables

View File

@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls';
import { getChatById } from '@buster/database/queries';
import {
AssetIdParamsSchema,
PutScreenshotRequestSchema,
PutMetricScreenshotRequestSchema,
type PutScreenshotResponse,
} from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc
const app = new Hono().put(
'/',
zValidator('json', PutScreenshotRequestSchema),
zValidator('json', PutMetricScreenshotRequestSchema),
zValidator('param', AssetIdParamsSchema),
async (c) => {
const assetId = c.req.valid('param').id;

View File

@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls';
import { getDashboardById } from '@buster/database/queries';
import {
AssetIdParamsSchema,
PutScreenshotRequestSchema,
PutDashboardScreenshotRequestSchema,
type PutScreenshotResponse,
} from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc
const app = new Hono().put(
'/',
zValidator('json', PutScreenshotRequestSchema),
zValidator('json', PutDashboardScreenshotRequestSchema),
zValidator('param', AssetIdParamsSchema),
async (c) => {
const assetId = c.req.valid('param').id;

View File

@ -1,10 +1,11 @@
import type { ColorPaletteDictionariesResponse } from '@buster/server-shared/dictionary';
import { Hono } from 'hono';
import { requireAuth } from '../../../../middleware/auth';
import { ALL_DICTIONARY_THEMES } from './config';
const app = new Hono();
app.get('/', async (c) => {
app.get('/', requireAuth, async (c) => {
const response: ColorPaletteDictionariesResponse = ALL_DICTIONARY_THEMES;
return c.json(response);
});

View File

@ -1,11 +1,13 @@
import type { CurrencyResponse } from '@buster/server-shared/dictionary';
import { Hono } from 'hono';
import { requireAuth } from '../../../../middleware/auth';
import { CURRENCIES_MAP } from './config';
const app = new Hono();
app.get('/', async (c) => {
app.get('/', requireAuth, async (c) => {
const response: CurrencyResponse = CURRENCIES_MAP;
return c.json(response);
});

View File

@ -1,64 +1,67 @@
import { checkPermission } from '@buster/access-controls';
import { getMetricFileById } from '@buster/database/queries';
import { getAssetScreenshotSignedUrl } from '@buster/search';
import { AssetIdParamsSchema, type GetScreenshotResponse } from '@buster/server-shared/screenshots';
import {
GetMetricScreenshotParamsSchema,
GetMetricScreenshotQuerySchema,
} from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { standardErrorHandler } from '../../../../../utils/response';
import { getMetricScreenshotHandler } from './getMetricScreenshotHandler';
const app = new Hono().get('/', zValidator('param', AssetIdParamsSchema), async (c) => {
const metricId = c.req.valid('param').id;
const user = c.get('busterUser');
const app = new Hono()
.get(
'/',
zValidator('param', GetMetricScreenshotParamsSchema),
zValidator('query', GetMetricScreenshotQuerySchema),
async (c) => {
const metricId = c.req.valid('param').id;
const { version_number, width, height, type } = c.req.valid('query');
const user = c.get('busterUser');
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
const metric = await getMetricFileById(metricId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
if (!metric.screenshotBucketKey) {
const result: GetScreenshotResponse = {
success: false,
error: 'Screenshot not found',
};
return c.json(result);
}
const permission = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'can_view',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
const permission = await checkPermission({
userId: user.id,
assetId: metricId,
assetType: 'metric_file',
requiredRole: 'can_view',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permission.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to view this metric',
});
}
if (!permission.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to view this metric',
});
}
try {
const screenshotBuffer = await getMetricScreenshotHandler({
params: { id: metricId },
search: { version_number, width, height, type },
context: c,
});
try {
const signedUrl = await getAssetScreenshotSignedUrl({
key: metric.screenshotBucketKey,
organizationId: metric.organizationId,
});
const result: GetScreenshotResponse = {
success: true,
url: signedUrl,
};
return c.json(result);
} catch (error) {
console.error('Failed to generate metric screenshot URL', {
metricId,
error,
});
const result: GetScreenshotResponse = {
success: false,
error: 'Failed to generate screenshot URL',
};
return c.json(result);
}
});
return new Response(screenshotBuffer, {
headers: {
'Content-Type': `image/${type}`,
'Content-Length': screenshotBuffer.length.toString(),
},
});
} catch (error) {
console.error('Failed to generate metric image', {
metricId,
error,
});
throw new Error('Failed to generate metric image');
}
}
)
.onError(standardErrorHandler);
export default app;

View File

@ -1,53 +1,52 @@
import { checkPermission } from '@buster/access-controls';
import { getMetricFileById } from '@buster/database/queries';
import {
AssetIdParamsSchema,
PutScreenshotRequestSchema,
type PutScreenshotResponse,
} from '@buster/server-shared/screenshots';
import { AssetIdParamsSchema, type PutScreenshotResponse } from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-screenshot-handler';
import { getMetricScreenshotHandler } from './getMetricScreenshotHandler';
const app = new Hono().put(
'/',
zValidator('json', PutScreenshotRequestSchema),
zValidator('param', AssetIdParamsSchema),
async (c) => {
const assetId = c.req.valid('param').id;
const { base64Image } = c.req.valid('json');
const user = c.get('busterUser');
const app = new Hono().put('/', zValidator('param', AssetIdParamsSchema), async (c) => {
const assetId = c.req.valid('param').id;
const user = c.get('busterUser');
const metric = await getMetricFileById(assetId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
const permission = await checkPermission({
userId: user.id,
assetId,
assetType: 'metric_file',
requiredRole: 'can_edit',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permission.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to upload a screenshot for this metric',
});
}
const result: PutScreenshotResponse = await uploadScreenshotHandler({
assetType: 'metric_file',
assetId,
base64Image,
organizationId: metric.organizationId,
});
return c.json(result);
const metric = await getMetricFileById(assetId);
if (!metric) {
throw new HTTPException(404, { message: 'Metric not found' });
}
);
const permission = await checkPermission({
userId: user.id,
assetId,
assetType: 'metric_file',
requiredRole: 'can_edit',
workspaceSharing: metric.workspaceSharing,
organizationId: metric.organizationId,
});
if (!permission.hasAccess) {
throw new HTTPException(403, {
message: 'You do not have permission to upload a screenshot for this metric',
});
}
const screenshotBuffer = await getMetricScreenshotHandler({
params: { metricId: assetId },
search: { version_number: 1, width: 800, height: 600, type: 'png' },
context: c,
});
const base64Image = screenshotBuffer.toString('base64');
const result: PutScreenshotResponse = await uploadScreenshotHandler({
assetType: 'metric_file',
assetId,
base64Image,
organizationId: metric.organizationId,
});
return c.json(result);
});
export default app;

View File

@ -0,0 +1,37 @@
import type {
GetMetricScreenshotParams,
GetMetricScreenshotQuery,
} from '@buster/server-shared/screenshots';
import type { Context } from 'hono';
import { createHrefFromLink } from '../../../../../shared-helpers/create-href-from-link';
export const getMetricScreenshotHandler = async ({
params,
search,
context,
}: {
params: GetMetricScreenshotParams;
search: GetMetricScreenshotQuery;
context: Context;
}) => {
const { width, height, type, version_number } = search;
const { id: metricId } = params;
const { browserLogin } = await import('../../../../../shared-helpers/browser-login');
const { result: screenshotBuffer } = await browserLogin({
width,
height,
fullPath: createHrefFromLink({
to: '/screenshots/metrics/$metricId/content' as const,
params: { metricId },
search: { version_number, type, width, height },
}),
context,
callback: async ({ page }) => {
return await page.screenshot({ type });
},
});
return screenshotBuffer;
};

View File

@ -2,7 +2,7 @@ import { checkPermission } from '@buster/access-controls';
import { getReportFileById } from '@buster/database/queries';
import {
AssetIdParamsSchema,
PutScreenshotRequestSchema,
PutReportScreenshotRequestSchema,
type PutScreenshotResponse,
} from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
@ -12,7 +12,7 @@ import { uploadScreenshotHandler } from '../../../../../shared-helpers/upload-sc
const app = new Hono().put(
'/',
zValidator('json', PutScreenshotRequestSchema),
zValidator('json', PutReportScreenshotRequestSchema),
zValidator('param', AssetIdParamsSchema),
async (c) => {
const assetId = c.req.valid('param').id;

View File

@ -7,7 +7,12 @@ import { getSupabaseClient } from './supabase';
export const requireAuth = bearerAuth({
verifyToken: async (token, c) => {
try {
const { data, error } = await getSupabaseClient().auth.getUser(token); //usually takes about 3 - 7ms
const supabase = getSupabaseClient();
const { data, error } = await supabase.auth.getUser(token); //usually takes about 3 - 7ms
const supabaseCookieKey = (supabase as unknown as { storageKey: string }).storageKey;
c.set('supabaseCookieKey', supabaseCookieKey);
c.set('accessToken', token);
if (error) {
// Log specific auth errors to help with debugging

View File

@ -0,0 +1,93 @@
import type { Context } from 'hono';
import type { Browser, Page } from 'playwright';
export const browserLogin = async <T = Buffer<ArrayBufferLike>>({
width,
height,
fullPath,
callback,
context,
}: {
width: number;
height: number;
fullPath: string;
callback: ({ page, browser }: { page: Page; browser: Browser }) => Promise<T>;
context: Context;
}) => {
const supabaseUser = context.get('supabaseUser');
const supabaseCookieKey = context.get('supabaseCookieKey');
const accessToken = context.get('accessToken');
if (!accessToken) {
throw new Error('Missing Authorization header');
}
if (accessToken.split('.').length !== 3) {
throw new Error('Invalid access token format');
}
const jwtPayload = JSON.parse(Buffer.from(accessToken.split('.')[1] || '', 'base64').toString());
if (!supabaseUser || supabaseUser?.is_anonymous) {
throw new Error('User not authenticated');
}
const session = {
access_token: accessToken,
token_type: 'bearer',
expires_in: 3600,
expires_at: jwtPayload.exp,
refresh_token: '',
user: supabaseUser,
};
const { chromium } = await import('playwright');
const browser = await chromium.launch();
try {
const context = await browser.newContext({
viewport: { width, height },
});
// Format cookie value as Supabase expects: base64-<encoded_session>
const cookieValue = `base64-${Buffer.from(JSON.stringify(session)).toString('base64')}`;
await context.addCookies([
{
name: supabaseCookieKey,
value: cookieValue,
domain: new URL(process.env.VITE_PUBLIC_URL || {}).hostname,
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
]);
const page = await context.newPage();
let pageError: Error | null = null;
page.on('console', (msg) => {
const text = msg.text();
// React logs errors to console even when caught by error boundaries
if (msg.type() === 'error' && (text.includes('Error:') || text.includes('occurred in'))) {
pageError = new Error(`Page error: ${text}`);
}
});
await page.goto(fullPath, { waitUntil: 'networkidle' });
const result = await callback({ page, browser });
if (pageError) {
throw pageError;
}
return { result };
} catch (error) {
console.error('Error logging in to browser', error);
await browser.close();
throw error;
}
};

View File

@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createHrefFromLink } from './create-href-from-link';
describe('createHrefFromLink', () => {
const originalEnv = process.env.VITE_PUBLIC_URL;
beforeEach(() => {
process.env.VITE_PUBLIC_URL = 'https://example.com';
});
afterEach(() => {
process.env.VITE_PUBLIC_URL = originalEnv;
});
it('should replace single param placeholder with value', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
});
expect(result).toBe('https://example.com/metrics/123');
});
it('should replace multiple param placeholders with values', () => {
const result = createHrefFromLink({
to: '/orgs/$orgId/metrics/$metricId',
params: { orgId: 'org-456', metricId: '123' },
});
expect(result).toBe('https://example.com/orgs/org-456/metrics/123');
});
it('should append query parameters', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
search: { width: 800, height: 600 },
});
expect(result).toBe('https://example.com/metrics/123?width=800&height=600');
});
it('should handle string, number, and boolean query params', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
search: { type: 'png', width: 800, fullscreen: true },
});
expect(result).toBe('https://example.com/metrics/123?type=png&width=800&fullscreen=true');
});
it('should filter out undefined query params', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
search: { width: 800, height: undefined, type: 'png' },
});
expect(result).toBe('https://example.com/metrics/123?width=800&type=png');
});
it('should work without query params', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
});
expect(result).toBe('https://example.com/metrics/123');
});
it('should work with empty query params object', () => {
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
search: {},
});
expect(result).toBe('https://example.com/metrics/123');
});
it('should handle paths without params', () => {
const result = createHrefFromLink({
to: '/health',
params: {},
search: { check: 'full' },
});
expect(result).toBe('https://example.com/health?check=full');
});
it('should work with empty base URL', () => {
process.env.VITE_PUBLIC_URL = '';
const result = createHrefFromLink({
to: '/metrics/$metricId',
params: { metricId: '123' },
});
expect(result).toBe('/metrics/123');
});
it('should handle complex screenshot route', () => {
const result = createHrefFromLink({
to: '/screenshots/metrics/$metricId/content',
params: { metricId: 'abc-123' },
search: { version_number: 5, type: 'png', width: 1920, height: 1080 },
});
expect(result).toBe(
'https://example.com/screenshots/metrics/abc-123/content?version_number=5&type=png&width=1920&height=1080'
);
});
});

View File

@ -0,0 +1,43 @@
import { z } from 'zod';
const CreateHrefFromLinkParamsSchema = z.object({
to: z.string().describe('Route path with param placeholders like /path/$paramName'),
params: z.record(z.string()).describe('Object mapping param names to values'),
search: z.record(z.union([z.string(), z.number(), z.boolean(), z.undefined()])).optional().describe('Query parameters'),
});
type CreateHrefFromLinkParams = z.infer<typeof CreateHrefFromLinkParamsSchema>;
/**
* Creates a full href from a route definition by:
* 1. Replacing $paramName placeholders with actual values
* 2. Appending query parameters
* 3. Prepending the public URL domain
*/
export function createHrefFromLink(input: CreateHrefFromLinkParams): string {
const { to, params, search } = CreateHrefFromLinkParamsSchema.parse(input);
// Replace $paramName with actual param values
let path = to;
for (const [key, value] of Object.entries(params)) {
path = path.replace(`$${key}`, value);
}
// Build query string from search params
const queryParams = new URLSearchParams();
if (search) {
for (const [key, value] of Object.entries(search)) {
if (value !== undefined) {
queryParams.append(key, String(value));
}
}
}
const queryString = queryParams.toString();
const fullPath = queryString ? `${path}?${queryString}` : path;
// Get base URL from environment
const baseUrl = process.env.VITE_PUBLIC_URL || '';
return `${baseUrl}${fullPath}`;
}

View File

@ -3,13 +3,13 @@ import { updateAssetScreenshotBucketKey } from '@buster/database/queries';
import type { AssetType } from '@buster/server-shared/assets';
import { AssetTypeSchema } from '@buster/server-shared/assets';
import {
PutScreenshotRequestSchema,
PutChatScreenshotRequestSchema,
type PutScreenshotResponse,
PutScreenshotResponseSchema,
} from '@buster/server-shared/screenshots';
import z from 'zod';
export const UploadScreenshotParamsSchema = PutScreenshotRequestSchema.extend({
export const UploadScreenshotParamsSchema = PutChatScreenshotRequestSchema.extend({
assetType: AssetTypeSchema,
assetId: z.string().uuid('Asset ID must be a valid UUID'),
organizationId: z.string().uuid('Organization ID must be a valid UUID'),

View File

@ -6,6 +6,12 @@ import type { User } from '@supabase/supabase-js';
declare module 'hono' {
interface ContextVariableMap {
/**
* The Supabase cookie key. This is used to set the cookie in the browser.
* It is the cookie that supabase uses to store the user's session.
* We use it on the server for playwright auth setting
*/
readonly supabaseCookieKey: string;
/**
* The authenticated Supabase user. This object is readonly to prevent accidental mutation.
*/
@ -31,5 +37,9 @@ declare module 'hono' {
* API key context for public API endpoints. Set by the createApiKeyAuthMiddleware.
*/
readonly apiKey?: ApiKeyContext;
/**
* The access token for the user. Set by the requireAuth middleware.
*/
readonly accessToken?: string;
}
}

View File

@ -28,30 +28,23 @@ export const Route = createFileRoute('/screenshots/metrics/$metricId/')({
const { result: screenshotBuffer } = await browserLogin({
width,
height,
request,
fullPath: createHrefFromLink({
to: '/screenshots/metrics/$metricId/content',
params: { metricId },
search: { version_number, type, width, height },
}),
request,
callback: async ({ page }) => {
const screenshotBuffer = await page.screenshot({
return await page.screenshot({
type,
});
return screenshotBuffer;
},
});
return createScreenshotResponse({ screenshotBuffer });
} catch (error) {
console.error('Error capturing metric screenshot', error);
return new Response(
JSON.stringify({
message: 'Failed to capture screenshot',
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
console.error('Error generating metric image', error);
throw Error('Failed to generate metric image');
}
},
},

View File

@ -1 +1,5 @@
export * from './screenshots';
export * from './requests.metrics';
export * from './requests.dashboards';
export * from './requests.reports';
export * from './requests.chats';

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const PutChatScreenshotRequestSchema = z.object({
base64Image: z.string(),
});
export type PutChatScreenshotRequest = z.infer<typeof PutChatScreenshotRequestSchema>;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const PutDashboardScreenshotRequestSchema = z.object({
base64Image: z.string(),
});
export type PutDashboardScreenshotRequest = z.infer<typeof PutDashboardScreenshotRequestSchema>;

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
export const GetMetricScreenshotParamsSchema = z.object({
id: z.string(),
});
export const GetMetricScreenshotQuerySchema = z.object({
version_number: z.coerce.number().min(1).optional(),
width: z.coerce.number().min(100).max(3840).default(800),
height: z.coerce.number().min(100).max(2160).default(450),
type: z.enum(['png', 'jpeg']).default('png'),
});
export type GetMetricScreenshotParams = z.infer<typeof GetMetricScreenshotParamsSchema>;
export type GetMetricScreenshotQuery = z.infer<typeof GetMetricScreenshotQuerySchema>;
export const PutMetricScreenshotRequestSchema = z.object({
base64Image: z.string(),
});
export type PutMetricScreenshotRequest = z.infer<typeof PutMetricScreenshotRequestSchema>;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const PutReportScreenshotRequestSchema = z.object({
base64Image: z.string(),
});
export type PutReportScreenshotRequest = z.infer<typeof PutReportScreenshotRequestSchema>;

View File

@ -4,12 +4,6 @@ export const AssetIdParamsSchema = z.object({
id: z.string().uuid('Asset ID must be a valid UUID'),
});
export const PutScreenshotRequestSchema = z.object({
base64Image: z.string().min(1, 'Base64 image is required'),
});
export type PutScreenshotRequest = z.infer<typeof PutScreenshotRequestSchema>;
export const PutScreenshotResponseSchema = z.object({
success: z.boolean(),
bucketKey: z.string().min(1, 'Bucket key is required'),

View File

@ -315,6 +315,9 @@ importers:
pino-pretty:
specifier: ^13.1.1
version: 13.1.1
playwright:
specifier: ^1.55.1
version: 1.55.1
tsup:
specifier: 'catalog:'
version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.5)(typescript@5.9.2)(yaml@2.8.1)
@ -18833,7 +18836,7 @@ snapshots:
magic-string: 0.30.17
sirv: 3.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
ws: 8.18.3
optionalDependencies:
playwright: 1.55.1
@ -18878,7 +18881,7 @@ snapshots:
std-env: 3.9.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
optionalDependencies:
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
transitivePeerDependencies: