@loadbare/app 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +95 -0
  3. package/dist/build/assemble.d.ts +31 -0
  4. package/dist/build/assemble.d.ts.map +1 -0
  5. package/dist/build/assemble.js +53 -0
  6. package/dist/build/cli.d.ts +30 -0
  7. package/dist/build/cli.d.ts.map +1 -0
  8. package/dist/build/cli.js +107 -0
  9. package/dist/build/elements.d.ts +61 -0
  10. package/dist/build/elements.d.ts.map +1 -0
  11. package/dist/build/elements.js +158 -0
  12. package/dist/build/expand.d.ts +45 -0
  13. package/dist/build/expand.d.ts.map +1 -0
  14. package/dist/build/expand.js +386 -0
  15. package/dist/build/format.d.ts +28 -0
  16. package/dist/build/format.d.ts.map +1 -0
  17. package/dist/build/format.js +42 -0
  18. package/dist/build/locations.d.ts +87 -0
  19. package/dist/build/locations.d.ts.map +1 -0
  20. package/dist/build/locations.js +173 -0
  21. package/dist/build/package-root.d.ts +9 -0
  22. package/dist/build/package-root.d.ts.map +1 -0
  23. package/dist/build/package-root.js +24 -0
  24. package/dist/build/pages.d.ts +25 -0
  25. package/dist/build/pages.d.ts.map +1 -0
  26. package/dist/build/pages.js +54 -0
  27. package/dist/build/styles.d.ts +13 -0
  28. package/dist/build/styles.d.ts.map +1 -0
  29. package/dist/build/styles.js +18 -0
  30. package/dist/client.js +522 -0
  31. package/dist/core/lb-constants.d.ts +23 -0
  32. package/dist/core/lb-constants.d.ts.map +1 -0
  33. package/dist/core/lb-constants.js +95 -0
  34. package/dist/core/lb-types.d.ts +88 -0
  35. package/dist/core/lb-types.d.ts.map +1 -0
  36. package/dist/core/lb-types.js +5 -0
  37. package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
  38. package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
  39. package/dist/demo-static/src/widgets/app-box.js +19 -0
  40. package/dist/hub/lb-apply.d.ts +13 -0
  41. package/dist/hub/lb-apply.d.ts.map +1 -0
  42. package/dist/hub/lb-apply.js +77 -0
  43. package/dist/hub/lb-hub.d.ts +2 -0
  44. package/dist/hub/lb-hub.d.ts.map +1 -0
  45. package/dist/hub/lb-hub.js +242 -0
  46. package/dist/hub/lb-rows.d.ts +18 -0
  47. package/dist/hub/lb-rows.d.ts.map +1 -0
  48. package/dist/hub/lb-rows.js +106 -0
  49. package/dist/server/lb-express.d.ts +28 -0
  50. package/dist/server/lb-express.d.ts.map +1 -0
  51. package/dist/server/lb-express.js +77 -0
  52. package/dist/server/lb-server.d.ts +174 -0
  53. package/dist/server/lb-server.d.ts.map +1 -0
  54. package/dist/server/lb-server.js +79 -0
  55. package/dist/tests/assemble.test.d.ts +8 -0
  56. package/dist/tests/assemble.test.d.ts.map +1 -0
  57. package/dist/tests/assemble.test.js +51 -0
  58. package/dist/tests/elements.test.d.ts +8 -0
  59. package/dist/tests/elements.test.d.ts.map +1 -0
  60. package/dist/tests/elements.test.js +111 -0
  61. package/dist/tests/expand.test.d.ts +10 -0
  62. package/dist/tests/expand.test.d.ts.map +1 -0
  63. package/dist/tests/expand.test.js +226 -0
  64. package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
  65. package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
  66. package/dist/tests/fixtures/elements/collision/elements.js +3 -0
  67. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
  68. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
  69. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
  70. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
  71. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
  72. package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
  73. package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
  74. package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
  75. package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
  76. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
  77. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
  78. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
  79. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
  80. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
  81. package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
  82. package/dist/tests/golden.test.d.ts +19 -0
  83. package/dist/tests/golden.test.d.ts.map +1 -0
  84. package/dist/tests/golden.test.js +60 -0
  85. package/dist/tests/helpers/console.d.ts +20 -0
  86. package/dist/tests/helpers/console.d.ts.map +1 -0
  87. package/dist/tests/helpers/console.js +28 -0
  88. package/dist/tests/helpers/dom.d.ts +18 -0
  89. package/dist/tests/helpers/dom.d.ts.map +1 -0
  90. package/dist/tests/helpers/dom.js +22 -0
  91. package/dist/tests/helpers/window.d.ts +43 -0
  92. package/dist/tests/helpers/window.d.ts.map +1 -0
  93. package/dist/tests/helpers/window.js +78 -0
  94. package/dist/tests/lb-apply.test.d.ts +8 -0
  95. package/dist/tests/lb-apply.test.d.ts.map +1 -0
  96. package/dist/tests/lb-apply.test.js +153 -0
  97. package/dist/tests/lb-express.test.d.ts +14 -0
  98. package/dist/tests/lb-express.test.d.ts.map +1 -0
  99. package/dist/tests/lb-express.test.js +238 -0
  100. package/dist/tests/lb-input.test.d.ts +9 -0
  101. package/dist/tests/lb-input.test.d.ts.map +1 -0
  102. package/dist/tests/lb-input.test.js +78 -0
  103. package/dist/tests/lb-list.test.d.ts +12 -0
  104. package/dist/tests/lb-list.test.d.ts.map +1 -0
  105. package/dist/tests/lb-list.test.js +44 -0
  106. package/dist/tests/lb-options.test.d.ts +10 -0
  107. package/dist/tests/lb-options.test.d.ts.map +1 -0
  108. package/dist/tests/lb-options.test.js +121 -0
  109. package/dist/tests/lb-picker.test.d.ts +14 -0
  110. package/dist/tests/lb-picker.test.d.ts.map +1 -0
  111. package/dist/tests/lb-picker.test.js +59 -0
  112. package/dist/tests/lb-rows.test.d.ts +12 -0
  113. package/dist/tests/lb-rows.test.d.ts.map +1 -0
  114. package/dist/tests/lb-rows.test.js +336 -0
  115. package/dist/tests/lb-select.test.d.ts +9 -0
  116. package/dist/tests/lb-select.test.d.ts.map +1 -0
  117. package/dist/tests/lb-select.test.js +71 -0
  118. package/dist/tests/lb-server.test.d.ts +9 -0
  119. package/dist/tests/lb-server.test.d.ts.map +1 -0
  120. package/dist/tests/lb-server.test.js +495 -0
  121. package/dist/tests/lb-table.test.d.ts +15 -0
  122. package/dist/tests/lb-table.test.d.ts.map +1 -0
  123. package/dist/tests/lb-table.test.js +205 -0
  124. package/dist/tests/pages.test.d.ts +6 -0
  125. package/dist/tests/pages.test.d.ts.map +1 -0
  126. package/dist/tests/pages.test.js +98 -0
  127. package/dist/tests/styles.test.d.ts +7 -0
  128. package/dist/tests/styles.test.d.ts.map +1 -0
  129. package/dist/tests/styles.test.js +73 -0
  130. package/dist/widgets/index.d.ts +7 -0
  131. package/dist/widgets/index.d.ts.map +1 -0
  132. package/dist/widgets/index.js +6 -0
  133. package/dist/widgets/lb-input.d.ts +2 -0
  134. package/dist/widgets/lb-input.d.ts.map +1 -0
  135. package/dist/widgets/lb-input.js +48 -0
  136. package/dist/widgets/lb-list.d.ts +2 -0
  137. package/dist/widgets/lb-list.d.ts.map +1 -0
  138. package/dist/widgets/lb-list.js +17 -0
  139. package/dist/widgets/lb-options.d.ts +26 -0
  140. package/dist/widgets/lb-options.d.ts.map +1 -0
  141. package/dist/widgets/lb-options.js +72 -0
  142. package/dist/widgets/lb-picker.d.ts +2 -0
  143. package/dist/widgets/lb-picker.d.ts.map +1 -0
  144. package/dist/widgets/lb-picker.js +25 -0
  145. package/dist/widgets/lb-select.d.ts +2 -0
  146. package/dist/widgets/lb-select.d.ts.map +1 -0
  147. package/dist/widgets/lb-select.js +43 -0
  148. package/dist/widgets/lb-table.d.ts +2 -0
  149. package/dist/widgets/lb-table.d.ts.map +1 -0
  150. package/dist/widgets/lb-table.js +113 -0
  151. package/docs/application-chrome.md +36 -0
  152. package/docs/building-html-pages.md +130 -0
  153. package/docs/getting-started.md +120 -0
  154. package/docs/guide.md +1164 -0
  155. package/docs/hosting.md +218 -0
  156. package/docs/latent-risks.md +20 -0
  157. package/docs/theory.md +226 -0
  158. package/package.json +85 -0
  159. package/widgets/index.ts +6 -0
  160. package/widgets/lb-input.html +1 -0
  161. package/widgets/lb-input.ts +64 -0
  162. package/widgets/lb-list.html +1 -0
  163. package/widgets/lb-list.ts +21 -0
  164. package/widgets/lb-options.html +4 -0
  165. package/widgets/lb-options.ts +88 -0
  166. package/widgets/lb-picker.html +7 -0
  167. package/widgets/lb-picker.ts +27 -0
  168. package/widgets/lb-select.html +4 -0
  169. package/widgets/lb-select.ts +55 -0
  170. package/widgets/lb-table.html +8 -0
  171. package/widgets/lb-table.ts +126 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Landing data on a live host — see docs/testing.md, tier 3.
