Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/web/src/lib/server/playground-db.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'playground-test-'))
const {
createSession,
getSession,
waitForBrowserSyncResult,
waitForErrorReport,
updateShader,
setScreenshot,
setErrors,
setStructuredErrors,
recordErrorReport,
setUniformValues,
updateMetadata,
} = await import('./playground-db.ts')
Expand Down Expand Up @@ -172,6 +175,130 @@ runTest('setStructuredErrors stores structured errors', () => {
assert.deepEqual(session.structuredErrors, errors)
})

runTest('waitForErrorReport resolves when browser posts errors', async () => {
const { id } = createSession()
const wait = waitForErrorReport(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: ['shader failed'],
structuredErrors: [],
})
}, 20)

const report = await wait
assert.deepEqual(report, {
errors: ['shader failed'],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult resolves early on compilation errors', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: ['compile failed'],
structuredErrors: [],
})
}, 20)

const result = await wait
assert.equal(Date.now() - startedAt < 500, true)
assert.equal(result.screenshotBase64, null)
assert.deepEqual(result.errorReport, {
errors: ['compile failed'],
structuredErrors: [],
})
})
Comment on lines +196 to +215
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good coverage of the key scenarios — error-first, screenshot-first, and the interleaved orderings.

Two optional gaps if you want full coverage:

  • Timeout scenario: neither event arrives within the window → returns { screenshotBase64: null, errorReport: null }
  • structuredErrors only: error report with empty errors but non-empty structuredErrors — the hasCompilationErrors check handles it correctly but there's no test exercising that branch

nit / optional


runTest('waitForBrowserSyncResult waits for screenshot after a successful empty error report', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [],
})
}, 20)

setTimeout(() => {
setScreenshot(id, 'data:image/png;base64,ok')
}, 60)

const result = await wait
const elapsedMs = Date.now() - startedAt

assert.equal(elapsedMs >= 40, true)
assert.equal(elapsedMs < 500, true)
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult waits for the error-clear report after screenshot success', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
setScreenshot(id, 'data:image/png;base64,ok')
}, 20)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [],
})
}, 60)

const result = await wait
const elapsedMs = Date.now() - startedAt

assert.equal(elapsedMs >= 40, true)
assert.equal(elapsedMs < 500, true)
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult returns nulls when the browser never responds', async () => {
const { id } = createSession()
const result = await waitForBrowserSyncResult(id, 25)

assert.deepEqual(result, {
screenshotBase64: null,
errorReport: null,
})
})

runTest('waitForBrowserSyncResult treats structured errors as compilation failures', async () => {
const { id } = createSession({ language: 'tsl', tslSource: 'export function createMaterial() {}' })
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
})
}, 20)

const result = await wait
assert.equal(result.screenshotBase64, null)
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
})
})

