Skip to content

Commit a21915b

Browse files
authored
fix(playground): wait for browser error reports (#74)
* fix(playground): wait for browser error reports * test(playground): cover review edge cases
1 parent c2a6042 commit a21915b

3 files changed

Lines changed: 230 additions & 8 deletions

File tree

apps/web/src/lib/server/playground-db.test.node.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'playground-test-'))
88
const {
99
createSession,
1010
getSession,
11+
waitForBrowserSyncResult,
12+
waitForErrorReport,
1113
updateShader,
1214
setScreenshot,
1315
setErrors,
1416
setStructuredErrors,
17+
recordErrorReport,
1518
setUniformValues,
1619
updateMetadata,
1720
} = await import('./playground-db.ts')
@@ -172,6 +175,130 @@ runTest('setStructuredErrors stores structured errors', () => {
172175
assert.deepEqual(session.structuredErrors, errors)
173176
})
174177

178+
runTest('waitForErrorReport resolves when browser posts errors', async () => {
179+
const { id } = createSession()
180+
const wait = waitForErrorReport(id, 1_000)
181+
182+
setTimeout(() => {
183+
recordErrorReport(id, {
184+
errors: ['shader failed'],
185+
structuredErrors: [],
186+
})
187+
}, 20)
188+
189+
const report = await wait
190+
assert.deepEqual(report, {
191+
errors: ['shader failed'],
192+
structuredErrors: [],
193+
})
194+
})
195+
196+
runTest('waitForBrowserSyncResult resolves early on compilation errors', async () => {
197+
const { id } = createSession()
198+
const startedAt = Date.now()
199+
const wait = waitForBrowserSyncResult(id, 1_000)
200+
201+
setTimeout(() => {
202+
recordErrorReport(id, {
203+
errors: ['compile failed'],
204+
structuredErrors: [],
205+
})
206+
}, 20)
207+
208+
const result = await wait
209+
assert.equal(Date.now() - startedAt < 500, true)
210+
assert.equal(result.screenshotBase64, null)
211+
assert.deepEqual(result.errorReport, {
212+
errors: ['compile failed'],
213+
structuredErrors: [],
214+
})
215+
})
216+
217+
runTest('waitForBrowserSyncResult waits for screenshot after a successful empty error report', async () => {
218+
const { id } = createSession()
219+
const startedAt = Date.now()
220+
const wait = waitForBrowserSyncResult(id, 1_000)
221+
222+
setTimeout(() => {
223+
recordErrorReport(id, {
224+
errors: [],
225+
structuredErrors: [],
226+
})
227+
}, 20)
228+
229+
setTimeout(() => {
230+
setScreenshot(id, 'data:image/png;base64,ok')
231+
}, 60)
232+
233+
const result = await wait
234+
const elapsedMs = Date.now() - startedAt
235+
236+
assert.equal(elapsedMs >= 40, true)
237+
assert.equal(elapsedMs < 500, true)
238+
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
239+
assert.deepEqual(result.errorReport, {
240+
errors: [],
241+
structuredErrors: [],
242+
})
243+
})
244+
245+
runTest('waitForBrowserSyncResult waits for the error-clear report after screenshot success', async () => {
246+
const { id } = createSession()
247+
const startedAt = Date.now()
248+
const wait = waitForBrowserSyncResult(id, 1_000)
249+
250+
setTimeout(() => {
251+
setScreenshot(id, 'data:image/png;base64,ok')
252+
}, 20)
253+
254+
setTimeout(() => {
255+
recordErrorReport(id, {
256+
errors: [],
257+
structuredErrors: [],
258+
})
259+
}, 60)
260+
261+
const result = await wait
262+
const elapsedMs = Date.now() - startedAt
263+
264+
assert.equal(elapsedMs >= 40, true)
265+
assert.equal(elapsedMs < 500, true)
266+
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
267+
assert.deepEqual(result.errorReport, {
268+
errors: [],
269+
structuredErrors: [],
270+
})
271+
})
272+
273+
runTest('waitForBrowserSyncResult returns nulls when the browser never responds', async () => {
274+
const { id } = createSession()
275+
const result = await waitForBrowserSyncResult(id, 25)
276+
277+
assert.deepEqual(result, {
278+
screenshotBase64: null,
279+
errorReport: null,
280+
})
281+
})
282+
283+
runTest('waitForBrowserSyncResult treats structured errors as compilation failures', async () => {
284+
const { id } = createSession({ language: 'tsl', tslSource: 'export function createMaterial() {}' })
285+
const wait = waitForBrowserSyncResult(id, 1_000)
286+
287+
setTimeout(() => {
288+
recordErrorReport(id, {
289+
errors: [],
290+
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
291+
})
292+
}, 20)
293+
294+
const result = await wait
295+
assert.equal(result.screenshotBase64, null)
296+
assert.deepEqual(result.errorReport, {
297+
errors: [],
298+
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
299+
})
300+
})
301+
175302
runTest('setUniformValues stores values', () => {
176303
const { id } = createSession()
177304
const values = { uTime: 1.5, uColor: [1, 0, 0] }

apps/web/src/lib/server/playground-db.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ const sseConnections = new Map<string, Set<WritableStreamDefaultWriter<Uint8Arra
135135
// Screenshot wait queue: when an update is posted, the API waits for the
136136
// browser to send a screenshot back. This map stores resolve callbacks.
137137
const screenshotWaiters = new Map<string, Array<(base64: string | null) => void>>()
138+
const errorReportWaiters = new Map<string, Array<(report: ErrorReportPayload | null) => void>>()
139+
140+
export type ErrorReportPayload = {
141+
errors: string[]
142+
structuredErrors: PlaygroundError[]
143+
}
138144

139145
export function addSSEConnection(sessionId: string, writer: WritableStreamDefaultWriter<Uint8Array>) {
140146
let set = sseConnections.get(sessionId)
@@ -203,6 +209,85 @@ export function resolveScreenshotWaiters(sessionId: string, base64: string) {
203209
}
204210
}
205211

212+
export function waitForErrorReport(sessionId: string, timeoutMs: number): Promise<ErrorReportPayload | null> {
213+
return new Promise((resolve) => {
214+
const list = errorReportWaiters.get(sessionId) ?? []
215+
list.push(resolve)
216+
errorReportWaiters.set(sessionId, list)
217+
setTimeout(() => {
218+
const current = errorReportWaiters.get(sessionId)
219+
if (current) {
220+
const idx = current.indexOf(resolve)
221+
if (idx !== -1) {
222+
current.splice(idx, 1)
223+
if (current.length === 0) errorReportWaiters.delete(sessionId)
224+
}
225+
}
226+
resolve(null)
227+
}, timeoutMs)
228+
})
229+
}
230+
231+
export function resolveErrorReportWaiters(sessionId: string, report: ErrorReportPayload) {
232+
const list = errorReportWaiters.get(sessionId)
233+
if (!list || list.length === 0) return
234+
errorReportWaiters.delete(sessionId)
235+
for (const resolve of list) {
236+
resolve(report)
237+
}
238+
}
239+
240+
export async function waitForBrowserSyncResult(sessionId: string, timeoutMs: number): Promise<{
241+
screenshotBase64: string | null
242+
errorReport: ErrorReportPayload | null
243+
}> {
244+
const screenshotPromise = waitForScreenshot(sessionId, timeoutMs).then((base64) => ({
245+
type: 'screenshot' as const,
246+
base64,
247+
}))
248+
const errorPromise = waitForErrorReport(sessionId, timeoutMs).then((report) => ({
249+
type: 'errorReport' as const,
250+
report,
251+
}))
252+
253+
let waitForScreenshotEvent = true
254+
let waitForErrorEvent = true
255+
let screenshotBase64: string | null = null
256+
let errorReport: ErrorReportPayload | null = null
257+
258+
while (waitForScreenshotEvent || waitForErrorEvent) {
259+
const pending: Array<
260+
Promise<
261+
| { type: 'screenshot'; base64: string | null }
262+
| { type: 'errorReport'; report: ErrorReportPayload | null }
263+
>
264+
> = []
265+
266+
if (waitForScreenshotEvent) pending.push(screenshotPromise)
267+
if (waitForErrorEvent) pending.push(errorPromise)
268+
269+
const next = await Promise.race(pending)
270+
271+
if (next.type === 'screenshot') {
272+
waitForScreenshotEvent = false
273+
screenshotBase64 = next.base64
274+
} else {
275+
waitForErrorEvent = false
276+
errorReport = next.report
277+
}
278+
279+
const hasCompilationErrors = !!errorReport
280+
&& (errorReport.errors.length > 0 || errorReport.structuredErrors.length > 0)
281+
const hasSuccessfulSync = screenshotBase64 !== null && errorReport !== null
282+
283+
if (hasCompilationErrors || hasSuccessfulSync) {
284+
break
285+
}
286+
}
287+
288+
return { screenshotBase64, errorReport }
289+
}
290+
206291
// ---------------------------------------------------------------------------
207292
// Row type from SQLite
208293
// ---------------------------------------------------------------------------
@@ -354,6 +439,12 @@ export function setStructuredErrors(id: string, errors: PlaygroundError[]): void
354439
).run(JSON.stringify(errors), id)
355440
}
356441

