Skip to content

Commit 7f6323e

Browse files
committed
Expand web_ui widget contracts and integration tests
1 parent 6b227ab commit 7f6323e

5 files changed

Lines changed: 154 additions & 2 deletions

File tree

packages/web_ui/lib/web_ui/info.ex

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule WebUi.Info do
1010
namespace: WebUi,
1111
package_areas: WebUi.package_areas(),
1212
validation_state: WebUi.Runtime.validation_state(),
13+
bridge: bridge_summary(),
1314
tooling: %{
1415
workflows: WebUi.Tooling.workflows(),
1516
mix_tasks: WebUi.Tooling.mix_tasks()
@@ -26,4 +27,27 @@ defmodule WebUi.Info do
2627
responsibilities: WebUi.Renderer.responsibilities()
2728
}
2829
end
30+
31+
@spec widget_summary(WebUi.Widget.t()) :: map()
32+
def widget_summary(%WebUi.Widget{} = widget) do
33+
%{
34+
id: widget.id,
35+
family: widget.family,
36+
kind: widget.kind,
37+
metadata: widget.metadata,
38+
state: widget.state,
39+
slots: widget.slots,
40+
style_keys: Map.keys(widget.styles),
41+
event_keys: Map.keys(widget.events)
42+
}
43+
end
44+
45+
@spec bridge_summary() :: map()
46+
def bridge_summary do
47+
%{
48+
boundaries: WebUi.Reference.browser_bridge_boundaries(),
49+
frontend_modules: WebUi.Frontend.modules(),
50+
server_modules: WebUi.Server.modules()
51+
}
52+
end
2953
end

packages/web_ui/lib/web_ui/reference.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ defmodule WebUi.Reference do
88
WebUi.Widgets.families()
99
end
1010

11+
@spec widget_contract() :: map()
12+
def widget_contract do
13+
WebUi.Widget.contract()
14+
end
15+
1116
@spec runtime_modules() :: [module()]
1217
def runtime_modules do
1318
WebUi.Runtime.modules()
@@ -31,18 +36,25 @@ defmodule WebUi.Reference do
3136
WebUi.Runtime.assumptions()
3237
end
3338

