orizu 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,976 @@
1
+ import { createServer } from 'http'
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'fs'
11
+ import { tmpdir } from 'os'
12
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
13
+ import { fileURLToPath, pathToFileURL } from 'url'
14
+
15
+ export const PREVIEW_RUNTIME_REGISTRY_VERSION = 1
16
+
17
+ // BEGIN_ORIZU_PREVIEW_IMPORT_REGISTRY
18
+ export const PREVIEW_ALLOWED_IMPORTS: Record<string, string[]> = {
19
+ '@/components/ui/button': ['Button'],
20
+ '@/components/ui/card': ['Card'],
21
+ '@/components/ui/carousel': ['Carousel'],
22
+ '@/components/ui/checkbox': ['Checkbox'],
23
+ '@/components/ui/form': ['Form'],
24
+ '@/components/ui/input': ['Input'],
25
+ '@/components/ui/label': ['Label'],
26
+ '@/components/ui/progress': ['Progress'],
27
+ '@/components/ui/radio-group': ['RadioGroup', 'RadioGroupItem'],
28
+ '@/components/ui/scroll-area': ['ScrollArea'],
29
+ '@/components/ui/select': ['Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue'],
30
+ '@/components/ui/separator': ['Separator'],
31
+ '@/components/ui/switch': ['Switch'],
32
+ '@/components/ui/table': ['Table', 'TableBody', 'TableCell', 'TableHead', 'TableHeader', 'TableRow', 'TableCaption', 'TableFooter'],
33
+ '@/components/ui/tabs': ['Tabs', 'TabsContent', 'TabsList', 'TabsTrigger'],
34
+ '@/components/ui/textarea': ['Textarea'],
35
+ '@/components/ui/toggle': ['Toggle'],
36
+ '@/components/ui/toggle-group': ['ToggleGroup', 'ToggleGroupItem'],
37
+ '@/components/ui/tooltip': ['Tooltip', 'TooltipContent', 'TooltipProvider', 'TooltipTrigger'],
38
+ '@/components/base/content/TextContent': ['TextContent', 'default'],
39
+ '@/components/base/content/CodeBlock': ['CodeBlock', 'default'],
40
+ '@/components/base/content/ConversationView': ['AssistantMessageBlock', 'ContextMessageBlock', 'ConversationMessageBlock', 'ConversationView', 'ReasoningMessageBlock', 'SystemMessageBlock', 'ToolCallBlock', 'ToolResultBlock', 'UserMessageBlock', 'default'],
41
+ '@/components/base/content/ContentRenderer': ['ContentRenderer', 'default'],
42
+ '@/components/base/content/Prose': ['Prose', 'default'],
43
+ '@/components/base/behaviors/Annotatable': ['Annotatable', 'default'],
44
+ '@/components/base/behaviors/Reactable': ['Reactable', 'default'],
45
+ '@/components/base/input/CommentBox': ['CommentBox'],
46
+ '@/components/base/input/CriterionRating': ['CriterionRating'],
47
+ '@/components/base/input/LikertScale': ['LikertScale'],
48
+ '@/components/base/input/NumericRating': ['NumericRating'],
49
+ '@/components/base/input/RatingSelector': ['RatingSelector'],
50
+ '@/components/base/input/StarRating': ['StarRating'],
51
+ '@/components/base/input/TagPicker': ['TagPicker'],
52
+ '@/components/base/input/ThumbsRating': ['ThumbsRating'],
53
+ '@/components/base/ui/ComparisonPanel': ['ComparisonPanel'],
54
+ '@/components/base/ui/DraggableItem': ['DraggableItem'],
55
+ '@/components/templates/classification/TagSelector': ['TagSelector'],
56
+ '@/components/templates/comparison/SideBySideComparison': ['SideBySideComparison'],
57
+ '@/components/templates/correction/CorrectionTask': ['CorrectionTask'],
58
+ '@/components/templates/code/CodeComparison': ['CodeComparison'],
59
+ '@/components/templates/qa/ContextualQA': ['ContextualQA'],
60
+ '@/components/templates/ranking/RankingList': ['RankingList'],
61
+ '@/components/templates/rating/SingleItemRater': ['SingleItemRater'],
62
+ }
63
+ // END_ORIZU_PREVIEW_IMPORT_REGISTRY
64
+
65
+ export interface AppPreviewOptions {
66
+ filePath: string
67
+ inputSchema: Record<string, unknown>
68
+ outputSchema: Record<string, unknown>
69
+ sampleRow: Record<string, unknown>
70
+ screenshotPath?: string
71
+ headed?: boolean
72
+ keepOpen?: boolean
73
+ componentName?: string
74
+ cwd?: string
75
+ forceSnapshot?: boolean
76
+ playwright?: PreviewPlaywright
77
+ chromiumExecutablePath?: string | null
78
+ }
79
+
80
+ export interface AppPreviewResult {
81
+ url: string
82
+ screenshotPath?: string
83
+ warnings: string[]
84
+ }
85
+
86
+ interface Issue {
87
+ level: 'error' | 'warn'
88
+ message: string
89
+ }
90
+
91
+ interface PreviewPlaywright {
92
+ chromium: {
93
+ launch: (options: Record<string, unknown>) => Promise<PreviewBrowser>
94
+ }
95
+ }
96
+
97
+ interface PreviewBrowser {
98
+ newPage: (options: Record<string, unknown>) => Promise<PreviewPage>
99
+ close: () => Promise<void>
100
+ once?: (event: string, listener: () => void) => unknown
101
+ on?: (event: string, listener: () => void) => unknown
102
+ off?: (event: string, listener: () => void) => unknown
103
+ }
104
+
105
+ interface PreviewPage {
106
+ goto: (url: string, options: Record<string, unknown>) => Promise<unknown>
107
+ waitForSelector: (selector: string, options: Record<string, unknown>) => Promise<unknown>
108
+ screenshot: (options: { path: string; fullPage: boolean }) => Promise<unknown>
109
+ waitForTimeout: (timeout: number) => Promise<unknown>
110
+ once?: (event: string, listener: () => void) => unknown
111
+ on?: (event: string, listener: () => void) => unknown
112
+ off?: (event: string, listener: () => void) => unknown
113
+ }
114
+
115
+ const SUPPORTED_SCHEMA_KEYS = new Set(['type', 'required', 'properties', 'items', 'enum'])
116
+
117
+ export function validatePreviewInputs(options: AppPreviewOptions): Issue[] {
118
+ const issues: Issue[] = []
119
+ const source = readFileSync(options.filePath, 'utf8')
120
+ validateImports(source, issues)
121
+ validateDefaultExport(source, options.componentName, issues)
122
+ validateSchema(options.inputSchema, 'input schema', issues)
123
+ validateSchema(options.outputSchema, 'output schema', issues)
124
+ validatePayload(options.inputSchema, options.sampleRow, 'sample row', issues)
125
+ return issues
126
+ }
127
+
128
+ export async function runLocalAppPreview(options: AppPreviewOptions): Promise<AppPreviewResult> {
129
+ const issues = validatePreviewInputs(options)
130
+ const errors = issues.filter(issue => issue.level === 'error')
131
+ if (errors.length > 0) {
132
+ throw new Error(errors.map(issue => issue.message).join('\n'))
133
+ }
134
+
135
+ const tempDir = mkdtempSync(join(tmpdir(), 'orizu-app-preview-'))
136
+ const appPath = realpathSync(options.filePath)
137
+ const repoRoot = options.forceSnapshot
138
+ ? null
139
+ : findPreviewRepoRoot(options.cwd || process.cwd(), appPath)
140
+ const cliPackageRoot = findCliPackageRoot()
141
+ const entryPath = join(tempDir, 'entry.tsx')
142
+ const cssPath = join(tempDir, 'preview.css')
143
+ const htmlPath = join(tempDir, 'index.html')
144
+ const outDir = join(tempDir, 'dist')
145
+
146
+ writeFileSync(cssPath, await buildPreviewCss(repoRoot, appPath), 'utf8')
147
+ writeFileSync(entryPath, buildEntrySource(appPath, options.sampleRow), 'utf8')
148
+ writeFileSync(htmlPath, buildPreviewHtml(), 'utf8')
149
+
150
+ let server: { port: number; close: () => Promise<void> } | null = null
151
+ try {
152
+ const esbuild = await import('esbuild').catch(() => null)
153
+ if (!esbuild) {
154
+ throw new Error(
155
+ 'Local preview requires esbuild. Install project dependencies with `bun install` or run from an Orizu checkout.'
156
+ )
157
+ }
158
+
159
+ await esbuild.build({
160
+ entryPoints: [entryPath],
161
+ bundle: true,
162
+ outdir: outDir,
163
+ format: 'esm',
164
+ platform: 'browser',
165
+ target: 'es2020',
166
+ jsx: 'automatic',
167
+ jsxImportSource: 'react',
168
+ loader: {
169
+ '.css': 'css',
170
+ '.tsx': 'tsx',
171
+ '.ts': 'ts',
172
+ },
173
+ absWorkingDir: repoRoot || cliPackageRoot || process.cwd(),
174
+ nodePaths: [
175
+ ...(repoRoot ? [join(repoRoot, 'node_modules')] : []),
176
+ ...(cliPackageRoot ? findNodeModuleDirs(cliPackageRoot) : []),
177
+ ],
178
+ alias: repoRoot ? { '@': repoRoot } : undefined,
179
+ plugins: repoRoot ? undefined : [createPreviewSnapshotPlugin()],
180
+ logLevel: 'silent',
181
+ })
182
+
183
+ let url = pathToFileURL(htmlPath).href
184
+ try {
185
+ server = await serveDirectory(tempDir)
186
+ url = `http://127.0.0.1:${server.port}/index.html`
187
+ } catch {
188
+ server = null
189
+ }
190
+ const playwright = options.playwright || await loadPlaywright()
191
+ const chromiumExecutablePath = 'chromiumExecutablePath' in options
192
+ ? options.chromiumExecutablePath
193
+ : resolveChromiumExecutablePath()
194
+ const browser = await playwright.chromium.launch({
195
+ headless: !options.headed,
196
+ ...(chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : {}),
197
+ })
198
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
199
+
200
+ try {
201
+ await page.goto(url, { waitUntil: 'networkidle' })
202
+ await page.waitForSelector('[data-orizu-preview-ready="true"]', { timeout: 15_000 })
203
+ if (options.screenshotPath) {
204
+ await page.screenshot({ path: options.screenshotPath, fullPage: true })
205
+ }
206
+ if (options.keepOpen) {
207
+ await waitForPreviewClose(page, browser)
208
+ }
209
+ } finally {
210
+ if (!options.keepOpen) {
211
+ await browser.close()
212
+ }
213
+ }
214
+
215
+ return {
216
+ url,
217
+ screenshotPath: options.screenshotPath,
218
+ warnings: issues.filter(issue => issue.level === 'warn').map(issue => issue.message),
219
+ }
220
+ } finally {
221
+ await server?.close()
222
+ rmSync(tempDir, { recursive: true, force: true })
223
+ }
224
+ }
225
+
226
+ function waitForPreviewClose(page: PreviewPage, browser: PreviewBrowser): Promise<void> {
227
+ return new Promise(resolveClose => {
228
+ const cleanups: Array<() => void> = []
229
+ let settled = false
230
+ const done = () => {
231
+ if (settled) return
232
+ settled = true
233
+ for (const cleanup of cleanups) cleanup()
234
+ resolveClose()
235
+ }
236
+
237
+ const listen = (target: PreviewPage | PreviewBrowser, event: string) => {
238
+ if (typeof target.once === 'function') {
239
+ target.once(event, done)
240
+ return true
241
+ }
242
+ if (typeof target.on === 'function') {
243
+ target.on(event, done)
244
+ if (typeof target.off === 'function') {
245
+ cleanups.push(() => target.off?.(event, done))
246
+ }
247
+ return true
248
+ }
249
+ return false
250
+ }
251
+
252
+ const listening = [
253
+ listen(page, 'close'),
254
+ listen(browser, 'disconnected'),
255
+ ].some(Boolean)
256
+
257
+ if (!listening) {
258
+ page.waitForTimeout(24 * 60 * 60 * 1000).then(done, done)
259
+ }
260
+ })
261
+ }
262
+
263
+ function validateImports(source: string, issues: Issue[]) {
264
+ const importRegex = /import\s+(?:(\w+)(?:\s*,\s*)?)?(?:\{([^}]+)\})?\s+from\s+['"`]([^'"`]+)['"`]/g
265
+ for (const match of source.matchAll(importRegex)) {
266
+ const defaultImport = match[1]
267
+ const namedImports = match[2]
268
+ ? match[2].split(',').map(name => name.trim().replace(/\s+as\s+\w+/, '')).filter(Boolean)
269
+ : []
270
+ const importPath = match[3]
271
+ if (importPath === 'react' || importPath === 'react-dom' || importPath.startsWith('react/')) {
272
+ continue
273
+ }
274
+ const allowed = PREVIEW_ALLOWED_IMPORTS[importPath]
275
+ if (!allowed) {
276
+ issues.push({ level: 'error', message: `Import not available in Orizu apps: ${importPath}` })
277
+ continue
278
+ }
279
+ if (defaultImport && !allowed.includes('default') && !allowed.includes(defaultImport)) {
280
+ issues.push({
281
+ level: 'error',
282
+ message: `Default import '${defaultImport}' is not available from '${importPath}'.`,
283
+ })
284
+ }
285
+ for (const name of namedImports) {
286
+ if (!allowed.includes(name)) {
287
+ issues.push({
288
+ level: 'error',
289
+ message: `Named import '${name}' is not available from '${importPath}'.`,
290
+ })
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ function validateDefaultExport(source: string, componentName: string | undefined, issues: Issue[]) {
297
+ const resolved = resolveDefaultExport(source)
298
+ if (!resolved) {
299
+ issues.push({
300
+ level: 'error',
301
+ message: 'App must default export a named React component.',
302
+ })
303
+ return
304
+ }
305
+ if (componentName && resolved.name !== componentName) {
306
+ issues.push({
307
+ level: 'error',
308
+ message: `Component name mismatch: expected '${componentName}', found '${resolved.name}'.`,
309
+ })
310
+ }
311
+ if (resolved.params === null) {
312
+ return
313
+ }
314
+ const params = resolved.params.trim()
315
+ if (!params.startsWith('{') || !params.includes('inputData') || !params.includes('onComplete')) {
316
+ issues.push({
317
+ level: 'error',
318
+ message: "Default component must destructure contract props 'inputData' and 'onComplete'.",
319
+ })
320
+ }
321
+ if (/\bdata\b|\bonSubmit\b/.test(params)) {
322
+ issues.push({
323
+ level: 'error',
324
+ message: "Use Orizu contract props instead of deprecated root props 'data' or 'onSubmit'.",
325
+ })
326
+ }
327
+ }
328
+
329
+ function resolveDefaultExport(source: string): { name: string; params: string | null } | null {
330
+ const directFunction = source.match(
331
+ /export\s+default\s+function\s+([A-Za-z_$][\w$]*)\s*\(([\s\S]*?)\)/
332
+ )
333
+ if (directFunction?.[1]) {
334
+ return { name: directFunction[1], params: directFunction[2] ?? '' }
335
+ }
336
+
337
+ const directClass = source.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)/)
338
+ if (directClass?.[1]) {
339
+ return { name: directClass[1], params: null }
340
+ }
341
+
342
+ const exportExpression = source.match(/export\s+default\s+([^\n;]+)/)
343
+ const identifier = exportExpression?.[1]
344
+ ? unwrapDefaultExportExpression(exportExpression[1])
345
+ : null
346
+ if (!identifier) {
347
+ return null
348
+ }
349
+
350
+ const escapedIdentifier = escapeRegExp(identifier)
351
+ const functionDeclaration = source.match(
352
+ new RegExp(`function\\s+${escapedIdentifier}(?![A-Za-z0-9_$])\\s*\\(([\\s\\S]*?)\\)`)
353
+ )
354
+ if (functionDeclaration) {
355
+ return { name: identifier, params: functionDeclaration[1] ?? '' }
356
+ }
357
+
358
+ const arrowDeclaration = source.match(
359
+ new RegExp(`(?:const|let|var)\\s+${escapedIdentifier}(?![A-Za-z0-9_$])\\s*=\\s*(?:\\(([^)]*)\\)|([^=()\\n]+))\\s*(?::\\s*[^=\\n]+)?\\s*=>`)
360
+ )
361
+ if (arrowDeclaration) {
362
+ return { name: identifier, params: arrowDeclaration[1] ?? arrowDeclaration[2] ?? '' }
363
+ }
364
+
365
+ const classDeclaration = source.match(
366
+ new RegExp(`class\\s+${escapedIdentifier}(?![A-Za-z0-9_$])`)
367
+ )
368
+ if (classDeclaration) {
369
+ return { name: identifier, params: null }
370
+ }
371
+
372
+ return null
373
+ }
374
+
375
+ function unwrapDefaultExportExpression(expression: string): string | null {
376
+ const trimmed = expression
377
+ .replace(/\/\*[\s\S]*?\*\/\s*$/, '')
378
+ .replace(/\/\/.*$/, '')
379
+ .trim()
380
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(trimmed)) {
381
+ return trimmed
382
+ }
383
+
384
+ const wrapperMatch = trimmed.match(
385
+ /^(?:[A-Za-z_$][A-Za-z0-9_$]*\.)?[A-Za-z_$][A-Za-z0-9_$]*\(\s*(.+)\s*\)$/
386
+ )
387
+ if (!wrapperMatch?.[1]) {
388
+ return null
389
+ }
390
+
391
+ return unwrapDefaultExportExpression(wrapperMatch[1])
392
+ }
393
+
394
+ function escapeRegExp(value: string): string {
395
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
396
+ }
397
+
398
+ function validateSchema(schema: unknown, label: string, issues: Issue[], path = label) {
399
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
400
+ issues.push({ level: 'error', message: `${path} must be a JSON object.` })
401
+ return
402
+ }
403
+ const obj = schema as Record<string, unknown>
404
+ for (const key of Object.keys(obj)) {
405
+ if (!SUPPORTED_SCHEMA_KEYS.has(key)) {
406
+ issues.push({ level: 'warn', message: `${path}.${key} is outside the supported schema subset and is ignored.` })
407
+ }
408
+ }
409
+ if ('properties' in obj && (!obj.properties || typeof obj.properties !== 'object' || Array.isArray(obj.properties))) {
410
+ issues.push({ level: 'error', message: `${path}.properties must be an object.` })
411
+ }
412
+ if ('required' in obj && !Array.isArray(obj.required)) {
413
+ issues.push({ level: 'error', message: `${path}.required must be an array.` })
414
+ }
415
+ if (obj.properties && typeof obj.properties === 'object' && !Array.isArray(obj.properties)) {
416
+ for (const [name, child] of Object.entries(obj.properties)) {
417
+ validateSchema(child, label, issues, `${path}.properties.${name}`)
418
+ }
419
+ }
420
+ if (obj.items) {
421
+ validateSchema(obj.items, label, issues, `${path}.items`)
422
+ }
423
+ }
424
+
425
+ function validatePayload(schema: unknown, payload: unknown, path: string, issues: Issue[]) {
426
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return
427
+ const obj = schema as Record<string, unknown>
428
+ if (Array.isArray(obj.enum) && !obj.enum.includes(payload)) {
429
+ issues.push({ level: 'error', message: `${path} must be one of: ${obj.enum.join(', ')}` })
430
+ return
431
+ }
432
+ if (obj.type === 'object') {
433
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
434
+ issues.push({ level: 'error', message: `${path} must be an object.` })
435
+ return
436
+ }
437
+ const record = payload as Record<string, unknown>
438
+ if (Array.isArray(obj.required)) {
439
+ for (const key of obj.required) {
440
+ if (typeof key === 'string' && !(key in record)) {
441
+ issues.push({ level: 'error', message: `${path}.${key} is required.` })
442
+ }
443
+ }
444
+ }
445
+ if (obj.properties && typeof obj.properties === 'object' && !Array.isArray(obj.properties)) {
446
+ for (const [key, child] of Object.entries(obj.properties)) {
447
+ if (key in record) validatePayload(child, record[key], `${path}.${key}`, issues)
448
+ }
449
+ }
450
+ }
451
+ if (obj.type === 'array') {
452
+ if (!Array.isArray(payload)) {
453
+ issues.push({ level: 'error', message: `${path} must be an array.` })
454
+ return
455
+ }
456
+ if (obj.items) {
457
+ payload.forEach((item, index) => validatePayload(obj.items, item, `${path}[${index}]`, issues))
458
+ }
459
+ }
460
+ if (obj.type === 'string' && typeof payload !== 'string') {
461
+ issues.push({ level: 'error', message: `${path} must be a string.` })
462
+ }
463
+ if (obj.type === 'number' && typeof payload !== 'number') {
464
+ issues.push({ level: 'error', message: `${path} must be a number.` })
465
+ }
466
+ if (obj.type === 'integer' && (!Number.isInteger(payload))) {
467
+ issues.push({ level: 'error', message: `${path} must be an integer.` })
468
+ }
469
+ if (obj.type === 'boolean' && typeof payload !== 'boolean') {
470
+ issues.push({ level: 'error', message: `${path} must be a boolean.` })
471
+ }
472
+ }
473
+
474
+ function findPreviewRepoRoot(cwd: string, appPath: string): string | null {
475
+ for (const start of [cwd, dirname(appPath), dirname(fileURLPath())]) {
476
+ let current = resolve(start)
477
+ while (true) {
478
+ if (
479
+ existsSync(join(current, 'components')) &&
480
+ existsSync(join(current, 'app', 'globals.css')) &&
481
+ existsSync(join(current, 'lib', 'available-components.ts'))
482
+ ) {
483
+ return current
484
+ }
485
+ const parent = dirname(current)
486
+ if (parent === current) break
487
+ current = parent
488
+ }
489
+ }
490
+ return null
491
+ }
492
+
493
+ function fileURLPath(): string {
494
+ return fileURLToPath(new URL('.', import.meta.url))
495
+ }
496
+
497
+ function findCliPackageRoot(): string | null {
498
+ let current = resolve(dirname(fileURLPath()))
499
+ while (true) {
500
+ if (
501
+ existsSync(join(current, 'package.json')) &&
502
+ (existsSync(join(current, 'dist', 'index.js')) || existsSync(join(current, 'src', 'index.ts')))
503
+ ) {
504
+ return current
505
+ }
506
+ const parent = dirname(current)
507
+ if (parent === current) break
508
+ current = parent
509
+ }
510
+ return null
511
+ }
512
+
513
+ function findNodeModuleDirs(start: string): string[] {
514
+ const dirs: string[] = []
515
+ let current = resolve(start)
516
+ while (true) {
517
+ const candidate = join(current, 'node_modules')
518
+ if (existsSync(candidate)) {
519
+ dirs.push(candidate)
520
+ }
521
+ const parent = dirname(current)
522
+ if (parent === current) break
523
+ current = parent
524
+ }
525
+ return dirs
526
+ }
527
+
528
+ function buildEntrySource(appPath: string, sampleRow: Record<string, unknown>) {
529
+ return `
530
+ import React from 'react'
531
+ import { createRoot } from 'react-dom/client'
532
+ import App from ${JSON.stringify(appPath)}
533
+ import ${JSON.stringify('./preview.css')}
534
+
535
+ const inputData = ${JSON.stringify(sampleRow)}
536
+ const root = createRoot(document.getElementById('root'))
537
+
538
+ function PreviewShell() {
539
+ const [submissions, setSubmissions] = React.useState([])
540
+ const onComplete = React.useCallback((payload) => {
541
+ window.__orizuSubmissions = [...(window.__orizuSubmissions || []), payload]
542
+ setSubmissions(window.__orizuSubmissions)
543
+ }, [])
544
+ return React.createElement(
545
+ 'main',
546
+ { className: 'orizu-preview-shell', 'data-orizu-preview-ready': 'true' },
547
+ React.createElement(
548
+ 'div',
549
+ { className: 'orizu-preview-app' },
550
+ React.createElement(App, { inputData, initialValues: {}, onComplete })
551
+ ),
552
+ React.createElement(
553
+ 'aside',
554
+ { className: 'orizu-preview-submissions', 'aria-label': 'Preview submissions' },
555
+ React.createElement('h2', null, 'Submissions'),
556
+ React.createElement('pre', null, JSON.stringify(submissions, null, 2))
557
+ )
558
+ )
559
+ }
560
+
561
+ root.render(React.createElement(PreviewShell))
562
+ `
563
+ }
564
+
565
+ function buildPreviewHtml() {
566
+ return `<!doctype html>
567
+ <html lang="en">
568
+ <head>
569
+ <meta charset="utf-8" />
570
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
571
+ <title>Orizu app preview</title>
572
+ <link rel="stylesheet" href="./dist/entry.css" />
573
+ </head>
574
+ <body>
575
+ <div id="root"></div>
576
+ <script type="module" src="./dist/entry.js"></script>
577
+ </body>
578
+ </html>
579
+ `
580
+ }
581
+
582
+ export async function buildPreviewCss(repoRoot: string | null, appPath: string) {
583
+ const fallbackCss = buildPreviewChromeCss()
584
+
585
+ try {
586
+ const postcss = (await import('postcss')).default
587
+ const tailwind = (await import('@tailwindcss/postcss')).default
588
+ const cliPackageRoot = findCliPackageRoot()
589
+ const globalsPath = repoRoot
590
+ ? join(repoRoot, 'app', 'globals.css')
591
+ : join(cliPackageRoot || dirname(appPath), 'preview-runtime.css')
592
+ const globalsCss = repoRoot
593
+ ? readFileSync(globalsPath, 'utf8')
594
+ : buildSnapshotTailwindCss()
595
+ const sourceCss = [
596
+ `@source ${JSON.stringify(appPath)};`,
597
+ ...(repoRoot
598
+ ? [
599
+ `@source ${JSON.stringify(join(repoRoot, 'components/**/*.{ts,tsx}'))};`,
600
+ `@source ${JSON.stringify(join(repoRoot, 'app/**/*.{ts,tsx}'))};`,
601
+ ]
602
+ : []),
603
+ globalsCss,
604
+ fallbackCss,
605
+ ].join('\n')
606
+ const result = await postcss([tailwind()]).process(sourceCss, { from: globalsPath })
607
+ return result.css
608
+ } catch {
609
+ return fallbackCss
610
+ }
611
+ }
612
+
613
+ function buildPreviewChromeCss() {
614
+ return `
615
+ :root {
616
+ color-scheme: light;
617
+ --background: #fbfaf8;
618
+ --foreground: #1f1a17;
619
+ --card: #ffffff;
620
+ --border: #e7ded6;
621
+ --muted: #f2eeea;
622
+ --muted-foreground: #756b62;
623
+ --primary: #d95f2b;
624
+ --primary-foreground: #fffaf7;
625
+ --radius: 10px;
626
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
627
+ }
628
+ * { box-sizing: border-box; }
629
+ body { margin: 0; background: var(--background); color: var(--foreground); }
630
+ button, input, textarea, select { font: inherit; }
631
+ .orizu-preview-shell { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; min-height: 100vh; padding: 24px; }
632
+ .orizu-preview-app { min-width: 0; padding: 24px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
633
+ .orizu-preview-submissions { border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); padding: 16px; }
634
+ .orizu-preview-submissions h2 { margin: 0 0 12px; font-size: 14px; }
635
+ .orizu-preview-submissions pre { overflow: auto; margin: 0; color: var(--muted-foreground); font-size: 12px; white-space: pre-wrap; }
636
+ @media (max-width: 900px) { .orizu-preview-shell { grid-template-columns: 1fr; padding: 12px; } }
637
+ `
638
+ }
639
+
640
+ function buildSnapshotTailwindCss() {
641
+ return `
642
+ @import 'tailwindcss';
643
+
644
+ @theme {
645
+ --color-background: #fbfaf8;
646
+ --color-foreground: #1f1a17;
647
+ --color-card: #ffffff;
648
+ --color-card-foreground: #1f1a17;
649
+ --color-popover: #ffffff;
650
+ --color-popover-foreground: #1f1a17;
651
+ --color-primary: #d95f2b;
652
+ --color-primary-foreground: #fffaf7;
653
+ --color-secondary: #f2eeea;
654
+ --color-secondary-foreground: #1f1a17;
655
+ --color-muted: #f2eeea;
656
+ --color-muted-foreground: #756b62;
657
+ --color-accent: #f5c15b;
658
+ --color-accent-foreground: #1f1a17;
659
+ --color-destructive: #c8442a;
660
+ --color-destructive-foreground: #ffffff;
661
+ --color-border: #e7ded6;
662
+ --color-input: #f2eeea;
663
+ --color-ring: #d95f2b;
664
+ --font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
665
+ --font-mono: "Geist Mono", Menlo, Monaco, Consolas, monospace;
666
+ --font-serif: Lora, Georgia, serif;
667
+ --radius-sm: 6px;
668
+ --radius-md: 8px;
669
+ --radius-lg: 10px;
670
+ --radius-xl: 14px;
671
+ }
672
+ `
673
+ }
674
+
675
+ function createPreviewSnapshotPlugin(): any {
676
+ const modules = buildPreviewSnapshotModules()
677
+ return {
678
+ name: 'orizu-preview-runtime-snapshot',
679
+ setup(build: any) {
680
+ build.onResolve({ filter: /^@\/components\// }, (args: any) => {
681
+ if (modules[args.path]) {
682
+ return { path: args.path, namespace: 'orizu-preview-snapshot' }
683
+ }
684
+ return null
685
+ })
686
+ build.onLoad({ filter: /.*/, namespace: 'orizu-preview-snapshot' }, (args: any) => {
687
+ const contents = modules[args.path]
688
+ if (!contents) {
689
+ return null
690
+ }
691
+ return {
692
+ contents,
693
+ loader: 'tsx',
694
+ resolveDir: findCliPackageRoot() || process.cwd(),
695
+ }
696
+ })
697
+ },
698
+ }
699
+ }
700
+
701
+ function buildPreviewSnapshotModules(): Record<string, string> {
702
+ const primitiveSource = `
703
+ import React from 'react'
704
+
705
+ function cn(...values) {
706
+ return values.filter(Boolean).join(' ')
707
+ }
708
+
709
+ function primitive(tag, baseClass = '') {
710
+ return React.forwardRef(function SnapshotPrimitive({ children, className, asChild, ...props }, ref) {
711
+ const element = asChild && React.isValidElement(children)
712
+ ? React.cloneElement(children, {
713
+ ...props,
714
+ ref,
715
+ className: cn(baseClass, children.props?.className, className),
716
+ })
717
+ : React.createElement(tag, { ...props, ref, className: cn(baseClass, className) }, children)
718
+ return element
719
+ })
720
+ }
721
+
722
+ function field(tag, baseClass = '') {
723
+ return React.forwardRef(function SnapshotField({ className, ...props }, ref) {
724
+ return React.createElement(tag, { ...props, ref, className: cn(baseClass, className) })
725
+ })
726
+ }
727
+ `
728
+
729
+ const contentSource = `
730
+ import React from 'react'
731
+ function cn(...values) { return values.filter(Boolean).join(' ') }
732
+ export function TextContent({ content, children, className, ...props }) {
733
+ return <div {...props} className={cn('whitespace-pre-wrap text-sm leading-relaxed', className)}>{children ?? content}</div>
734
+ }
735
+ export default TextContent
736
+ `
737
+
738
+ const codeBlockSource = `
739
+ import React from 'react'
740
+ function cn(...values) { return values.filter(Boolean).join(' ') }
741
+ export function CodeBlock({ code, children, className, ...props }) {
742
+ return <pre {...props} className={cn('overflow-auto rounded-md border bg-muted p-3 font-mono text-xs', className)}><code>{children ?? code}</code></pre>
743
+ }
744
+ export default CodeBlock
745
+ `
746
+
747
+ const proseSource = `
748
+ import React from 'react'
749
+ function cn(...values) { return values.filter(Boolean).join(' ') }
750
+ export function Prose({ children, className, ...props }) {
751
+ return <div {...props} className={cn('prose prose-sm max-w-none text-foreground', className)}>{children}</div>
752
+ }
753
+ export default Prose
754
+ `
755
+
756
+ const contentRendererSource = `
757
+ import React from 'react'
758
+ function cn(...values) { return values.filter(Boolean).join(' ') }
759
+ export function ContentRenderer({ content, children, className, ...props }) {
760
+ return <div {...props} className={cn('whitespace-pre-wrap text-sm leading-relaxed', className)}>{children ?? content}</div>
761
+ }
762
+ export default ContentRenderer
763
+ `
764
+
765
+ const conversationSource = `
766
+ import React from 'react'
767
+ function cn(...values) { return values.filter(Boolean).join(' ') }
768
+ function MessageBlock({ children, className, role, message, ...props }) {
769
+ return <div {...props} className={cn('rounded-md border bg-card p-3 text-sm', className)}><div className="mb-1 text-xs font-semibold uppercase text-muted-foreground">{role}</div>{children ?? message?.content ?? message}</div>
770
+ }
771
+ export function ConversationView({ messages = [], children, className, ...props }) {
772
+ return <div {...props} className={cn('space-y-3', className)}>{children ?? messages.map((message, index) => <MessageBlock key={index} message={message} role={message?.role ?? 'message'} />)}</div>
773
+ }
774
+ export const ConversationMessageBlock = MessageBlock
775
+ export const AssistantMessageBlock = (props) => <MessageBlock {...props} role="assistant" />
776
+ export const UserMessageBlock = (props) => <MessageBlock {...props} role="user" />
777
+ export const SystemMessageBlock = (props) => <MessageBlock {...props} role="system" />
778
+ export const ContextMessageBlock = (props) => <MessageBlock {...props} role="context" />
779
+ export const ReasoningMessageBlock = (props) => <MessageBlock {...props} role="reasoning" />
780
+ export const ToolCallBlock = (props) => <MessageBlock {...props} role="tool call" />
781
+ export const ToolResultBlock = (props) => <MessageBlock {...props} role="tool result" />
782
+ export default ConversationView
783
+ `
784
+
785
+ const behaviorSource = `
786
+ import React from 'react'
787
+ function cn(...values) { return values.filter(Boolean).join(' ') }
788
+ export function Annotatable({ children, className, ...props }) {
789
+ return <span {...props} className={cn('rounded-sm bg-yellow-50 ring-1 ring-yellow-200', className)}>{children}</span>
790
+ }
791
+ export function Reactable({ children, className, reactions, ...props }) {
792
+ return <span {...props} className={cn('inline-flex items-center gap-1', className)}>{children}{Array.isArray(reactions) ? reactions.map((reaction, index) => <span key={index} className="rounded-full border px-1 text-xs">{reaction?.emoji ?? reaction}</span>) : null}</span>
793
+ }
794
+ `
795
+
796
+ const inputSource = `
797
+ import React from 'react'
798
+ function cn(...values) { return values.filter(Boolean).join(' ') }
799
+ export function CommentBox({ value, onChange, placeholder, className, ...props }) {
800
+ return <textarea {...props} value={value} placeholder={placeholder} onChange={(event) => onChange?.(event.target.value)} className={cn('min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm', className)} />
801
+ }
802
+ export function NumericRating({ value, onChange, max = 5, className, ...props }) {
803
+ return <div {...props} className={cn('inline-flex gap-1', className)}>{Array.from({ length: max }, (_, i) => i + 1).map(n => <button key={n} type="button" onClick={() => onChange?.(n)} className={cn('rounded-md border px-3 py-1 text-sm', value === n && 'bg-primary text-primary-foreground')}>{n}</button>)}</div>
804
+ }
805
+ export const StarRating = NumericRating
806
+ export const LikertScale = NumericRating
807
+ export const RatingSelector = NumericRating
808
+ export function ThumbsRating({ value, onChange, className, ...props }) {
809
+ return <div {...props} className={cn('inline-flex gap-2', className)}><button type="button" onClick={() => onChange?.('up')} className={cn('rounded-md border px-3 py-1', value === 'up' && 'bg-primary text-primary-foreground')}>Up</button><button type="button" onClick={() => onChange?.('down')} className={cn('rounded-md border px-3 py-1', value === 'down' && 'bg-primary text-primary-foreground')}>Down</button></div>
810
+ }
811
+ export function CriterionRating({ criteria = [], value = {}, onChange, className, ...props }) {
812
+ return <div {...props} className={cn('space-y-2', className)}>{criteria.map((criterion) => <label key={criterion} className="flex items-center justify-between gap-3 text-sm"><span>{criterion}</span><NumericRating value={value[criterion]} onChange={(rating) => onChange?.({ ...value, [criterion]: rating })} /></label>)}</div>
813
+ }
814
+ export function TagPicker({ options = [], value = [], onChange, className, ...props }) {
815
+ return <div {...props} className={cn('flex flex-wrap gap-2', className)}>{options.map(option => { const selected = value.includes(option); return <button key={option} type="button" onClick={() => onChange?.(selected ? value.filter(v => v !== option) : [...value, option])} className={cn('rounded-full border px-3 py-1 text-sm', selected && 'bg-primary text-primary-foreground')}>{option}</button> })}</div>
816
+ }
817
+ `
818
+
819
+ const templateSource = `
820
+ import React from 'react'
821
+ function cn(...values) { return values.filter(Boolean).join(' ') }
822
+ function Template({ title, children, className, inputData, onComplete, ...props }) {
823
+ return <section {...props} className={cn('rounded-lg border bg-card p-4 text-card-foreground', className)}><h2 className="mb-3 text-lg font-semibold">{title ?? inputData?.title ?? 'Preview template'}</h2>{children}<button type="button" onClick={() => onComplete?.({ reviewed: true })} className="mt-4 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">Submit</button></section>
824
+ }
825
+ export const TagSelector = Template
826
+ export const SideBySideComparison = Template
827
+ export const CorrectionTask = Template
828
+ export const CodeComparison = Template
829
+ export const ContextualQA = Template
830
+ export const RankingList = Template
831
+ export const SingleItemRater = Template
832
+ `
833
+
834
+ return {
835
+ '@/components/ui/button': `${primitiveSource}\nexport const Button = primitive('button', 'inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:opacity-90 disabled:opacity-50')`,
836
+ '@/components/ui/card': `${primitiveSource}\nexport const Card = primitive('div', 'rounded-lg border bg-card text-card-foreground shadow-sm')`,
837
+ '@/components/ui/carousel': `${primitiveSource}\nexport const Carousel = primitive('div', 'relative overflow-hidden')`,
838
+ '@/components/ui/checkbox': `${primitiveSource}\nexport const Checkbox = field('input', 'h-4 w-4 rounded border border-input')`,
839
+ '@/components/ui/form': `${primitiveSource}\nexport const Form = primitive('form', 'space-y-4')`,
840
+ '@/components/ui/input': `${primitiveSource}\nexport const Input = field('input', 'h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm')`,
841
+ '@/components/ui/label': `${primitiveSource}\nexport const Label = primitive('label', 'text-sm font-medium leading-none')`,
842
+ '@/components/ui/progress': `${primitiveSource}\nexport const Progress = field('progress', 'h-2 w-full overflow-hidden rounded-full')`,
843
+ '@/components/ui/radio-group': `${primitiveSource}\nexport const RadioGroup = primitive('div', 'grid gap-2')\nexport const RadioGroupItem = field('input', 'h-4 w-4')`,
844
+ '@/components/ui/scroll-area': `${primitiveSource}\nexport const ScrollArea = primitive('div', 'overflow-auto')`,
845
+ '@/components/ui/select': `${primitiveSource}\nexport const Select = primitive('select', 'h-10 rounded-md border border-input bg-background px-3 py-2 text-sm')\nexport const SelectTrigger = primitive('button', 'inline-flex h-10 items-center justify-between rounded-md border px-3 py-2 text-sm')\nexport const SelectValue = primitive('span')\nexport const SelectContent = primitive('div', 'rounded-md border bg-popover p-1')\nexport const SelectItem = primitive('option')`,
846
+ '@/components/ui/separator': `${primitiveSource}\nexport const Separator = primitive('hr', 'border-border')`,
847
+ '@/components/ui/switch': `${primitiveSource}\nexport const Switch = field('input', 'h-5 w-9 rounded-full')`,
848
+ '@/components/ui/table': `${primitiveSource}\nexport const Table = primitive('table', 'w-full caption-bottom text-sm')\nexport const TableHeader = primitive('thead')\nexport const TableBody = primitive('tbody')\nexport const TableFooter = primitive('tfoot')\nexport const TableRow = primitive('tr', 'border-b')\nexport const TableHead = primitive('th', 'h-10 px-2 text-left font-medium')\nexport const TableCell = primitive('td', 'p-2 align-middle')\nexport const TableCaption = primitive('caption', 'mt-4 text-sm text-muted-foreground')`,
849
+ '@/components/ui/tabs': `${primitiveSource}\nexport const Tabs = primitive('div')\nexport const TabsList = primitive('div', 'inline-flex rounded-md bg-muted p-1')\nexport const TabsTrigger = primitive('button', 'rounded-sm px-3 py-1 text-sm')\nexport const TabsContent = primitive('div', 'mt-2')`,
850
+ '@/components/ui/textarea': `${primitiveSource}\nexport const Textarea = field('textarea', 'min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm')`,
851
+ '@/components/ui/toggle': `${primitiveSource}\nexport const Toggle = primitive('button', 'inline-flex items-center rounded-md border px-3 py-2 text-sm')`,
852
+ '@/components/ui/toggle-group': `${primitiveSource}\nexport const ToggleGroup = primitive('div', 'inline-flex rounded-md border')\nexport const ToggleGroupItem = primitive('button', 'px-3 py-2 text-sm')`,
853
+ '@/components/ui/tooltip': `${primitiveSource}\nexport const TooltipProvider = primitive('div')\nexport const Tooltip = primitive('span')\nexport const TooltipTrigger = primitive('span')\nexport const TooltipContent = primitive('span', 'rounded-md border bg-popover px-2 py-1 text-xs')`,
854
+ '@/components/base/content/TextContent': contentSource,
855
+ '@/components/base/content/CodeBlock': codeBlockSource,
856
+ '@/components/base/content/ConversationView': conversationSource,
857
+ '@/components/base/content/ContentRenderer': contentRendererSource,
858
+ '@/components/base/content/Prose': proseSource,
859
+ '@/components/base/behaviors/Annotatable': `${behaviorSource}\nexport default Annotatable`,
860
+ '@/components/base/behaviors/Reactable': `${behaviorSource}\nexport default Reactable`,
861
+ '@/components/base/input/CommentBox': inputSource,
862
+ '@/components/base/input/CriterionRating': inputSource,
863
+ '@/components/base/input/LikertScale': inputSource,
864
+ '@/components/base/input/NumericRating': inputSource,
865
+ '@/components/base/input/RatingSelector': inputSource,
866
+ '@/components/base/input/StarRating': inputSource,
867
+ '@/components/base/input/TagPicker': inputSource,
868
+ '@/components/base/input/ThumbsRating': inputSource,
869
+ '@/components/base/ui/ComparisonPanel': `${primitiveSource}\nexport const ComparisonPanel = primitive('div', 'grid gap-4 rounded-lg border bg-card p-4 md:grid-cols-2')`,
870
+ '@/components/base/ui/DraggableItem': `${primitiveSource}\nexport const DraggableItem = primitive('div', 'cursor-grab rounded-md border bg-card p-3')`,
871
+ '@/components/templates/classification/TagSelector': templateSource,
872
+ '@/components/templates/comparison/SideBySideComparison': templateSource,
873
+ '@/components/templates/correction/CorrectionTask': templateSource,
874
+ '@/components/templates/code/CodeComparison': templateSource,
875
+ '@/components/templates/qa/ContextualQA': templateSource,
876
+ '@/components/templates/ranking/RankingList': templateSource,
877
+ '@/components/templates/rating/SingleItemRater': templateSource,
878
+ }
879
+ }
880
+
881
+ export function getPreviewSnapshotModulePaths(): string[] {
882
+ return Object.keys(buildPreviewSnapshotModules()).sort()
883
+ }
884
+
885
+ async function serveDirectory(root: string): Promise<{ port: number; close: () => Promise<void> }> {
886
+ const server = createServer((request, response) => {
887
+ const url = new URL(request.url || '/', 'http://127.0.0.1')
888
+ const requestedPath = url.pathname === '/' ? '/index.html' : url.pathname
889
+ const filePath = resolve(root, `.${requestedPath}`)
890
+ const relativePath = relative(root, filePath)
891
+ if (relativePath.startsWith('..') || isAbsolute(relativePath) || !existsSync(filePath)) {
892
+ response.statusCode = 404
893
+ response.end('Not found')
894
+ return
895
+ }
896
+ try {
897
+ if (!statSync(filePath).isFile()) {
898
+ response.statusCode = 404
899
+ response.end('Not found')
900
+ return
901
+ }
902
+ } catch {
903
+ response.statusCode = 404
904
+ response.end('Not found')
905
+ return
906
+ }
907
+ const ext = basename(filePath).split('.').pop()
908
+ const contentType =
909
+ ext === 'html' ? 'text/html; charset=utf-8' :
910
+ ext === 'js' ? 'text/javascript; charset=utf-8' :
911
+ ext === 'css' ? 'text/css; charset=utf-8' :
912
+ 'application/octet-stream'
913
+ response.setHeader('content-type', contentType)
914
+ try {
915
+ response.end(readFileSync(filePath))
916
+ } catch {
917
+ response.statusCode = 500
918
+ response.end('Internal server error')
919
+ }
920
+ })
921
+ const port = await listenOnAvailablePort(server)
922
+ const address = server.address()
923
+ if (!address || typeof address === 'string') {
924
+ throw new Error('Unable to start local preview server.')
925
+ }
926
+ return {
927
+ port,
928
+ close: () => new Promise(resolveClose => server.close(() => resolveClose())),
929
+ }
930
+ }
931
+
932
+ async function listenOnAvailablePort(server: ReturnType<typeof createServer>): Promise<number> {
933
+ const firstPort = 39000 + Math.floor(Math.random() * 1000)
934
+ for (let offset = 0; offset < 20; offset += 1) {
935
+ const port = firstPort + offset
936
+ try {
937
+ await new Promise<void>((resolveListen, reject) => {
938
+ server.once('error', reject)
939
+ server.listen(port, '127.0.0.1', () => {
940
+ server.off('error', reject)
941
+ resolveListen()
942
+ })
943
+ })
944
+ return port
945
+ } catch (error: any) {
946
+ server.removeAllListeners('error')
947
+ if (error?.code !== 'EADDRINUSE') throw error
948
+ }
949
+ }
950
+ throw new Error('Unable to find an available localhost port for app preview.')
951
+ }
952
+
953
+ async function loadPlaywright(): Promise<any> {
954
+ const fromTest = await import('@playwright/test').catch(() => null)
955
+ if (fromTest?.chromium) return fromTest
956
+ const fromPlaywright = await import('playwright').catch(() => null)
957
+ if (fromPlaywright?.chromium) return fromPlaywright
958
+ throw new Error(
959
+ 'Local preview requires Playwright. Install project dependencies with `bun install`, then rerun `orizu apps preview`.'
960
+ )
961
+ }
962
+
963
+ function resolveChromiumExecutablePath(): string | null {
964
+ const candidates = [
965
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
966
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
967
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
968
+ '/usr/bin/google-chrome',
969
+ '/usr/bin/chromium',
970
+ '/usr/bin/chromium-browser',
971
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
972
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
973
+ ].filter(Boolean) as string[]
974
+
975
+ return candidates.find(candidate => existsSync(candidate)) || null
976
+ }