A fork of mtelver's day10 project

feat(x-ocaml): add Page module, test linking, and Playwright tests

Extract page-level orchestration into a new Page module, separating
cell registry, test linking, message routing, and auto-run from the
WebComponent definition in x_ocaml.ml.

Key changes:
- Page module: cell registry, test linking (positional + data-for),
backend message routing, universe discovery stub, format config
- Cell: add ?merlin param, set_on_completed hook, has_completed,
string_of_mode, cell_for/cell_env accessors
- Editor: improved docstrings
- x_ocaml.ml: simplified to thin WebComponent + Page delegation
- Playwright browser tests: 17 assertions across 7 test scenarios
covering hidden/interactive/exercise/test modes, read-only state,
default mode, merlin disable, and full assessment chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+888 -69
+1
x-ocaml/.gitignore
··· 14 14 15 15 _build/ 16 16 *.js 17 + !test/run_tests.js
+32 -13
x-ocaml/src/cell.ml
··· 8 8 | "hidden" -> Hidden 9 9 | "interactive" | _ -> Interactive 10 10 11 + let string_of_mode = function 12 + | Interactive -> "interactive" 13 + | Exercise -> "exercise" 14 + | Test -> "test" 15 + | Hidden -> "hidden" 16 + 11 17 type status = Not_run | Running | Run_ok | Request_run 12 18 13 19 type t = { ··· 26 32 merlin_worker : Merlin_ext.Client.worker option; 27 33 run_on : [ `Click | `Load ]; 28 34 filename : string option; 35 + mutable on_completed : (t -> unit) option; 29 36 } 30 37 31 38 let id t = t.id 32 39 let mode t = t.mode 33 40 let cell_id t = t.cell_id 41 + let cell_for t = t.cell_for 42 + let cell_env t = t.cell_env 43 + let has_completed t = t.status = Run_ok 44 + let set_on_completed t f = t.on_completed <- Some f 34 45 35 46 let source t = 36 47 match t.cm with ··· 146 157 ] 147 158 148 159 let init ~id ~mode ~run_on ?cell_id ?cell_for ?cell_env ?filename 149 - ?extra_style ?inline_style ~eval_fn ~fmt_fn ~post_fn this = 160 + ?(merlin = true) ?extra_style ?inline_style ~eval_fn ~fmt_fn ~post_fn this = 150 161 let shadow = Webcomponent.attach_shadow this in 151 162 let is_hidden = mode = Hidden in 152 163 let is_readonly = mode = Interactive || mode = Test in ··· 154 165 if not is_hidden then 155 166 init_css shadow ~extra_style ~inline_style; 156 167 157 - let cm, merlin_info = 158 - if is_hidden then (None, None) 168 + let cm, merlin_info, editor_ref = 169 + if is_hidden then (None, None, None) 159 170 else begin 160 171 let run_btn = El.button [ El.txt (Jstr.of_string "Run") ] in 161 172 El.append_children shadow ··· 163 174 164 175 let cm = Editor.make ~read_only:is_readonly shadow in 165 176 166 - let merlin = Merlin_ext.make ~id ?filename post_fn in 167 - let merlin_worker = Merlin_ext.Client.make_worker merlin in 177 + let merlin_info = 178 + if merlin then begin 179 + let m = Merlin_ext.make ~id ?filename post_fn in 180 + let w = Merlin_ext.Client.make_worker m in 181 + Some (m, w) 182 + end else None 183 + in 168 184 169 185 let editor_ref = ref None in 170 186 ··· 183 199 (El.as_target run_btn) 184 200 in 185 201 186 - (Some cm, Some (merlin, merlin_worker, editor_ref)) 202 + (Some cm, merlin_info, Some editor_ref) 187 203 end 188 204 in 189 205 ··· 202 218 eval_fn; 203 219 fmt_fn; 204 220 merlin_worker = 205 - (match merlin_info with Some (_, w, _) -> Some w | None -> None); 221 + (match merlin_info with Some (_, w) -> Some w | None -> None); 206 222 run_on; 207 223 filename; 224 + on_completed = None; 208 225 } 209 226 in 210 227 ··· 212 229 | Some cm -> Editor.on_change cm (fun () -> invalidate_after ~editor) 213 230 | None -> ()); 214 231 215 - (match merlin_info with 216 - | Some (merlin, merlin_worker, editor_ref) -> 217 - editor_ref := Some editor; 232 + (match editor_ref with Some r -> r := Some editor | None -> ()); 233 + 234 + (match merlin_info, cm with 235 + | Some (merlin, merlin_worker), Some cm -> 218 236 Merlin_ext.set_context merlin (fun () -> pre_source editor); 219 - Editor.configure_merlin (Option.get cm) (fun () -> 237 + Editor.configure_merlin cm (fun () -> 220 238 Merlin_ext.extensions merlin_worker) 221 - | None -> ()); 239 + | _ -> ()); 222 240 223 241 editor 224 242 ··· 258 276 let loc = String.length (source ed) in 259 277 add_message ed loc msg); 260 278 ed.status <- Run_ok; 261 - match ed.next with Some e when e.status = Request_run -> run e | _ -> () 279 + (match ed.next with Some e when e.status = Request_run -> run e | _ -> ()); 280 + match ed.on_completed with Some f -> f ed | None -> () 262 281 263 282 let receive_merlin t msg = 264 283 match t.merlin_worker with
+142 -6
x-ocaml/src/cell.mli
··· 1 - type mode = Interactive | Exercise | Test | Hidden 1 + (** Individual code cell within an x-ocaml page. 2 + 3 + A cell is a single code editor + output region embedded in the page. Each 4 + cell has a {!mode} that controls its appearance and behaviour: 5 + 6 + - {b Interactive}: visible, read-only. The default for cells without a 7 + [mode] attribute. Shows pre-written code the reader can run but not edit. 8 + - {b Exercise}: visible, editable. The reader writes or modifies code here. 9 + - {b Test}: visible, read-only. Contains assertions that validate exercise 10 + code. Linked to an exercise cell and run automatically after it. 11 + - {b Hidden}: invisible. Provides definitions (types, helpers) available to 12 + subsequent cells without cluttering the page. 13 + 14 + Cells form a linear chain via {!set_prev}. When a cell is run, all 15 + preceding cells that haven't completed are run first, ensuring definitions 16 + are available. The chain determines line-number offsets so error locations 17 + map correctly to the page. 18 + 19 + {2 Test linking} 20 + 21 + A {!Test} cell can be linked to an {!Exercise} cell in two ways: 22 + 23 + - {b Positional}: if no [data-for] is set, a test cell links to the nearest 24 + preceding exercise cell. 25 + - {b Explicit}: [data-for="some-id"] links to the exercise cell whose 26 + [data-id] is ["some-id"], regardless of position. 27 + 28 + Linking is established by the page-level orchestrator ({!Page}), not by the 29 + cell itself. The cell exposes {!set_on_completed} so the page can register 30 + a callback that triggers linked test cells after a successful run. *) 31 + 32 + (** {1 Mode} *) 33 + 34 + type mode = 35 + | Interactive (** Visible, read-only. *) 36 + | Exercise (** Visible, editable. *) 37 + | Test (** Visible, read-only. Linked to an exercise cell. *) 38 + | Hidden (** Invisible. Provides definitions to later cells. *) 2 39 3 40 val mode_of_string : string -> mode 41 + (** [mode_of_string s] parses a mode from an HTML attribute value. 42 + Returns {!Interactive} for unrecognised strings. *) 43 + 44 + val string_of_mode : mode -> string 45 + (** [string_of_mode m] returns the canonical lowercase string for a mode. *) 46 + 47 + (** {1 Cell type} *) 4 48 5 49 type t 50 + (** An individual code cell. Mutable: tracks execution status, editor state, 51 + and position in the cell chain. *) 52 + 53 + (** {1 Creation} *) 6 54 7 55 val init : 8 56 id:int -> ··· 12 60 ?cell_for:string -> 13 61 ?cell_env:string -> 14 62 ?filename:string -> 63 + ?merlin:bool -> 15 64 ?extra_style:Jstr.t -> 16 65 ?inline_style:Jstr.t -> 17 66 eval_fn:(id:int -> line_number:int -> string -> unit) -> ··· 19 68 post_fn:(X_protocol.request -> unit) -> 20 69 Webcomponent.t -> 21 70 t 71 + (** Create and initialise a cell. 72 + 73 + Attaches a shadow DOM to the given web component, creates the CodeMirror 74 + editor (unless the mode is {!Hidden}), and sets up the merlin integration 75 + (unless [~merlin:false] is passed). 76 + 77 + @param id Unique numeric identifier (typically the cell's position on page). 78 + @param mode Determines editability and visibility. 79 + @param run_on Whether to run automatically on page load or only on click. 80 + @param cell_id Optional [data-id] attribute for test-linking targets. 81 + @param cell_for Optional [data-for] attribute linking a test to an exercise. 82 + @param cell_env Optional [data-env] for future environment scoping. 83 + @param filename Optional filename for merlin and error reporting. 84 + @param merlin Whether to enable merlin integration. Default [true]; 85 + set to [false] to disable completions and type-on-hover for this cell. 86 + Has no effect on {!Hidden} cells, which never have merlin regardless. 87 + @param extra_style URL to an external stylesheet to load. 88 + @param inline_style Inline CSS for the [:host] selector. 89 + @param eval_fn Called to evaluate code. Receives the cell [~id], 90 + [~line_number] offset, and the source code string. 91 + @param fmt_fn Called to format code. Receives the cell [~id] and source. 92 + @param post_fn Called to send raw protocol messages (for merlin). *) 93 + 94 + (** {1 Accessors} *) 22 95 23 96 val id : t -> int 97 + (** The cell's numeric identifier. *) 98 + 24 99 val mode : t -> mode 100 + (** The cell's mode. *) 101 + 25 102 val cell_id : t -> string option 26 - val set_source : t -> string -> unit 27 - val add_message : t -> int -> X_protocol.output list -> unit 103 + (** The [data-id] attribute, if set. Used as a target for [data-for] linking. *) 104 + 105 + val cell_for : t -> string option 106 + (** The [data-for] attribute, if set. Links a test cell to an exercise cell. *) 107 + 108 + val cell_env : t -> string option 109 + (** The [data-env] attribute, if set. Reserved for environment scoping. *) 110 + 111 + val source : t -> string 112 + (** The current source code. Reads from the CodeMirror editor if present, 113 + otherwise from the internal text buffer (for hidden cells). *) 114 + 115 + val has_completed : t -> bool 116 + (** [true] if the cell has been run and completed successfully since the last 117 + invalidation. *) 118 + 119 + (** {1 Execution} *) 120 + 121 + val run : t -> unit 122 + (** Run this cell. If preceding cells in the chain have not completed, they 123 + are run first (cascading). Does nothing if the cell is already running. *) 124 + 125 + val loadable : t -> bool 126 + (** [true] if the cell's [run_on] is [`Load]. *) 127 + 28 128 val completed_run : t -> X_protocol.output list -> unit 129 + (** Called by the backend when evaluation completes. Displays the output and 130 + marks the cell as successfully run. If the next cell in the chain was 131 + waiting, triggers it. 132 + 133 + After marking the cell complete, fires any callback registered via 134 + {!set_on_completed}. The callback fires after every completed run, 135 + regardless of whether the output contains errors. *) 136 + 137 + val set_on_completed : t -> (t -> unit) -> unit 138 + (** [set_on_completed cell f] registers [f] to be called each time [cell] 139 + completes a run. Used by {!Page} to trigger linked test cells after 140 + an exercise cell finishes. 141 + 142 + Replaces any previously registered callback. *) 143 + 144 + (** {1 Chain management} *) 145 + 29 146 val set_prev : prev:t option -> t -> unit 30 - val receive_merlin : t -> Protocol.answer -> unit 147 + (** [set_prev ~prev cell] sets [cell]'s predecessor in the execution chain. 148 + Updates line-number offsets for all subsequent cells. The previous cell's 149 + [next] pointer is updated as well. *) 150 + 151 + (** {1 Content and display} *) 152 + 153 + val set_source : t -> string -> unit 154 + (** Replace the cell's source code. Updates the editor if present. *) 155 + 156 + val add_message : t -> int -> X_protocol.output list -> unit 157 + (** [add_message cell loc msgs] adds output messages at character offset 158 + [loc] in the cell's editor. No-op for hidden cells. *) 159 + 31 160 val start : t -> Webcomponent.t -> unit 32 - val loadable : t -> bool 33 - val run : t -> unit 161 + (** [start cell element] reads the initial source from the element's 162 + [textContent] and loads it into the cell. Called once after the 163 + WebComponent's [connectedCallback]. *) 164 + 165 + (** {1 Merlin integration} *) 166 + 167 + val receive_merlin : t -> Protocol.answer -> unit 168 + (** Handle a merlin response for this cell. No-op if the cell has no 169 + merlin worker. *)
+50 -2
x-ocaml/src/editor.mli
··· 1 + (** CodeMirror editor wrapper. 2 + 3 + Wraps a CodeMirror 6 editor view with support for: 4 + - Line number offsets (so cells display correct line numbers in a chain) 5 + - Inline output messages (displayed as decorations after code) 6 + - Merlin extensions (completions, type-on-hover, error highlighting) 7 + - Change tracking with invalidation callbacks 8 + 9 + The editor is created inside a shadow DOM element and requires the 10 + CodeMirror [basic_setup] extension to be available at creation time. *) 11 + 1 12 type t 13 + (** A CodeMirror editor instance with associated state. *) 2 14 3 15 val make : ?read_only:bool -> Brr.El.t -> t 16 + (** [make ?read_only parent] creates a new editor inside [parent]. 17 + 18 + @param read_only If [true], the editor content cannot be modified. 19 + Uses the CodeMirror [readOnly] facet. Default is [false]. *) 20 + 4 21 val source : t -> string 22 + (** The current document text. *) 23 + 5 24 val set_source : t -> string -> unit 25 + (** Replace the entire document. Also updates the internal doc cache 26 + and clears stale messages. *) 27 + 28 + (** {1 Messages} *) 29 + 6 30 val clear : t -> unit 31 + (** Clear all messages and refresh line numbers and merlin state. *) 32 + 33 + val clear_messages : t -> unit 34 + (** Clear output messages only. *) 35 + 36 + val add_message : t -> int -> Brr.El.t list -> unit 37 + (** [add_message editor loc elements] adds output elements at character 38 + offset [loc]. The elements are rendered as block widgets below the 39 + line containing offset [loc]. *) 40 + 41 + (** {1 Line numbers} *) 42 + 7 43 val nb_lines : t -> int 44 + (** Total line count: previous lines offset + lines in the current document. *) 45 + 8 46 val get_previous_lines : t -> int 47 + (** The line offset from preceding cells. *) 48 + 9 49 val set_previous_lines : t -> int -> unit 10 - val clear_messages : t -> unit 11 - val add_message : t -> int -> Brr.El.t list -> unit 50 + (** Set the line offset and refresh the gutter. *) 51 + 52 + (** {1 Integration} *) 53 + 12 54 val on_change : t -> (unit -> unit) -> unit 55 + (** [on_change editor fn] calls [fn] whenever the document changes. 56 + Replaces any previously registered change handler. *) 57 + 13 58 val configure_merlin : t -> (unit -> Code_mirror.Extension.t list) -> unit 59 + (** [configure_merlin editor ext_fn] sets the merlin extension provider. 60 + [ext_fn] is called to get the current list of merlin extensions. 61 + Replaces any previously configured provider. *)
+142
x-ocaml/src/page.ml
··· 1 + open Brr 2 + 3 + type t = { 4 + backend : Backend.t; 5 + extra_style : Jstr.t option; 6 + inline_style : Jstr.t option; 7 + default_run_on : string; 8 + mutable cells : Cell.t list; 9 + (* test_links: maps exercise cell id -> list of test cells linked to it *) 10 + mutable test_links : (int * Cell.t list) list; 11 + } 12 + 13 + let find_by_id t id = List.find (fun c -> Cell.id c = id) t.cells 14 + 15 + let find_by_cell_id t name = 16 + List.find_opt (fun c -> Cell.cell_id c = Some name) t.cells 17 + 18 + let cells t = List.rev t.cells 19 + 20 + (* Find the nearest preceding exercise cell in document order. 21 + t.cells is stored in reverse document order (most recent first). *) 22 + let find_preceding_exercise t = 23 + List.find_opt (fun c -> Cell.mode c = Cell.Exercise) t.cells 24 + 25 + (* Register a test cell as linked to an exercise cell. 26 + When the exercise completes, all linked tests will be triggered. *) 27 + let link_test t ~exercise ~test = 28 + let ex_id = Cell.id exercise in 29 + let existing = 30 + match List.assoc_opt ex_id t.test_links with 31 + | Some tests -> tests 32 + | None -> [] 33 + in 34 + t.test_links <- (ex_id, test :: existing) 35 + :: List.filter (fun (id, _) -> id <> ex_id) t.test_links 36 + 37 + (* Set up the on_completed callback for an exercise cell. 38 + When it completes, run all linked test cells. *) 39 + let setup_exercise_callback t exercise = 40 + Cell.set_on_completed exercise (fun _ex -> 41 + let ex_id = Cell.id exercise in 42 + match List.assoc_opt ex_id t.test_links with 43 + | None -> () 44 + | Some tests -> List.iter Cell.run tests) 45 + 46 + let read_meta_content name = 47 + let doc = Document.to_jv G.document in 48 + let selector = Jstr.of_string ("meta[name=\"" ^ name ^ "\"]") in 49 + let result = Jv.call doc "querySelector" [| Jv.of_jstr selector |] in 50 + if Jv.is_none result then None 51 + else 52 + let content = Jv.call result "getAttribute" [| Jv.of_string "content" |] in 53 + if Jv.is_none content then None 54 + else Some (Jv.to_string content) 55 + 56 + let create ~backend ?extra_style ?inline_style ?(default_run_on = "load") 57 + ?format_config () = 58 + let t = 59 + { backend; extra_style; inline_style; default_run_on; 60 + cells = []; test_links = [] } 61 + in 62 + (* Route backend responses to the appropriate cell *) 63 + Backend.on_message backend (function 64 + | X_protocol.Formatted_source (id, code_fmt) -> 65 + Cell.set_source (find_by_id t id) code_fmt 66 + | Top_response_at (id, loc, msg) -> 67 + Cell.add_message (find_by_id t id) loc msg 68 + | Top_response (id, msg) -> 69 + Cell.completed_run (find_by_id t id) msg 70 + | Merlin_response (id, msg) -> 71 + Cell.receive_merlin (find_by_id t id) msg); 72 + (* Initialise backend *) 73 + Backend.post backend Setup; 74 + (match format_config with 75 + | Some conf -> Backend.post backend (Format_config conf) 76 + | None -> ()); 77 + (* Universe discovery: read <meta name="x-ocaml-packages"> *) 78 + (match read_meta_content "x-ocaml-packages" with 79 + | None -> () 80 + | Some _packages -> 81 + (* TODO: send package configuration to backend once protocol supports it *) 82 + ()); 83 + t 84 + 85 + let run_on_of_string = function "click" -> `Click | "load" | _ -> `Load 86 + 87 + let register t this = 88 + let id = List.length t.cells in 89 + let prev = match t.cells with [] -> None | c :: _ -> Some c in 90 + let mode = 91 + Cell.mode_of_string 92 + (Option.value ~default:"interactive" 93 + (Webcomponent.get_attribute this "mode")) 94 + in 95 + let run_on = 96 + run_on_of_string 97 + (Option.value ~default:t.default_run_on 98 + (Webcomponent.get_attribute this "run-on")) 99 + in 100 + let cell_id = Webcomponent.get_attribute this "data-id" in 101 + let cell_for = Webcomponent.get_attribute this "data-for" in 102 + let cell_env = Webcomponent.get_attribute this "data-env" in 103 + let filename = Webcomponent.get_attribute this "data-filename" in 104 + let merlin = 105 + match Webcomponent.get_attribute this "data-merlin" with 106 + | Some "false" -> false 107 + | _ -> true 108 + in 109 + let eval_fn ~id ~line_number code = 110 + Backend.eval ~id ~line_number t.backend code 111 + in 112 + let fmt_fn ~id code = Backend.fmt ~id t.backend code in 113 + let post_fn msg = Backend.post t.backend msg in 114 + let cell = 115 + Cell.init ~id ~mode ~run_on ?cell_id ?cell_for ?cell_env ?filename ~merlin 116 + ?extra_style:t.extra_style ?inline_style:t.inline_style ~eval_fn ~fmt_fn 117 + ~post_fn this 118 + in 119 + t.cells <- cell :: t.cells; 120 + Cell.set_prev ~prev cell; 121 + (* Test linking: connect Test cells to their target Exercise cell *) 122 + (match mode with 123 + | Cell.Test -> 124 + let target = 125 + match cell_for with 126 + | Some target_id -> find_by_cell_id t target_id 127 + | None -> find_preceding_exercise t 128 + in 129 + (match target with 130 + | Some exercise -> 131 + link_test t ~exercise ~test:cell; 132 + setup_exercise_callback t exercise; 133 + (* Late registration: if exercise already completed, trigger now *) 134 + if Cell.has_completed exercise then Cell.run cell 135 + | None -> ()) 136 + | Cell.Exercise -> 137 + (* Set up the callback so tests linked later will be triggered *) 138 + setup_exercise_callback t cell 139 + | _ -> ()); 140 + Cell.start cell this; 141 + if List.for_all Cell.loadable t.cells then Cell.run cell; 142 + cell
+110
x-ocaml/src/page.mli
··· 1 + (** Page-level orchestration for x-ocaml cells. 2 + 3 + A page contains a collection of {!Cell.t} values arranged in document 4 + order. This module manages: 5 + 6 + - {b Cell registry}: looking up cells by numeric id or by [data-id]. 7 + - {b Execution chain}: linking cells into a linear predecessor chain so 8 + that running a cell automatically runs its prerequisites. 9 + - {b Test linking}: connecting {!Cell.Test} cells to their target 10 + {!Cell.Exercise} cells, either positionally (nearest preceding exercise) 11 + or explicitly via [data-for] / [data-id] attributes. After an exercise 12 + cell completes, its linked test cells run automatically. 13 + - {b Auto-run}: triggering cells whose [run-on] is ["load"] once all 14 + cells have been registered. 15 + - {b Backend message routing}: dispatching responses from the backend to 16 + the correct cell. 17 + 18 + {2 Typical lifecycle} 19 + 20 + {[ 21 + let page = Page.create ~backend () in 22 + (* WebComponent connectedCallback fires for each <x-ocaml>: *) 23 + let _cell = Page.register page element in 24 + (* After all cells registered, auto-run triggers. *) 25 + ]} 26 + 27 + {2 Test linking} 28 + 29 + When a {!Cell.Test} cell is registered, the page links it to an exercise: 30 + 31 + - If the test has [data-for="some-id"], it links to the exercise cell 32 + whose [data-id="some-id"]. 33 + - Otherwise it links to the nearest preceding {!Cell.Exercise}. 34 + 35 + After an exercise cell's run completes, all test cells linked to it are 36 + triggered automatically. If a test cell is registered after its linked 37 + exercise has already completed, the test is triggered immediately upon 38 + registration. 39 + 40 + {2 Universe discovery} 41 + 42 + During creation, the page reads 43 + [<meta name="x-ocaml-packages" content="...">] from the document head. 44 + If present, the comma-separated package names are sent to the backend so 45 + that their modules become available during evaluation. *) 46 + 47 + type t 48 + (** Mutable page-level state: cell registry, backend handle, and page-wide 49 + configuration. *) 50 + 51 + val create : 52 + backend:Backend.t -> 53 + ?extra_style:Jstr.t -> 54 + ?inline_style:Jstr.t -> 55 + ?default_run_on:string -> 56 + ?format_config:string -> 57 + unit -> 58 + t 59 + (** Create a new page and initialise the backend. 60 + 61 + Sends [Setup] to the backend, registers a message handler that routes 62 + responses to the appropriate cell, reads 63 + [<meta name="x-ocaml-packages">] from the document and sends package 64 + configuration if present. 65 + 66 + @param backend The evaluation/formatting backend. 67 + @param extra_style Default stylesheet URL applied to every cell. 68 + @param inline_style Default inline CSS for every cell's [:host] selector. 69 + @param default_run_on Default [run-on] value for cells that don't specify 70 + their own (["load"] or ["click"]). Defaults to ["load"]. 71 + @param format_config OCamlformat configuration string. If provided, 72 + sent to the backend as [Format_config]. *) 73 + 74 + (** {1 Cell registration} *) 75 + 76 + val register : t -> Webcomponent.t -> Cell.t 77 + (** [register page element] creates and registers a new cell from the 78 + given custom element. 79 + 80 + Reads the following attributes from [element]: 81 + 82 + - [mode]: cell mode (["interactive"], ["exercise"], ["test"], ["hidden"]). 83 + Defaults to ["interactive"]. 84 + - [data-id]: makes this cell addressable by name for test linking. 85 + - [data-for]: links a test cell to an exercise cell by [data-id]. 86 + - [data-env]: reserved for future environment scoping. 87 + - [data-filename]: filename for merlin and error reporting. 88 + - [data-merlin]: ["false"] to disable merlin for this cell. 89 + - [run-on]: per-cell override of the default run-on behaviour. 90 + 91 + After creation, the cell is: 92 + + Added to the registry. 93 + + Linked into the execution chain (predecessor set to the previous cell). 94 + + If it is a {!Cell.Test}, linked to its target exercise cell. If the 95 + target exercise has already completed, the test is triggered immediately. 96 + + Started (initial source loaded from element [textContent]). 97 + + If all cells so far are loadable, the last cell is auto-run. *) 98 + 99 + (** {1 Lookups} *) 100 + 101 + val find_by_id : t -> int -> Cell.t 102 + (** [find_by_id page id] returns the cell with numeric id [id]. 103 + @raise Not_found if no such cell exists. *) 104 + 105 + val find_by_cell_id : t -> string -> Cell.t option 106 + (** [find_by_cell_id page name] returns the cell whose [data-id] is [name], 107 + if any. *) 108 + 109 + val cells : t -> Cell.t list 110 + (** All registered cells in document order (first cell at the head). *)
+13 -48
x-ocaml/src/x_ocaml.ml
··· 1 - let all : Cell.t list ref = ref [] 2 - let find_by_id id = List.find (fun t -> Cell.id t = id) !all 3 - 4 1 let current_script = 5 2 Brr.El.of_jv (Jv.get (Brr.Document.to_jv Brr.G.document) "currentScript") 6 3 ··· 26 23 27 24 let backend = Backend.make ~backend:backend_name ?extra_load worker_url 28 25 29 - let () = 30 - Backend.on_message backend @@ function 31 - | Formatted_source (id, code_fmt) -> Cell.set_source (find_by_id id) code_fmt 32 - | Top_response_at (id, loc, msg) -> Cell.add_message (find_by_id id) loc msg 33 - | Top_response (id, msg) -> Cell.completed_run (find_by_id id) msg 34 - | Merlin_response (id, msg) -> Cell.receive_merlin (find_by_id id) msg 26 + let format_config = 27 + match current_attribute "x-ocamlformat" with 28 + | None -> None 29 + | Some conf -> Some (Jstr.to_string conf) 30 + 31 + let extra_style = current_attribute "src-style" 32 + let inline_style = current_attribute "inline-style" 35 33 36 - let () = Backend.post backend Setup 34 + let default_run_on = 35 + current_attribute "run-on" |> Option.map Jstr.to_string 37 36 38 - let () = 39 - match current_attribute "x-ocamlformat" with 40 - | None -> () 41 - | Some conf -> Backend.post backend (Format_config (Jstr.to_string conf)) 37 + let page = 38 + Page.create ~backend ?extra_style ?inline_style ?default_run_on 39 + ?format_config () 42 40 43 41 let elt_name = 44 42 match current_attribute "elt-name" with 45 43 | None -> Jstr.of_string "x-ocaml" 46 44 | Some name -> name 47 - 48 - let extra_style = current_attribute "src-style" 49 - let inline_style = current_attribute "inline-style" 50 - let run_on = current_attribute "run-on" |> Option.map Jstr.to_string 51 - let run_on_of_string = function "click" -> `Click | "load" | _ -> `Load 52 45 53 46 let _ = 54 47 Webcomponent.define elt_name @@ fun this -> 55 - let prev = match !all with [] -> None | e :: _ -> Some e in 56 - let run_on = 57 - run_on_of_string 58 - @@ 59 - match Webcomponent.get_attribute this "run-on" with 60 - | Some s -> s 61 - | None -> Option.value ~default:"load" run_on 62 - in 63 - let id = List.length !all in 64 - let filename = Webcomponent.get_attribute this "data-filename" in 65 - let mode = 66 - Cell.mode_of_string 67 - (Option.value ~default:"interactive" 68 - (Webcomponent.get_attribute this "mode")) 69 - in 70 - let cell_id = Webcomponent.get_attribute this "data-id" in 71 - let cell_for = Webcomponent.get_attribute this "data-for" in 72 - let cell_env = Webcomponent.get_attribute this "data-env" in 73 - let eval_fn ~id ~line_number code = Backend.eval ~id ~line_number backend code in 74 - let fmt_fn ~id code = Backend.fmt ~id backend code in 75 - let post_fn msg = Backend.post backend msg in 76 - let editor = 77 - Cell.init ~id ~mode ~run_on ?cell_id ?cell_for ?cell_env ?filename 78 - ?extra_style ?inline_style ~eval_fn ~fmt_fn ~post_fn this 79 - in 80 - all := editor :: !all; 81 - Cell.set_prev ~prev editor; 82 - Cell.start editor this; 83 - if List.for_all Cell.loadable !all then Cell.run editor; 48 + let _cell = Page.register page this in 84 49 ()
+1
x-ocaml/test/.gitignore
··· 1 + node_modules/
+23
x-ocaml/test/dune
··· 1 + ; Browser tests for x-ocaml cell modes 2 + ; Build deps: dune build @x-ocaml/test/browser 3 + ; Run tests: dune build @x-ocaml/test/runbrowser 4 + ; Requires: cd x-ocaml/test && npm install (once, for Playwright) 5 + 6 + (alias 7 + (name browser) 8 + (deps 9 + ../x-ocaml.js 10 + ../x-ocaml.worker.js 11 + (source_tree .))) 12 + 13 + (rule 14 + (alias runbrowser) 15 + (deps 16 + ../x-ocaml.js 17 + ../x-ocaml.worker.js 18 + (source_tree .)) 19 + (action 20 + (chdir %{project_root}/test 21 + (progn 22 + (echo "Running x-ocaml browser tests with Playwright...\n") 23 + (run node run_tests.js)))))
+13
x-ocaml/test/package.json
··· 1 + { 2 + "name": "x-ocaml-browser-tests", 3 + "version": "1.0.0", 4 + "description": "Browser tests for x-ocaml cell modes", 5 + "private": true, 6 + "scripts": { 7 + "test": "node run_tests.js", 8 + "test:headed": "node run_tests.js --headed" 9 + }, 10 + "devDependencies": { 11 + "playwright": "^1.40.0" 12 + } 13 + }
+139
x-ocaml/test/run_tests.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Playwright test runner for x-ocaml browser tests. 4 + * 5 + * Usage: 6 + * node run_tests.js [--headed] 7 + * 8 + * Serves x-ocaml build output + test HTML, runs tests in Chromium. 9 + */ 10 + 11 + const { chromium } = require('playwright'); 12 + const http = require('http'); 13 + const fs = require('fs'); 14 + const path = require('path'); 15 + 16 + const PORT = 8766; 17 + const TIMEOUT = 60000; 18 + 19 + const testDir = path.dirname(fs.realpathSync(__filename)); 20 + const xocamlDir = path.resolve(testDir, '..'); 21 + 22 + const mimeTypes = { 23 + '.html': 'text/html', 24 + '.js': 'application/javascript', 25 + '.css': 'text/css', 26 + }; 27 + 28 + function startServer() { 29 + return new Promise((resolve, reject) => { 30 + const server = http.createServer((req, res) => { 31 + let filePath = req.url === '/' ? '/test_modes.html' : req.url; 32 + 33 + // Serve from: test dir first, then x-ocaml root (for promoted JS), 34 + // then jsoo-code-mirror includes (for bundle.js) 35 + const searchPaths = [ 36 + path.join(testDir, filePath), 37 + path.join(xocamlDir, filePath), 38 + path.join(xocamlDir, 'jsoo-code-mirror/includes', filePath), 39 + ]; 40 + 41 + let fullPath = searchPaths.find(p => fs.existsSync(p)); 42 + 43 + if (!fullPath) { 44 + res.writeHead(404); 45 + res.end('Not found: ' + filePath); 46 + return; 47 + } 48 + 49 + const ext = path.extname(fullPath); 50 + const contentType = mimeTypes[ext] || 'application/octet-stream'; 51 + 52 + fs.readFile(fullPath, (err, content) => { 53 + if (err) { 54 + res.writeHead(500); 55 + res.end('Error reading file'); 56 + return; 57 + } 58 + res.writeHead(200, { 'Content-Type': contentType }); 59 + res.end(content); 60 + }); 61 + }); 62 + 63 + server.listen(PORT, () => { 64 + console.log(`Test server running at http://localhost:${PORT}/`); 65 + resolve(server); 66 + }); 67 + 68 + server.on('error', reject); 69 + }); 70 + } 71 + 72 + async function runTests(headed = false) { 73 + let server; 74 + let browser; 75 + let exitCode = 0; 76 + 77 + try { 78 + server = await startServer(); 79 + 80 + browser = await chromium.launch({ headless: !headed }); 81 + const page = await browser.newPage(); 82 + 83 + const logs = []; 84 + page.on('console', msg => { 85 + const text = msg.text(); 86 + logs.push(text); 87 + console.log(`[browser] ${text}`); 88 + }); 89 + 90 + page.on('pageerror', err => { 91 + console.error(`[browser error] ${err.message}`); 92 + }); 93 + 94 + console.log('Loading test page...'); 95 + await page.goto(`http://localhost:${PORT}/`); 96 + 97 + console.log('Waiting for tests to complete...'); 98 + await page.waitForFunction( 99 + () => window.testResults && window.testResults.done, 100 + { timeout: TIMEOUT } 101 + ); 102 + 103 + const testResults = await page.evaluate(() => ({ 104 + total: window.testResults.total, 105 + passed: window.testResults.passed, 106 + failed: window.testResults.failed, 107 + details: window.testResults.details || [], 108 + })); 109 + 110 + console.log('\n========================================'); 111 + console.log(`Test Results: ${testResults.passed}/${testResults.total} passed`); 112 + if (testResults.details.length > 0) { 113 + for (const d of testResults.details) { 114 + const icon = d.ok ? 'PASS' : 'FAIL'; 115 + console.log(` [${icon}] ${d.name}`); 116 + } 117 + } 118 + console.log('========================================\n'); 119 + 120 + if (testResults.failed > 0) { 121 + console.log('FAILED: Some tests did not pass'); 122 + exitCode = 1; 123 + } else { 124 + console.log('SUCCESS: All tests passed'); 125 + } 126 + 127 + } catch (err) { 128 + console.error('Error running tests:', err.message); 129 + exitCode = 1; 130 + } finally { 131 + if (browser) await browser.close(); 132 + if (server) server.close(); 133 + } 134 + 135 + process.exit(exitCode); 136 + } 137 + 138 + const headed = process.argv.includes('--headed'); 139 + runTests(headed);
+222
x-ocaml/test/test_modes.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>x-ocaml Cell Modes Tests</title> 5 + <style> 6 + body { font-family: monospace; padding: 20px; max-width: 80ch; } 7 + .test-section { border: 1px solid #ccc; padding: 10px; margin: 10px 0; } 8 + .test-section h3 { margin-top: 0; } 9 + #status { margin-bottom: 20px; font-size: 1.2em; } 10 + #log { white-space: pre-wrap; } 11 + .pass { color: green; } 12 + .fail { color: red; } 13 + </style> 14 + </head> 15 + <body> 16 + <h1>x-ocaml Cell Modes Tests</h1> 17 + <div id="status">Loading...</div> 18 + <pre id="log"></pre> 19 + 20 + <!-- T1: Hidden cell — not visible, definitions available --> 21 + <div class="test-section" id="section-t1"> 22 + <h3>T1: Hidden cell</h3> 23 + <x-ocaml mode="hidden">let secret = 42</x-ocaml> 24 + <x-ocaml mode="interactive">let t1_result = secret + 1</x-ocaml> 25 + </div> 26 + 27 + <!-- T2: Interactive cell — visible, read-only --> 28 + <div class="test-section" id="section-t2"> 29 + <h3>T2: Interactive cell (read-only)</h3> 30 + <x-ocaml mode="interactive">let t2_x = 1 + 2</x-ocaml> 31 + </div> 32 + 33 + <!-- T3: Exercise cell — visible, editable --> 34 + <div class="test-section" id="section-t3"> 35 + <h3>T3: Exercise cell (editable)</h3> 36 + <x-ocaml mode="exercise" data-id="t3ex">let double x = x * 2</x-ocaml> 37 + </div> 38 + 39 + <!-- T4: Test cell — visible, read-only --> 40 + <div class="test-section" id="section-t4"> 41 + <h3>T4: Test cell (read-only)</h3> 42 + <x-ocaml mode="test" data-for="t3ex">assert (double 5 = 10)</x-ocaml> 43 + </div> 44 + 45 + <!-- T5: Default mode — no mode attribute defaults to interactive --> 46 + <div class="test-section" id="section-t5"> 47 + <h3>T5: Default mode (no attribute)</h3> 48 + <x-ocaml>let t5_default = 99</x-ocaml> 49 + </div> 50 + 51 + <!-- T6: Per-cell merlin disable --> 52 + <div class="test-section" id="section-t6"> 53 + <h3>T6: Merlin disabled</h3> 54 + <x-ocaml mode="exercise" data-merlin="false">let t6_no_merlin = 1</x-ocaml> 55 + </div> 56 + 57 + <!-- T7: Hidden → Exercise → Test chain (full assessment pattern) --> 58 + <div class="test-section" id="section-t7"> 59 + <h3>T7: Full chain (hidden → exercise → test)</h3> 60 + <x-ocaml mode="hidden">let check_positive f = assert (f 1 > 0)</x-ocaml> 61 + <x-ocaml mode="exercise" data-id="factorial"> 62 + let rec facr n = if n <= 1 then 1 else n * facr (n - 1) 63 + </x-ocaml> 64 + <x-ocaml mode="test" data-for="factorial"> 65 + assert (facr 10 = 3628800);; 66 + check_positive facr 67 + </x-ocaml> 68 + </div> 69 + 70 + <!-- Load x-ocaml (includes CodeMirror bundle) with builtin backend --> 71 + <script src="x-ocaml.js" 72 + backend="builtin" 73 + src-worker="x-ocaml.worker.js" 74 + run-on="load"> 75 + </script> 76 + 77 + <!-- Test assertions run after cells have initialised and executed --> 78 + <script> 79 + window.testResults = { total: 0, passed: 0, failed: 0, done: false, details: [] }; 80 + 81 + function assert(condition, name) { 82 + window.testResults.total++; 83 + if (condition) { 84 + window.testResults.passed++; 85 + window.testResults.details.push({ name, ok: true }); 86 + console.log('PASS: ' + name); 87 + } else { 88 + window.testResults.failed++; 89 + window.testResults.details.push({ name, ok: false }); 90 + console.log('FAIL: ' + name); 91 + } 92 + } 93 + 94 + function getCells() { 95 + return Array.from(document.querySelectorAll('x-ocaml')); 96 + } 97 + 98 + function hasShadowContent(el) { 99 + const shadow = el.shadowRoot; 100 + if (!shadow) return false; 101 + // Hidden cells have an empty shadow DOM (just the shadow root) 102 + return shadow.children.length > 0; 103 + } 104 + 105 + function hasEditor(el) { 106 + const shadow = el.shadowRoot; 107 + if (!shadow) return false; 108 + return shadow.querySelector('.cm-editor') !== null; 109 + } 110 + 111 + function isReadOnly(el) { 112 + const shadow = el.shadowRoot; 113 + if (!shadow) return false; 114 + const editor = shadow.querySelector('.cm-editor'); 115 + if (!editor) return false; 116 + // CodeMirror 6 readOnly facet sets aria-readonly="true" on .cm-content 117 + return editor.querySelector('.cm-content')?.getAttribute('aria-readonly') === 'true'; 118 + } 119 + 120 + function hasOutput(el) { 121 + const shadow = el.shadowRoot; 122 + if (!shadow) return false; 123 + // Check for output widgets (rendered as .caml_stdout, .caml_stderr, .caml_meta) 124 + return shadow.querySelector('.caml_stdout, .caml_stderr, .caml_meta') !== null; 125 + } 126 + 127 + // Wait for cells to initialise (connectedCallback uses setTimeout) 128 + // and for auto-run to complete 129 + function waitForResults(maxWait) { 130 + return new Promise((resolve) => { 131 + const start = Date.now(); 132 + const check = () => { 133 + const cells = getCells(); 134 + const allCells = cells.length; 135 + 136 + // Check if we have the expected number of cells 137 + if (allCells < 10) { 138 + // Not all cells registered yet 139 + if (Date.now() - start < maxWait) { 140 + setTimeout(check, 200); 141 + } else { 142 + resolve(false); 143 + } 144 + return; 145 + } 146 + 147 + // Check if visible cells that should auto-run have output 148 + // Give cells time to evaluate 149 + if (Date.now() - start < 3000) { 150 + setTimeout(check, 200); 151 + return; 152 + } 153 + 154 + resolve(true); 155 + }; 156 + setTimeout(check, 500); 157 + }); 158 + } 159 + 160 + async function runAllTests() { 161 + const ready = await waitForResults(30000); 162 + if (!ready) { 163 + console.log('WARNING: Timed out waiting for cells'); 164 + } 165 + 166 + const cells = getCells(); 167 + console.log('Total x-ocaml elements: ' + cells.length); 168 + 169 + // T1: Hidden cell — not visible 170 + const t1_hidden = cells[0]; // first cell in section-t1 171 + const t1_interactive = cells[1]; 172 + assert(!hasShadowContent(t1_hidden), 'T1: hidden cell has no visible content'); 173 + assert(hasEditor(t1_interactive), 'T1: interactive cell after hidden has editor'); 174 + 175 + // T2: Interactive cell — visible, read-only 176 + const t2 = cells[2]; 177 + assert(hasEditor(t2), 'T2: interactive cell has editor'); 178 + assert(isReadOnly(t2), 'T2: interactive cell is read-only'); 179 + 180 + // T3: Exercise cell — visible, editable 181 + const t3 = cells[3]; 182 + assert(hasEditor(t3), 'T3: exercise cell has editor'); 183 + assert(!isReadOnly(t3), 'T3: exercise cell is editable'); 184 + 185 + // T4: Test cell — visible, read-only 186 + const t4 = cells[4]; 187 + assert(hasEditor(t4), 'T4: test cell has editor'); 188 + assert(isReadOnly(t4), 'T4: test cell is read-only'); 189 + 190 + // T5: Default mode — should be interactive (visible, read-only) 191 + const t5 = cells[5]; 192 + assert(hasEditor(t5), 'T5: default mode cell has editor'); 193 + assert(isReadOnly(t5), 'T5: default mode cell is read-only'); 194 + 195 + // T6: Exercise with merlin disabled — still editable 196 + const t6 = cells[6]; 197 + assert(hasEditor(t6), 'T6: merlin-disabled cell has editor'); 198 + assert(!isReadOnly(t6), 'T6: merlin-disabled exercise cell is editable'); 199 + 200 + // T7: Full chain — hidden, exercise, test 201 + const t7_hidden = cells[7]; 202 + const t7_exercise = cells[8]; 203 + const t7_test = cells[9]; 204 + assert(!hasShadowContent(t7_hidden), 'T7: chain hidden cell has no visible content'); 205 + assert(hasEditor(t7_exercise), 'T7: chain exercise cell has editor'); 206 + assert(!isReadOnly(t7_exercise), 'T7: chain exercise cell is editable'); 207 + assert(hasEditor(t7_test), 'T7: chain test cell has editor'); 208 + assert(isReadOnly(t7_test), 'T7: chain test cell is read-only'); 209 + 210 + window.testResults.done = true; 211 + const status = document.getElementById('status'); 212 + if (window.testResults.failed === 0) { 213 + status.innerHTML = '<span class="pass">All ' + window.testResults.passed + ' tests passed!</span>'; 214 + } else { 215 + status.innerHTML = '<span class="fail">' + window.testResults.failed + ' tests failed</span> (' + window.testResults.passed + ' passed)'; 216 + } 217 + } 218 + 219 + runAllTests(); 220 + </script> 221 + </body> 222 + </html>