442+
export function recordErrorReport(id: string, report: ErrorReportPayload): void {
443+
setErrors(id, report.errors)
444+
setStructuredErrors(id, report.structuredErrors)
445+
resolveErrorReportWaiters(id, report)
446+
}
447+
357448
export function setUniformValues(id: string, values: Record<string, unknown>): void {
358449
db.prepare(
359450
`UPDATE playground_sessions SET uniform_values_json = ?, updated_at = datetime('now') WHERE id = ?`,

apps/web/src/routes/api/playground/$.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import {
44
getSession,
55
updateShader,
66
setScreenshot,
7-
setErrors,
8-
setStructuredErrors,
7+
recordErrorReport,
98
hasSSEConnections,
109
addSSEConnection,
1110
removeSSEConnection,
12-
waitForScreenshot,
11+
waitForBrowserSyncResult,
1312
} from '../../../lib/server/playground-db'
1413
import type {
1514
PlaygroundError,
@@ -64,7 +63,7 @@ function jsonResponse(data: unknown, status = 200): Response {
6463
// ---------------------------------------------------------------------------
6564

6665
const WEB_URL = process.env.WEB_URL || 'https://shaderbase.com'
67-
const SCREENSHOT_WAIT_MS = 5000
66+
const BROWSER_SYNC_WAIT_MS = 5000
6867

6968
async function handlePlayground(request: Request): Promise<Response> {
7069
const url = new URL(request.url)
@@ -170,11 +169,14 @@ async function handlePlayground(request: Request): Promise<Response> {
170169

171170
const previewAvailable = true
172171

173-
// Wait for screenshot from the browser when it is connected.
172+
// Wait for browser feedback when it is connected. Successful renders
173+
// should produce both an empty error report and a screenshot. Failed
174+
// renders should produce a non-empty error report.
174175
const browserConnected = hasSSEConnections(sessionId)
175176
let screenshotBase64: string | null = null
176177
if (browserConnected) {
177-
screenshotBase64 = await waitForScreenshot(sessionId, SCREENSHOT_WAIT_MS)
178+
const browserResult = await waitForBrowserSyncResult(sessionId, BROWSER_SYNC_WAIT_MS)
179+
screenshotBase64 = browserResult.screenshotBase64
178180
}
179181

180182
// Re-fetch session to get latest errors
@@ -210,8 +212,10 @@ async function handlePlayground(request: Request): Promise<Response> {
210212
errors: string[]
211213
structuredErrors?: PlaygroundError[]
212214
}
213-
setErrors(sessionId, body.errors ?? [])
214-
setStructuredErrors(sessionId, body.structuredErrors ?? [])
215+
recordErrorReport(sessionId, {
216+
errors: body.errors ?? [],
217+
structuredErrors: body.structuredErrors ?? [],
218+
})
215219
return jsonResponse({ status: 'ok' })
216220
}
217221

0 commit comments

Comments
 (0)