diff --git a/docker-compose.dev-sx.yml b/docker-compose.dev-sx.yml index 6377897a..40dc8261 100644 --- a/docker-compose.dev-sx.yml +++ b/docker-compose.dev-sx.yml @@ -17,6 +17,7 @@ services: - ./lib:/app/lib:ro - ./web:/app/web:ro - ./sx/sx:/app/sx:ro + - ./sx/sxc:/app/sxc:ro - ./shared:/app/shared:ro # OCaml binary (rebuild with: cd hosts/ocaml && eval $(opam env) && dune build) - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro diff --git a/hosts/ocaml/browser/sx_browser.ml b/hosts/ocaml/browser/sx_browser.ml index ff72784f..4187819d 100644 --- a/hosts/ocaml/browser/sx_browser.ml +++ b/hosts/ocaml/browser/sx_browser.ml @@ -684,6 +684,34 @@ let () = ignore (env_bind global_env "VOID_ELEMENTS" void_elements); ignore (env_bind global_env "BOOLEAN_ATTRS" boolean_attrs); + (* --- HTML tag special forms (div, span, h1, ...) --- *) + (* Registered as custom special forms so keywords are preserved. + Handler receives (raw-args env), evaluates non-keyword args + while keeping keyword names intact. *) + let eval_tag_args raw_args env = + let args = Sx_runtime.sx_to_list raw_args in + let rec process = function + | [] -> [] + | (Keyword _ as kw) :: value :: rest -> + (* keyword + its value: keep keyword, evaluate value *) + kw :: Sx_ref.eval_expr value env :: process rest + | (Keyword _ as kw) :: [] -> + (* trailing keyword with no value — boolean attr *) + [kw] + | expr :: rest -> + (* non-keyword: evaluate *) + Sx_ref.eval_expr expr env :: process rest + in + process args + in + List.iter (fun tag -> + ignore (Sx_ref.register_special_form (String tag) + (NativeFn ("sf:" ^ tag, fun handler_args -> + match handler_args with + | [raw_args; env] -> List (Symbol tag :: eval_tag_args raw_args env) + | _ -> Nil))) + ) Sx_render.html_tags; + (* --- Error handling --- *) bind "cek-try" (fun args -> match args with diff --git a/tests/playwright/sx-inspect.js b/tests/playwright/sx-inspect.js index 2977bf82..7e1ce229 100644 --- a/tests/playwright/sx-inspect.js +++ b/tests/playwright/sx-inspect.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // sx-inspect.js — SX-aware Playwright page inspector // Usage: node sx-inspect.js '{"mode":"...","url":"/",...}' -// Modes: inspect, diff, hydrate, eval, interact, screenshot +// Modes: inspect, diff, hydrate, eval, interact, screenshot, trace-boot, hydrate-debug, eval-at // Output: JSON to stdout const { chromium } = require('playwright'); @@ -1047,6 +1047,255 @@ async function modeReactive(page, url, island, actionsStr) { // --------------------------------------------------------------------------- // Main +// --------------------------------------------------------------------------- +// Mode: trace-boot — full console capture during boot (ALL prefixes) +// --------------------------------------------------------------------------- + +async function modeTraceBoot(page, url, filterPrefix) { + const allLogs = []; + page.on('console', msg => { + allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) }); + }); + page.on('pageerror', err => { + allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) }); + }); + + await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 }); + await waitForHydration(page); + + // Categorise + const phases = { platform: [], boot: [], errors: [], warnings: [], other: [] }; + for (const l of allLogs) { + if (l.type === 'error' || l.type === 'pageerror') phases.errors.push(l); + else if (l.type === 'warning') phases.warnings.push(l); + else if (l.text.startsWith('[sx-platform]')) phases.platform.push(l); + else if (l.text.startsWith('[sx]')) phases.boot.push(l); + else phases.other.push(l); + } + + // Apply optional prefix filter + const filtered = filterPrefix + ? allLogs.filter(l => l.text.includes(filterPrefix)) + : null; + + return { + url, + total: allLogs.length, + summary: { + platform: phases.platform.length, + boot: phases.boot.length, + errors: phases.errors.length, + warnings: phases.warnings.length, + other: phases.other.length, + }, + phases, + ...(filtered ? { filtered } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Mode: hydrate-debug — re-run hydration on one island with full tracing +// --------------------------------------------------------------------------- + +async function modeHydrateDebug(page, url, islandName) { + const allLogs = []; + page.on('console', msg => { + allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) }); + }); + page.on('pageerror', err => { + allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) }); + }); + + await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 }); + await waitForHydration(page); + + // Capture SSR HTML before re-hydration + const ssrHtml = await page.evaluate((name) => { + const el = name + ? document.querySelector(`[data-sx-island="${name}"]`) + : document.querySelector('[data-sx-island]'); + return el ? { name: el.getAttribute('data-sx-island'), html: el.innerHTML.substring(0, 2000) } : null; + }, islandName); + + if (!ssrHtml) { + return { url, error: `Island not found: ${islandName || '(first)'}` }; + } + + const targetIsland = ssrHtml.name; + + // Mark console position + const logCursorBefore = allLogs.length; + + // Run instrumented re-hydration: clear flag, inject tracing, re-hydrate + const result = await page.evaluate((name) => { + const K = globalThis.SxKernel; + if (!K) return { error: 'SxKernel not available' }; + + const el = document.querySelector(`[data-sx-island="${name}"]`); + if (!el) return { error: `Element not found: ${name}` }; + + // Clear hydration flag + el.removeAttribute('data-sx-hydrated'); + delete el._sxBoundislandhydrated; + delete el['_sxBound' + 'island-hydrated']; + + // Check env state + const compName = '~' + name; + const checks = {}; + checks.compType = K.eval(`(type-of ${compName})`); + checks.renderDomList = K.eval('(type-of render-dom-list)'); + checks.cssx = K.eval('(type-of ~cssx/tw)'); + checks.stateAttr = el.getAttribute('data-sx-state') || '{}'; + + // Parse state + window.__hd_state = checks.stateAttr; + checks.stateParsed = K.eval('(inspect (or (first (sx-parse (host-global "__hd_state"))) {}))'); + + // Get component params + checks.params = K.eval(`(inspect (component-params (env-get (global-env) "${compName}")))`); + + // Manual render with error capture (NOT cek-try — let errors propagate) + let renderResult; + try { + window.__hd_state2 = checks.stateAttr; + const rendered = K.eval(` + (let ((comp (env-get (global-env) "${compName}")) + (kwargs (or (first (sx-parse (host-global "__hd_state2"))) {}))) + (let ((local (env-merge (component-closure comp) (get-render-env nil)))) + (for-each + (fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + (let ((el (render-to-dom (component-body comp) local nil))) + (host-get el "outerHTML")))) + `); + renderResult = { ok: true, html: (typeof rendered === 'string' ? rendered.substring(0, 2000) : String(rendered).substring(0, 2000)) }; + } catch (e) { + renderResult = { ok: false, error: e.message }; + } + + // Now run the actual hydrate-island via boot.sx + try { + K.eval(`(hydrate-island (host-global "__hd_el"))`); + } catch (e) { + // ignore — we already got the manual render + } + + // Capture hydrated HTML + const hydratedHtml = el.innerHTML.substring(0, 2000); + + delete window.__hd_state; + delete window.__hd_state2; + + return { checks, renderResult, hydratedHtml }; + }, targetIsland); + + // Collect console messages from the re-hydration + const hydrateLogs = allLogs.slice(logCursorBefore); + + // Compare SSR vs hydrated + const ssrClasses = (ssrHtml.html.match(/class="[^"]*"/g) || []).length; + const hydClasses = (result.hydratedHtml || '').match(/class="[^"]*"/g) || []; + const manualClasses = (result.renderResult?.html || '').match(/class="[^"]*"/g) || []; + + return { + url, + island: targetIsland, + ssrHtml: ssrHtml.html.substring(0, 500), + checks: result.checks, + manualRender: result.renderResult, + hydratedHtml: (result.hydratedHtml || '').substring(0, 500), + classCounts: { + ssr: ssrClasses, + manual: manualClasses.length, + hydrated: hydClasses.length, + }, + hydrateLogs: hydrateLogs.length > 0 ? hydrateLogs : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Mode: eval-at — inject eval breakpoint at a specific boot phase +// --------------------------------------------------------------------------- + +async function modeEvalAt(browser, url, phase, expr) { + const validPhases = [ + 'before-modules', 'after-modules', + 'before-pages', 'after-pages', + 'before-components', 'after-components', + 'before-hydrate', 'after-hydrate', + 'after-boot', + ]; + if (!validPhases.includes(phase)) { + return { error: `Invalid phase: ${phase}. Valid: ${validPhases.join(', ')}` }; + } + + const context = await browser.newContext(); + const page = await context.newPage(); + + const allLogs = []; + page.on('console', msg => { + allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) }); + }); + + // Inject a hook that pauses the boot at the desired phase. + // We do this by intercepting sx-platform-2.js and injecting eval calls. + await page.route('**/*sx-platform-2.js*', async (route) => { + const resp = await route.fetch(); + let body = await resp.text(); + + // Pass expression via a global to avoid JS/SX quoting hell. + // K.eval() handles parsing internally, so we just pass the SX string. + // Also set up common DOM helpers as globals for easy SX access. + const evalCode = [ + `window.__sxEvalAtExpr = ${JSON.stringify(expr)};`, + `window.__sxEl0 = document.querySelectorAll('[data-sx-island]')[0] || null;`, + `window.__sxEl1 = document.querySelectorAll('[data-sx-island]')[1] || null;`, + `try { console.log("[sx-eval-at] ${phase}: " + K.eval(window.__sxEvalAtExpr)); }`, + `catch(e) { console.log("[sx-eval-at] ${phase}: JS-ERROR: " + e.message); }`, + ].join('\n '); + + // Map phase names to injection points in the platform code + const injections = { + 'before-modules': { before: 'loadWebStack();' }, + 'after-modules': { after: 'loadWebStack();' }, + 'before-pages': { before: 'K.eval("(process-page-scripts)");' }, + 'after-pages': { after: 'K.eval("(process-page-scripts)");' }, + 'before-components': { before: 'K.eval("(process-sx-scripts nil)");' }, + 'after-components': { after: 'K.eval("(process-sx-scripts nil)");' }, + 'before-hydrate': { before: 'K.eval("(sx-hydrate-islands nil)");' }, + 'after-hydrate': { after: 'K.eval("(sx-hydrate-islands nil)");' }, + 'after-boot': { after: 'console.log("[sx] boot done");' }, + }; + + const inj = injections[phase]; + if (inj.before) { + body = body.replace(inj.before, evalCode + '\n ' + inj.before); + } else if (inj.after) { + body = body.replace(inj.after, inj.after + '\n ' + evalCode); + } + + await route.fulfill({ body, headers: { ...resp.headers(), 'content-type': 'application/javascript' } }); + }); + + await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 }); + await waitForHydration(page); + + // Extract our eval-at result from console + const evalAtLogs = allLogs.filter(l => l.text.startsWith('[sx-eval-at]')); + const evalResult = evalAtLogs.length > 0 + ? evalAtLogs[0].text.replace(`[sx-eval-at] ${phase}: `, '') + : 'INJECTION FAILED — phase marker not found in platform code'; + + // Also collect boot sequence for context + const bootLogs = allLogs.filter(l => + l.text.startsWith('[sx]') || l.text.startsWith('[sx-platform]') || l.text.startsWith('[sx-eval-at]') || + l.type === 'error' || l.type === 'warning' + ); + + await context.close(); + return { url, phase, expr, result: evalResult, bootLog: bootLogs }; +} + async function main() { const argsJson = process.argv[2] || '{}'; let args; @@ -1096,6 +1345,15 @@ async function main() { case 'reactive': result = await modeReactive(page, url, args.island || '', args.actions || ''); break; + case 'trace-boot': + result = await modeTraceBoot(page, url, args.filter || ''); + break; + case 'hydrate-debug': + result = await modeHydrateDebug(page, url, args.island || ''); + break; + case 'eval-at': + result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)'); + break; default: result = { error: `Unknown mode: ${mode}` }; }