39+
@spec browser_bridge_boundaries() :: [atom()]
40+
def browser_bridge_boundaries do
41+
[:hydration_envelope, :event_envelope, :acknowledgement]
42+
end
43+
3444
@spec package_reference() :: map()
3545
def package_reference do
3646
%{
3747
package: WebUi,
3848
widgets: %{
3949
families: widget_families(),
40-
modules: WebUi.Widgets.modules()
50+
modules: WebUi.Widgets.modules(),
51+
contract: widget_contract()
4152
},
4253
runtime: %{
4354
capabilities: WebUi.Runtime.capabilities(),
4455
modules: runtime_modules(),
45-
assumptions: runtime_assumptions()
56+
assumptions: runtime_assumptions(),
57+
bridge_boundaries: browser_bridge_boundaries()
4658
},
4759
renderer: %{
4860
accepts: WebUi.Renderer.accepts(),

packages/web_ui/lib/web_ui/widget.ex

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ defmodule WebUi.Widget do
99
id: String.t() | atom() | nil,
1010
family: family(),
1111
kind: atom(),
12+
metadata: map(),
13+
state: map(),
14+
slots: [atom() | String.t()],
1215
attributes: map(),
1316
styles: map(),
1417
events: map(),
@@ -18,11 +21,25 @@ defmodule WebUi.Widget do
1821
defstruct id: nil,
1922
family: :content,
2023
kind: :text,
24+
metadata: %{},
25+
state: %{},
26+
slots: [:default],
2127
attributes: %{},
2228
styles: %{},
2329
events: %{},
2430
children: []
2531

32+
@spec contract() :: map()
33+
def contract do
34+
%{
35+
metadata: [:label, :description, :role, :variant],
36+
state: [:disabled, :selected, :expanded, :focused],
37+
slots: [:default],
38+
styles: [:tone, :size, :spacing, :surface],
39+
events: [:click, :change, :submit, :navigation]
40+
}
41+
end
42+
2643
@spec new(atom(), keyword() | map()) :: t()
2744
def new(kind, attrs \\ %{}) when is_atom(kind) do
2845
attrs = normalize_map(attrs)
@@ -31,6 +48,9 @@ defmodule WebUi.Widget do
3148
id: Map.get(attrs, :id) || Map.get(attrs, "id"),
3249
family: Map.get(attrs, :family) || Map.get(attrs, "family") || family_for(kind),
3350
kind: kind,
51+
metadata: normalize_map(Map.get(attrs, :metadata) || Map.get(attrs, "metadata")),
52+
state: normalize_map(Map.get(attrs, :state) || Map.get(attrs, "state")),
53+
slots: normalize_slots(Map.get(attrs, :slots) || Map.get(attrs, "slots") || [:default]),
3454
attributes: normalize_map(Map.get(attrs, :attributes) || Map.get(attrs, "attributes")),
3555
styles: normalize_map(Map.get(attrs, :styles) || Map.get(attrs, "styles")),
3656
events: normalize_map(Map.get(attrs, :events) || Map.get(attrs, "events")),
@@ -48,17 +68,30 @@ defmodule WebUi.Widget do
4868
%{widget | styles: Map.put(widget.styles, key, value)}
4969
end
5070

71+
@spec put_state(t(), atom() | String.t(), term()) :: t()
72+
def put_state(%__MODULE__{} = widget, key, value) do
73+
%{widget | state: Map.put(widget.state, key, value)}
74+
end
75+
5176
@spec put_event(t(), atom() | String.t(), map()) :: t()
5277
def put_event(%__MODULE__{} = widget, key, value) when is_map(value) do
5378
%{widget | events: Map.put(widget.events, key, value)}
5479
end
5580

81+
@spec put_slot(t(), atom() | String.t()) :: t()
82+
def put_slot(%__MODULE__{} = widget, slot) when is_atom(slot) or is_binary(slot) do
83+
%{widget | slots: Enum.uniq(widget.slots ++ [slot])}
84+
end
85+
5686
@spec serialize(t()) :: map()
5787
def serialize(%__MODULE__{} = widget) do
5888
%{
5989
id: widget.id,
6090
family: widget.family,
6191
kind: widget.kind,
92+
metadata: widget.metadata,
93+
state: widget.state,
94+
slots: widget.slots,
6295
attributes: widget.attributes,
6396
styles: widget.styles,
6497
events: widget.events,
@@ -75,6 +108,9 @@ defmodule WebUi.Widget do
75108
defp normalize_map(map) when is_map(map), do: Map.new(map)
76109
defp normalize_map(list) when is_list(list), do: Enum.into(list, %{})
77110

111+
defp normalize_slots(slots) when is_list(slots), do: slots
112+
defp normalize_slots(_slots), do: [:default]
113+
78114
defp normalize_children(children) when is_list(children) do
79115
Enum.map(children, fn
80116
%__MODULE__{} = child ->

packages/web_ui/lib/web_ui/widgets.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule WebUi.Widgets do
2121
def text(id, content, opts \\ []) do
2222
Widget.new(:text,
2323
id: id,
24+
metadata: %{label: "text"},
2425
attributes: %{content: content},
2526
styles: Keyword.get(opts, :styles, %{})
2627
)
@@ -30,6 +31,8 @@ defmodule WebUi.Widgets do
3031
def button(id, label, opts \\ []) do
3132
Widget.new(:button,
3233
id: id,
34+
metadata: %{label: label, role: :action},
35+
state: %{disabled: Keyword.get(opts, :disabled, false)},
3336
attributes: %{label: label},
3437
styles: Keyword.get(opts, :styles, %{}),
3538
events: build_events(opts)
@@ -40,6 +43,8 @@ defmodule WebUi.Widgets do
4043
def stack(id, children, opts \\ []) do
4144
Widget.new(:stack,
4245
id: id,
46+
metadata: %{label: "stack"},
47+
slots: [:default],
4348
attributes: %{direction: Keyword.get(opts, :direction, :column)},
4449
styles: Keyword.get(opts, :styles, %{}),
4550
children: children
@@ -50,6 +55,8 @@ defmodule WebUi.Widgets do
5055
def panel(id, title, children, opts \\ []) do
5156
Widget.new(:panel,
5257
id: id,
58+
metadata: %{label: title, role: :panel},
59+
slots: [:header, :body],
5360
attributes: %{title: title},
5461
styles: Keyword.get(opts, :styles, %{}),
5562
children: children
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
defmodule WebUi.PhaseOneIntegrationTest do
2+
use ExUnit.Case, async: true
3+
4+
alias UnifiedIUR.Element
5+
alias WebUi.FrontendRuntime.{Boot, Message}
6+
alias WebUi.ServerRuntime
7+
8+
test "package and split runtime entrypoints stay available without owning application startup" do
9+
assert WebUi.server() == WebUi.Server
10+
assert WebUi.frontend() == WebUi.Frontend
11+
assert WebUi.runtime() == WebUi.Runtime
12+
assert WebUi.renderer() == WebUi.Renderer
13+
assert WebUi.transport() == WebUi.Transport
14+
end
15+
16+
test "minimal native screen mounts, hydrates, and renders through the backbone" do
17+
assert {:ok, runtime_state} =
18+
WebUi.Runtime.mount_native_screen(WebUi.Examples.native_counter_screen())
19+
20+
envelope = ServerRuntime.frontend_envelope(runtime_state)
21+
assert {:ok, frontend_model} = Boot.hydrate_message(envelope)
22+
23+
assert runtime_state.rendered_tree.kind == :stack
24+
assert frontend_model.tree.kind == :stack
25+
assert frontend_model.boundary_mode == :native_local
26+
end
27+
28+
test "canonical screens reuse the same runtime and frontend flow" do
29+
element =
30+
Element.new(:widget, :button,
31+
id: :phase_one_button,
32+
attributes: %{label: "Continue"}
33+
)
34+
35+
assert {:ok, runtime_state} = WebUi.Runtime.mount_iur_screen(element, runtime_id: "phase-one")
36+
assert {:ok, frontend_model} = WebUi.Runtime.hydrate_frontend(runtime_state)
37+
38+
assert runtime_state.boundary_mode == :canonical_boundary
39+
assert frontend_model.tree.kind == :button
40+
end
41+
42+
test "malformed widget declarations, hydration payloads, and wiring fail with deterministic diagnostics" do
43+
assert {:error, %ServerRuntime.Error{reason: :invalid_screen}} =
44+
WebUi.Runtime.mount_native_screen(%{id: :broken, title: "Broken"})
45+
46+
assert {:error, %WebUi.FrontendRuntime.Error{reason: :invalid_hydration_payload}} =
47+
Boot.hydrate(%{runtime_id: "missing"})
48+
49+
assert {:error, %ServerRuntime.Error{reason: :unsupported_frontend_message}} =
50+
ServerRuntime.receive_frontend_message(
51+
%WebUi.ServerRuntime.State{
52+
runtime_id: "phase-one",
53+
rendered_tree: WebUi.Widgets.text(:id, "ok")
54+
},
55+
Message.new(:hydrate, %{runtime_id: "phase-one"})
56+
)
57+
end
58+
59+
test "reference helpers and inspection surfaces expose phase one boundaries without renderer coupling" do
60+
reference = WebUi.reference()
61+
info = WebUi.info()
62+
63+
widget =
64+
WebUi.Widgets.button(:save, "Save",
65+
on_click: %{family: :click, intent: :save, boundary: :local}
66+
)
67+
68+
assert :hydration_envelope in reference.runtime.bridge_boundaries
69+
assert reference.widgets.contract.metadata == [:label, :description, :role, :variant]
70+
assert info.bridge.boundaries == [:hydration_envelope, :event_envelope, :acknowledgement]
71+
assert WebUi.Info.widget_summary(widget).event_keys == [:click]
72+
end
73+
end

0 commit comments

Comments
 (0)