3
+ *
4
+ * hub/lb-apply.ts is pure DOM: no fetch, no widget upgrade, no history.
5
+ * jsdom is real evidence for it, which is not true one file further on.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { applyData, applyTuple } from "../hub/lb-apply";
10
+ import { body, one } from "./helpers/dom";
11
+ import { captured } from "./helpers/console";
12
+ /** An element that is a list widget as far as the hub is concerned. */
13
+ function rowHost(html) {
14
+ const el = one(html);
15
+ el.seen = [];
16
+ el.acceptRows = (result) => void el.seen.push(result);
17
+ return el;
18
+ }
19
+ // --- One tuple into one scope -------------------------------------------
20
+ describe("applyTuple", () => {
21
+ it("gives a native element its value as text", () => {
22
+ const scope = one(`<div><span lb-cell="name"></span></div>`);
23
+ applyTuple(scope, { name: "Ada" });
24
+ assert.equal(scope.querySelector("span").textContent, "Ada");
25
+ });
26
+ it("gives a widget its value as an attribute", () => {
27
+ // A custom element owns whatever control it wraps, so it receives the
28
+ // value and renders it itself. The test is lexical — a hyphen in the tag
29
+ // name — and the hub holds no knowledge of any particular element.
30
+ const scope = one(`<div><lb-input lb-cell="name"></lb-input></div>`);
31
+ applyTuple(scope, { name: "Ada" });
32
+ const widget = scope.querySelector("lb-input");
33
+ assert.equal(widget.getAttribute("lb-value"), "Ada");
34
+ assert.equal(widget.textContent, "", "the widget's children are its own");
35
+ });
36
+ it("counts the root as a cell when it carries one", () => {
37
+ // <option>'s content model is text, so an option row has to be the cell
38
+ // it displays. Requiring a wrapper would require an element HTML does
39
+ // not allow.
40
+ const scope = one(`<option lb-cell="name"></option>`);
41
+ applyTuple(scope, { name: "Ada" });
42
+ assert.equal(scope.textContent, "Ada");
43
+ });
44
+ it("fills every element naming the same cell", () => {
45
+ const scope = one(`<div><b lb-cell="name"></b><i lb-cell="name"></i></div>`);
46
+ applyTuple(scope, { name: "Ada" });
47
+ assert.equal(scope.querySelector("b").textContent, "Ada");
48
+ assert.equal(scope.querySelector("i").textContent, "Ada");
49
+ });
50
+ it("empties a native root that is a cell, children and all", () => {
51
+ // A root that is a cell is a leaf, and a native one lands its value as
52
+ // text — so the children go, because setting textContent is what
53
+ // replacing an element's content means.
54
+ //
55
+ // The case this is for cannot hit it: <option> is a leaf by content
56
+ // model. But nothing stops an author writing a cell on a <tr> that has
57
+ // cells inside it, and this is what they would get.
58
+ const scope = body(`<table><tbody><tr lb-cell="line"><td lb-cell="amount"></td></tr></tbody></table>`).querySelector("tr");
59
+ applyTuple(scope, { line: "Total", amount: "10" });
60
+ assert.equal(scope.textContent, "Total");
61
+ assert.equal(scope.children.length, 0, "the amount cell is gone");
62
+ });
63
+ it("keeps the children of a widget root that is a cell", () => {
64
+ // The same shape with a hyphen in the tag name is unharmed, because a
65
+ // widget receives its value as an attribute and renders it itself. The
66
+ // asymmetry follows from what a cell means on each kind of element.
67
+ const scope = one(`<lb-input lb-cell="line"><input /></lb-input>`);
68
+ applyTuple(scope, { line: "Total" });
69
+ assert.equal(scope.getAttribute("lb-value"), "Total");
70
+ assert.equal(scope.children.length, 1);
71
+ });
72
+ it("leaves a cell the tuple did not name alone", () => {
73
+ const scope = one(`<div><span lb-cell="name">Ada</span><span lb-cell="role">lead</span></div>`);
74
+ applyTuple(scope, { role: "staff" });
75
+ assert.equal(scope.querySelector(`[lb-cell="name"]`).textContent, "Ada");
76
+ assert.equal(scope.querySelector(`[lb-cell="role"]`).textContent, "staff");
77
+ });
78
+ it("says nothing about a cell with nowhere to land", () => {
79
+ const scope = one(`<div><span lb-cell="name"></span></div>`);
80
+ assert.doesNotThrow(() => applyTuple(scope, { absent: "x" }));
81
+ });
82
+ it("lands a value as text, so data cannot become markup", () => {
83
+ // The same guarantee expansion makes at build time, made here at run
84
+ // time and by the same means: the DOM API rather than a string.
85
+ const scope = one(`<div><span lb-cell="name"></span></div>`);
86
+ applyTuple(scope, { name: `<img src=x onerror=alert(1)>` });
87
+ assert.equal(scope.querySelectorAll("img").length, 0);
88
+ assert.equal(scope.querySelector("span").textContent, `<img src=x onerror=alert(1)>`);
89
+ });
90
+ it("lands the empty string as the empty string", () => {
91
+ const scope = one(`<div><span lb-cell="name">Ada</span></div>`);
92
+ applyTuple(scope, { name: "" });
93
+ assert.equal(scope.querySelector("span").textContent, "");
94
+ });
95
+ });
96
+ // --- A whole response ----------------------------------------------------
97
+ describe("applyData", () => {
98
+ it("lands each result in the scope its query names", async () => {
99
+ const root = body(`<div lb-query="greeting"><span lb-cell="text"></span></div>` +
100
+ `<div lb-query="count"><span lb-cell="n"></span></div>`);
101
+ applyData(root, { greeting: { text: "hello" }, count: { n: "3" } });
102
+ assert.equal(root.querySelector(`[lb-query="greeting"] span`).textContent, "hello");
103
+ assert.equal(root.querySelector(`[lb-query="count"] span`).textContent, "3");
104
+ });
105
+ it("fills every scope naming the same query", () => {
106
+ // Two projections of one query on one page: the hub resolves by name and
107
+ // does not care how many places asked.
108
+ const root = body(`<div lb-query="total"><span lb-cell="n"></span></div>` +
109
+ `<p lb-query="total"><span lb-cell="n"></span></p>`);
110
+ applyData(root, { total: { n: "7" } });
111
+ for (const el of root.querySelectorAll("span")) {
112
+ assert.equal(el.textContent, "7");
113
+ }
114
+ });
115
+ it("reports a query with no scope, and lands the rest anyway", async () => {
116
+ const root = body(`<div lb-query="count"><span lb-cell="n"></span></div>`);
117
+ const { warn } = await captured(() => applyData(root, { ghost: { x: "1" }, count: { n: "3" } }));
118
+ assert.match(warn.join("\n"), /no scope for query 'ghost', skipping/);
119
+ assert.equal(root.querySelector("span").textContent, "3");
120
+ });
121
+ it("hands a projection to the widget, whole", () => {
122
+ const host = rowHost(`<lb-list lb-query="members"></lb-list>`);
123
+ const root = body("");
124
+ root.append(host);
125
+ const result = { op: "rows", rows: [{ id: "1" }] };
126
+ applyData(root, { members: result });
127
+ assert.deepEqual(host.seen, [result]);
128
+ });
129
+ it("reports a projection landing on something that is not a list", async () => {
130
+ // A native element has one destination for a value and no way to acquire
131
+ // children, so a list is not something it can be asked to show.
132
+ const root = body(`<div lb-query="members"></div>`);
133
+ const { error } = await captured(() => applyData(root, { members: { op: "rows", rows: [{ id: "1" }] } }));
134
+ assert.match(error.join("\n"), /query 'members' returned rows, but <div> is not a list widget/);
135
+ });
136
+ it("reads a tuple carrying a cell called op as a projection", async () => {
137
+ // The reserved name, from the browser's side. Stated in
138
+ // core/lb-types.ts and worth failing loudly if it ever moves.
139
+ const root = body(`<div lb-query="members"></div>`);
140
+ const { error } = await captured(() => applyData(root, { members: { op: "rows" } }));
141
+ assert.match(error.join("\n"), /is not a list widget/);
142
+ });
143
+ it("lands a tuple whose op cell is not a projection word", () => {
144
+ const root = body(`<div lb-query="row"><span lb-cell="op"></span></div>`);
145
+ applyData(root, { row: { op: "deleted" } });
146
+ assert.equal(root.querySelector("span").textContent, "deleted");
147
+ });
148
+ it("does nothing with an empty response", () => {
149
+ const root = body(`<div lb-query="count"><span lb-cell="n">3</span></div>`);
150
+ applyData(root, {});
151
+ assert.equal(root.querySelector("span").textContent, "3");
152
+ });
153
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The Express adapter — see docs/testing.md, tier 2.
3
+ *
4
+ * server/lb-express.ts is the only file that knows both HTTP and Hub. What
5
+ * it owes is small and exact: two endpoints and no third, the page from the
6
+ * query string, and an operation it does not yet handle refused rather than
7
+ * half-handled.
8
+ *
9
+ * It is tested against a listening server rather than a mocked request,
10
+ * because the things it can get wrong — a body that never parsed, a route
11
+ * that swallows another — are things a mock would agree with.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=lb-express.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-express.test.d.ts","sourceRoot":"","sources":["../../tests/lb-express.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The Express adapter — see docs/testing.md, tier 2.
3
+ *
4
+ * server/lb-express.ts is the only file that knows both HTTP and Hub. What
5
+ * it owes is small and exact: two endpoints and no third, the page from the
6
+ * query string, and an operation it does not yet handle refused rather than
7
+ * half-handled.
8
+ *
9
+ * It is tested against a listening server rather than a mocked request,
10
+ * because the things it can get wrong — a body that never parsed, a route
11
+ * that swallows another — are things a mock would agree with.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { after, before, describe, it } from "node:test";
15
+ import express from "express";
16
+ import { LB_DATA_ENDPOINT, LB_REQUEST_ENDPOINT } from "../core/lb-constants";
17
+ import { hubRoutes } from "../server/lb-express";
18
+ import { captured } from "./helpers/console";
19
+ const calls = [];
20
+ let contexts = 0;
21
+ const engine = {
22
+ async dataForPage(page, ctx) {
23
+ calls.push({ kind: "data", page, ctx });
24
+ return { greeting: { text: "hello" } };
25
+ },
26
+ async runAction(page, name, where, ctx) {
27
+ calls.push({ kind: "action", page, name, where, ctx });
28
+ if (name === "explode")
29
+ throw new Error("application code failed");
30
+ return { count: { n: "2" } };
31
+ },
32
+ async runCellChange(page, where, ctx) {
33
+ calls.push({ kind: "cell-change", page, where, ctx });
34
+ return { members: { n: "3" } };
35
+ },
36
+ async runTupleDelete(page, where, ctx) {
37
+ calls.push({ kind: "tuple-delete", page, where, ctx });
38
+ return { members: { n: "4" } };
39
+ },
40
+ async runTupleInsert(page, where, ctx) {
41
+ calls.push({ kind: "tuple-insert", page, where, ctx });
42
+ return { members: { n: "5" } };
43
+ },
44
+ async runTupleUpdate(page, where, ctx) {
45
+ calls.push({ kind: "tuple-update", page, where, ctx });
46
+ return { members: { n: "6" } };
47
+ },
48
+ };
49
+ let base;
50
+ let server;
51
+ before(async () => {
52
+ const app = express();
53
+ app.use(hubRoutes(engine, () => {
54
+ contexts += 1;
55
+ return {};
56
+ }));
57
+ await new Promise((done) => {
58
+ server = app.listen(0, "127.0.0.1", () => done());
59
+ });
60
+ base = `http://127.0.0.1:${server.address().port}`;
61
+ });
62
+ after(() => void server.close());
63
+ function post(page, body, raw) {
64
+ return fetch(`${base}${LB_REQUEST_ENDPOINT}?page=${page}`, {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: raw ?? JSON.stringify(body),
68
+ });
69
+ }
70
+ describe("the data endpoint", () => {
71
+ it("runs the page named in the query string", async () => {
72
+ calls.length = 0;
73
+ const res = await fetch(`${base}${LB_DATA_ENDPOINT}?page=hello`);
74
+ assert.equal(res.status, 200);
75
+ assert.deepEqual(await res.json(), { greeting: { text: "hello" } });
76
+ assert.equal(calls.length, 1);
77
+ assert.equal(calls[0].kind, "data");
78
+ assert.equal(calls[0].page, "hello");
79
+ });
80
+ it("names the empty page when the query string does not", async () => {
81
+ // The engine answers for a page it does not have; the route's job is only
82
+ // to pass on what it was given rather than to invent a default.
83
+ calls.length = 0;
84
+ await fetch(`${base}${LB_DATA_ENDPOINT}`);
85
+ assert.equal(calls[0].page, "");
86
+ });
87
+ });
88
+ describe("the request endpoint", () => {
89
+ it("runs the action, with where the interaction happened", async () => {
90
+ calls.length = 0;
91
+ const res = await post("roster", {
92
+ op: "action",
93
+ name: "pick",
94
+ query: "members",
95
+ key: "7",
96
+ cell: "role",
97
+ value: "lead",
98
+ });
99
+ assert.equal(res.status, 200);
100
+ assert.deepEqual(await res.json(), { count: { n: "2" } });
101
+ assert.deepEqual(calls[0].where, {
102
+ query: "members",
103
+ key: "7",
104
+ cell: "role",
105
+ value: "lead",
106
+ });
107
+ assert.equal(calls[0].name, "pick");
108
+ assert.equal(calls[0].page, "roster");
109
+ });
110
+ it("passes on an action carrying no coordinates at all", async () => {
111
+ // A button carries no value and may sit outside any scope. Undefined
112
+ // stays undefined rather than becoming the empty string.
113
+ calls.length = 0;
114
+ await post("counter", { op: "action", name: "bump" });
115
+ assert.deepEqual(calls[0].where, {
116
+ query: undefined,
117
+ key: undefined,
118
+ cell: undefined,
119
+ value: undefined,
120
+ });
121
+ });
122
+ it("refuses an op it does not recognise, rather than guessing", async () => {
123
+ calls.length = 0;
124
+ const { result: res, warn } = await captured(() => post("roster", { op: "tuple-upsert", query: "members", key: "1" }));
125
+ assert.equal(res.status, 400);
126
+ assert.deepEqual(await res.json(), {});
127
+ assert.deepEqual(calls, [], "the engine was never reached");
128
+ assert.match(warn.join("\n"), /operation 'tuple-upsert' not implemented/);
129
+ });
130
+ it("runs a cell-change, with where the interaction happened", async () => {
131
+ calls.length = 0;
132
+ const res = await post("roster", {
133
+ op: "cell-change",
134
+ query: "members",
135
+ key: "7",
136
+ cell: "name",
137
+ value: "Ada",
138
+ });
139
+ assert.equal(res.status, 200);
140
+ assert.deepEqual(await res.json(), { members: { n: "3" } });
141
+ assert.deepEqual(calls[0].where, {
142
+ query: "members",
143
+ key: "7",
144
+ cell: "name",
145
+ value: "Ada",
146
+ });
147
+ assert.equal(calls[0].kind, "cell-change");
148
+ assert.equal(calls[0].page, "roster");
149
+ });
150
+ it("runs a tuple-delete, with the key of the row", async () => {
151
+ calls.length = 0;
152
+ const res = await post("roster", {
153
+ op: "tuple-delete",
154
+ query: "members",
155
+ key: "7",
156
+ });
157
+ assert.equal(res.status, 200);
158
+ assert.deepEqual(await res.json(), { members: { n: "4" } });
159
+ assert.deepEqual(calls[0].where, { query: "members", key: "7" });
160
+ assert.equal(calls[0].kind, "tuple-delete");
161
+ assert.equal(calls[0].page, "roster");
162
+ });
163
+ it("runs a tuple-insert, with the gathered values", async () => {
164
+ calls.length = 0;
165
+ const res = await post("roster", {
166
+ op: "tuple-insert",
167
+ query: "members",
168
+ values: { name: "Ada", role: "Analyst" },
169
+ });
170
+ assert.equal(res.status, 200);
171
+ assert.deepEqual(await res.json(), { members: { n: "5" } });
172
+ assert.deepEqual(calls[0].where, {
173
+ query: "members",
174
+ values: { name: "Ada", role: "Analyst" },
175
+ });
176
+ assert.equal(calls[0].kind, "tuple-insert");
177
+ assert.equal(calls[0].page, "roster");
178
+ });
179
+ it("runs a tuple-update, with the key and the gathered values", async () => {
180
+ calls.length = 0;
181
+ const res = await post("roster", {
182
+ op: "tuple-update",
183
+ query: "members",
184
+ key: "7",
185
+ values: { name: "Ada", role: "Analyst" },
186
+ });
187
+ assert.equal(res.status, 200);
188
+ assert.deepEqual(await res.json(), { members: { n: "6" } });
189
+ assert.deepEqual(calls[0].where, {
190
+ query: "members",
191
+ key: "7",
192
+ values: { name: "Ada", role: "Analyst" },
193
+ });
194
+ assert.equal(calls[0].kind, "tuple-update");
195
+ assert.equal(calls[0].page, "roster");
196
+ });
197
+ it("refuses a body with no op", async () => {
198
+ calls.length = 0;
199
+ const { result: res } = await captured(() => post("roster", { name: "pick" }));
200
+ assert.equal(res.status, 400);
201
+ assert.deepEqual(calls, []);
202
+ });
203
+ it("survives an operation that throws, rather than crashing", async () => {
204
+ calls.length = 0;
205
+ const { result: res, error } = await captured(() => post("roster", { op: "action", name: "explode", query: "members" }));
206
+ assert.equal(res.status, 500);
207
+ assert.deepEqual(await res.json(), {});
208
+ assert.match(error.join("\n"), /operation 'action' threw/);
209
+ // And the process is still serving.
210
+ const next = await fetch(`${base}${LB_DATA_ENDPOINT}?page=hello`);
211
+ assert.equal(next.status, 200);
212
+ });
213
+ it("survives a body that is not JSON", async () => {
214
+ calls.length = 0;
215
+ const res = await post("roster", null, "{ not json");
216
+ assert.ok(res.status >= 400, `expected a refusal, got ${res.status}`);
217
+ assert.deepEqual(calls, []);
218
+ // And the process is still serving. A parse failure is one request's
219
+ // problem, not the server's.
220
+ const next = await fetch(`${base}${LB_DATA_ENDPOINT}?page=hello`);
221
+ assert.equal(next.status, 200);
222
+ });
223
+ });
224
+ describe("the context", () => {
225
+ it("is built once per request, by the application", async () => {
226
+ // Hub's only requirement is that the context is built per request,
227
+ // after whatever establishes identity. Holding one for the life of the
228
+ // process is the mistake this asserts against.
229
+ contexts = 0;
230
+ calls.length = 0;
231
+ await fetch(`${base}${LB_DATA_ENDPOINT}?page=hello`);
232
+ await fetch(`${base}${LB_DATA_ENDPOINT}?page=hello`);
233
+ await post("counter", { op: "action", name: "bump" });
234
+ assert.equal(contexts, 3);
235
+ assert.notEqual(calls[0].ctx, calls[1].ctx);
236
+ assert.notEqual(calls[1].ctx, calls[2].ctx);
237
+ });
238
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * lb-input — see docs/testing.md, tier 4.
3
+ *
4
+ * The simplest widget there is, and therefore the one that states the leaf
5
+ * contract on its own: the hub writes one attribute name for every widget
6
+ * type, and the widget forwards it to the control it owns.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=lb-input.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-input.test.d.ts","sourceRoot":"","sources":["../../tests/lb-input.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lb-input — see docs/testing.md, tier 4.
3
+ *
4
+ * The simplest widget there is, and therefore the one that states the leaf
5
+ * contract on its own: the hub writes one attribute name for every widget
6
+ * type, and the widget forwards it to the control it owns.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { ATTR_CELL, ATTR_KEY, ATTR_QUERY, ATTR_VALUE } from "../core/lb-constants";
11
+ import { captured } from "./helpers/console";
12
+ import { change, installWindow, mount, requests } from "./helpers/window";
13
+ installWindow();
14
+ await import("../widgets/lb-input");
15
+ describe("lb-input receiving a value", () => {
16
+ it("forwards its value to the input it owns", () => {
17
+ const widget = mount(`<lb-input><input /></lb-input>`);
18
+ widget.setAttribute(ATTR_VALUE, "Ada");
19
+ assert.equal(widget.querySelector("input").value, "Ada");
20
+ });
21
+ it("takes the value it was already carrying when it upgraded", () => {
22
+ // Hydration and refresh are one operation. The browser runs
23
+ // attributeChangedCallback for attributes already present when a widget
24
+ // upgrades, so a value that landed before the class existed is not lost —
25
+ // which is what lets applyData fill a row and then insert it.
26
+ const widget = mount(`<lb-input ${ATTR_VALUE}="Ada"><input /></lb-input>`);
27
+ assert.equal(widget.querySelector("input").value, "Ada");
28
+ });
29
+ it("keeps up with a value that changes", () => {
30
+ const widget = mount(`<lb-input><input /></lb-input>`);
31
+ widget.setAttribute(ATTR_VALUE, "Ada");
32
+ widget.setAttribute(ATTR_VALUE, "Grace");
33
+ assert.equal(widget.querySelector("input").value, "Grace");
34
+ });
35
+ it("reports when there is no input to receive the value", async () => {
36
+ const widget = mount(`<lb-input></lb-input>`);
37
+ const { error } = await captured(() => widget.setAttribute(ATTR_VALUE, "Ada"));
38
+ assert.match(error.join("\n"), /lb-input: no <input> to receive/);
39
+ });
40
+ it("watches only the value attribute", async () => {
41
+ // observedAttributes is the whole of what a widget is told about. A page
42
+ // author's class or title must not reach the control.
43
+ const widget = mount(`<lb-input><input /></lb-input>`);
44
+ const { error } = await captured(() => {
45
+ widget.setAttribute("class", "wide");
46
+ widget.setAttribute("title", "Name");
47
+ });
48
+ assert.deepEqual(error, []);
49
+ assert.equal(widget.querySelector("input").value, "");
50
+ });
51
+ });
52
+ describe("lb-input sending a cell-change", () => {
53
+ it("sends its coordinates and the input's current value", () => {
54
+ const seen = requests();
55
+ const widget = mount(`<div ${ATTR_QUERY}="members" ${ATTR_KEY}="7">` +
56
+ `<lb-input ${ATTR_CELL}="name"><input /></lb-input></div>`).querySelector("lb-input");
57
+ widget.querySelector("input").value = "Ada";
58
+ change(widget.querySelector("input"));
59
+ assert.deepEqual(seen, [
60
+ { op: "cell-change", query: "members", key: "7", cell: "name", value: "Ada" },
61
+ ]);
62
+ });
63
+ it("reports a change with no query/key/cell coordinates, and sends nothing", async () => {
64
+ const seen = requests();
65
+ const widget = mount(`<lb-input ${ATTR_CELL}="name"><input /></lb-input>`);
66
+ const { error } = await captured(() => change(widget.querySelector("input")));
67
+ assert.match(error.join("\n"), /lb-input: change with no query\/key\/cell coordinates/);
68
+ assert.deepEqual(seen, []);
69
+ });
70
+ it("reports a change with no input, and sends nothing", async () => {
71
+ const seen = requests();
72
+ const widget = mount(`<div ${ATTR_QUERY}="members" ${ATTR_KEY}="7">` +
73
+ `<lb-input ${ATTR_CELL}="name"></lb-input></div>`).querySelector("lb-input");
74
+ const { error } = await captured(() => change(widget));
75
+ assert.match(error.join("\n"), /lb-input: change with no <input>/);
76
+ assert.deepEqual(seen, []);
77
+ });
78
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * lb-list — see docs/testing.md, tier 4.
3
+ *
4
+ * The plain repeater: whatever the page author wrote inside is repeated once
5
+ * per tuple, in the order the server sent. It adds no placement of its own,
6
+ * so what is worth testing is that it is a row host at all, and the case it
7
+ * exists for — a <table> that cannot be one, because a custom element written
8
+ * inside <tbody> is discarded by the parser. The widget goes around the
9
+ * table, and the template goes inside it.
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=lb-list.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-list.test.d.ts","sourceRoot":"","sources":["../../tests/lb-list.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * lb-list — see docs/testing.md, tier 4.
3
+ *
4
+ * The plain repeater: whatever the page author wrote inside is repeated once
5
+ * per tuple, in the order the server sent. It adds no placement of its own,
6
+ * so what is worth testing is that it is a row host at all, and the case it
7
+ * exists for — a <table> that cannot be one, because a custom element written
8
+ * inside <tbody> is discarded by the parser. The widget goes around the
9
+ * table, and the template goes inside it.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { installWindow, mount } from "./helpers/window";
14
+ installWindow();
15
+ await import("../widgets/lb-list");
16
+ const rows = (list) => ({ op: "rows", rows: list });
17
+ describe("lb-list", () => {
18
+ it("repeats what the page wrote, once per tuple", () => {
19
+ const widget = mount(`<lb-list><ul><template lb-key="id"><li lb-cell="name"></li></template></ul></lb-list>`);
20
+ widget.acceptRows(rows([
21
+ { id: "1", name: "Ada" },
22
+ { id: "2", name: "Bab" },
23
+ ]));
24
+ assert.deepEqual([...widget.querySelectorAll("li")].map((li) => li.textContent), ["Ada", "Bab"]);
25
+ });
26
+ it("goes around a table it cannot be inside", () => {
27
+ // The reason this widget is not the <table> itself. Everything the hub
28
+ // needs still works: the template is inside <tbody>, where the content
29
+ // model already allows it, and the rows land there.
30
+ const widget = mount(`<lb-list><table><tbody>` +
31
+ `<template lb-key="id"><tr><td lb-cell="name"></td></tr></template>` +
32
+ `</tbody></table></lb-list>`);
33
+ widget.acceptRows(rows([{ id: "1", name: "Ada" }]));
34
+ const row = widget.querySelector("tbody tr");
35
+ assert.equal(row.textContent, "Ada");
36
+ assert.equal(widget.getAttribute("data-rows"), "1");
37
+ });
38
+ it("survives the parser dropping a widget written inside a tbody", () => {
39
+ // The failure this design avoids, stated as what the parser actually
40
+ // does: a lb-list inside <tbody> is not there afterwards.
41
+ const table = mount(`<table><tbody><lb-list><template lb-key="id"><tr></tr></template></lb-list></tbody></table>`);
42
+ assert.equal(table.querySelector("tbody lb-list"), null);
43
+ });
44
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * lb-options — see docs/testing.md, tier 4.
3
+ *
4
+ * The case that shows why placement belongs to the widget. The rows arrive
5
+ * flat and keyed; nothing on the wire knows about <optgroup>, and nothing
6
+ * needs to. Turning a repeated column value into an element is exactly the
7
+ * local knowledge a widget has and the hub does not.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=lb-options.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-options.test.d.ts","sourceRoot":"","sources":["../../tests/lb-options.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}