runTest('setUniformValues stores values', () => {
const { id } = createSession()
const values = { uTime: 1.5, uColor: [1, 0, 0] }
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/lib/server/playground-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ const sseConnections = new Map<string, Set<WritableStreamDefaultWriter<Uint8Arra
// Screenshot wait queue: when an update is posted, the API waits for the
// browser to send a screenshot back. This map stores resolve callbacks.
const screenshotWaiters = new Map<string, Array<(base64: string | null) => void>>()
const errorReportWaiters = new Map<string, Array<(report: ErrorReportPayload | null) => void>>()

export type ErrorReportPayload = {
errors: string[]
structuredErrors: PlaygroundError[]
}

export function addSSEConnection(sessionId: string, writer: WritableStreamDefaultWriter<Uint8Array>) {
let set = sseConnections.get(sessionId)
Expand Down Expand Up @@ -203,6 +209,85 @@ export function resolveScreenshotWaiters(sessionId: string, base64: string) {
}
}

export function waitForErrorReport(sessionId: string, timeoutMs: number): Promise<ErrorReportPayload | null> {
return new Promise((resolve) => {
const list = errorReportWaiters.get(sessionId) ?? []
list.push(resolve)
errorReportWaiters.set(sessionId, list)
setTimeout(() => {
const current = errorReportWaiters.get(sessionId)
if (current) {
const idx = current.indexOf(resolve)
if (idx !== -1) {
current.splice(idx, 1)
if (current.length === 0) errorReportWaiters.delete(sessionId)
}
}
resolve(null)
}, timeoutMs)
})
}

export function resolveErrorReportWaiters(sessionId: string, report: ErrorReportPayload) {
const list = errorReportWaiters.get(sessionId)
if (!list || list.length === 0) return
errorReportWaiters.delete(sessionId)
for (const resolve of list) {
resolve(report)
}
}

export async function waitForBrowserSyncResult(sessionId: string, timeoutMs: number): Promise<{
screenshotBase64: string | null
errorReport: ErrorReportPayload | null
}> {
const screenshotPromise = waitForScreenshot(sessionId, timeoutMs).then((base64) => ({
type: 'screenshot' as const,
base64,
}))
const errorPromise = waitForErrorReport(sessionId, timeoutMs).then((report) => ({
type: 'errorReport' as const,
report,
}))

let waitForScreenshotEvent = true
let waitForErrorEvent = true
let screenshotBase64: string | null = null
let errorReport: ErrorReportPayload | null = null

while (waitForScreenshotEvent || waitForErrorEvent) {
const pending: Array<
Promise<
| { type: 'screenshot'; base64: string | null }
| { type: 'errorReport'; report: ErrorReportPayload | null }
>
> = []

if (waitForScreenshotEvent) pending.push(screenshotPromise)
if (waitForErrorEvent) pending.push(errorPromise)

const next = await Promise.race(pending)

if (next.type === 'screenshot') {
waitForScreenshotEvent = false
screenshotBase64 = next.base64
} else {
waitForErrorEvent = false
errorReport = next.report
}

const hasCompilationErrors = !!errorReport
&& (errorReport.errors.length > 0 || errorReport.structuredErrors.length > 0)
const hasSuccessfulSync = screenshotBase64 !== null && errorReport !== null

if (hasCompilationErrors || hasSuccessfulSync) {
break
}
}

return { screenshotBase64, errorReport }
Comment on lines +240 to +288
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state machine here is solid — I traced through all five scenarios:

  1. Error with messages firsthasCompilationErrors breaks immediately (~96ms path)
  2. Empty error first, then screenshot → waits for screenshot after empty error
  3. Screenshot first, then empty error → waits for error report after screenshot
  4. Both timeout → both flags become false, while loop exits naturally
  5. Screenshot first, then error with messageshasCompilationErrors breaks with both

One thing to note: when breaking early on compilation errors, the screenshot waiter remains registered until the 5s timeout fires. Functionally harmless (promise resolves to null, nobody reads it, waiter cleaned up on timeout). Not worth adding cancellation complexity.

Also: browserResult.errorReport is unused by the API handler — it re-fetches from DB instead. This is correct since recordErrorReport persists before resolving waiters. A brief comment in the API handler could make that intent clearer for future readers, but optional.

}

// ---------------------------------------------------------------------------
// Row type from SQLite
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -354,6 +439,12 @@ export function setStructuredErrors(id: string, errors: PlaygroundError[]): void
).run(JSON.stringify(errors), id)
}

export function recordErrorReport(id: string, report: ErrorReportPayload): void {
setErrors(id, report.errors)
setStructuredErrors(id, report.structuredErrors)
resolveErrorReportWaiters(id, report)
}

export function setUniformValues(id: string, values: Record<string, unknown>): void {
db.prepare(
`UPDATE playground_sessions SET uniform_values_json = ?, updated_at = datetime('now') WHERE id = ?`,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/lib/webgl-shader-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,17 @@ runTest('collectShaderDiagnostics falls back to a generic compile message', () =
])
})

runTest('collectShaderDiagnostics falls back to a generic link message', () => {
const diagnostics = collectShaderDiagnostics({
gl: mockGl,
program: { log: null },
vertexShader: { ok: true, log: null },
fragmentShader: { ok: true, log: null },
})

assert.deepEqual(diagnostics, [
{ kind: 'glsl-link', message: 'GLSL program linking failed.' },
])
})

console.log('webgl-shader-errors tests passed')
4 changes: 2 additions & 2 deletions apps/web/src/lib/webgl-shader-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ type ShaderLogContext = {
getShaderParameter: (shader: unknown, pname: number) => boolean
}
program: unknown
vertexShader?: unknown | null
fragmentShader?: unknown | null
vertexShader?: unknown
fragmentShader?: unknown
}

function normalizeLog(log: string | null | undefined): string {
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/routes/api/playground/$.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import {
getSession,
updateShader,
setScreenshot,
setErrors,
setStructuredErrors,
recordErrorReport,
hasSSEConnections,
addSSEConnection,
removeSSEConnection,
waitForScreenshot,
waitForBrowserSyncResult,
} from '../../../lib/server/playground-db'
import type {
PlaygroundError,
Expand Down Expand Up @@ -64,7 +63,7 @@ function jsonResponse(data: unknown, status = 200): Response {
// ---------------------------------------------------------------------------

const WEB_URL = process.env.WEB_URL || 'https://shaderbase.com'
const SCREENSHOT_WAIT_MS = 5000
const BROWSER_SYNC_WAIT_MS = 5000

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

const previewAvailable = true

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

// Re-fetch session to get latest errors
Expand Down Expand Up @@ -210,8 +212,10 @@ async function handlePlayground(request: Request): Promise<Response> {
errors: string[]
structuredErrors?: PlaygroundError[]
}
setErrors(sessionId, body.errors ?? [])
setStructuredErrors(sessionId, body.structuredErrors ?? [])
recordErrorReport(sessionId, {
errors: body.errors ?? [],
structuredErrors: body.structuredErrors ?? [],
})
return jsonResponse({ status: 'ok' })
}

Expand Down