pywebview-htmx 0.3.0__tar.gz → 0.3.1__tar.gz

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 (24) hide show
  1. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/PKG-INFO +17 -8
  2. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/README.md +15 -6
  3. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/pyproject.toml +5 -5
  4. pywebview_htmx-0.3.1/src/pywebview_htmx/py.typed +1 -0
  5. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/runtime.js +36 -9
  6. pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +369 -0
  7. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_request_and_swap.py +16 -3
  8. pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +27 -0
  9. pywebview_htmx-0.3.1/tests/test_repo_legibility.py +125 -0
  10. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/LICENSE +0 -0
  11. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/__init__.py +0 -0
  12. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/runtime.py +0 -0
  13. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  14. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/base.css +0 -0
  15. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  16. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
  17. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/conftest.py +0 -0
  18. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_config_and_binding.py +0 -0
  19. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_ergonomics.py +0 -0
  20. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_create_window.py +0 -0
  21. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_injection.py +0 -0
  22. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_params_attr.py +0 -0
  23. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_script_loading.py +0 -0
  24. {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_themes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pywebview-htmx
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: HTMX-style declarative UI bindings for PyWebview apps
5
5
  Author-Email: "B.T. Franklin" <brandon.franklin@gmail.com>
6
6
  License: MIT License
@@ -35,7 +35,7 @@ Classifier: Intended Audience :: Developers
35
35
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
36
36
  Classifier: Topic :: Internet :: WWW/HTTP
37
37
  Requires-Python: >=3.14
38
- Requires-Dist: pywebview>=6.1
38
+ Requires-Dist: pywebview>=6.2.1
39
39
  Description-Content-Type: text/markdown
40
40
 
41
41
  # pywebview-htmx
@@ -84,6 +84,11 @@ pdm install
84
84
  pdm add pywebview-htmx
85
85
  ```
86
86
 
87
+ ## Maintainer Docs
88
+
89
+ For maintainer and agent-facing repository guidance, see
90
+ [`docs/index.md`](docs/index.md).
91
+
87
92
  ## Agent Skill
88
93
 
89
94
  This repository includes an installable coding-agent skill at
@@ -255,7 +260,7 @@ Unknown value falls back to `innerHTML`.
255
260
  ### `py-wait` (optional)
256
261
 
257
262
  CSS selector of element receiving `.py-waiting` while request is in flight.
258
- If missing/empty/unresolvable, the triggering element is used.
263
+ If missing, empty, invalid, or unresolvable, the triggering element is used.
259
264
 
260
265
  ### `py-policy` (optional)
261
266
 
@@ -302,17 +307,21 @@ document.body.addEventListener("py:error", (event) => {
302
307
 
303
308
  ## Concurrency Behavior
304
309
 
305
- Per trigger element, `pywebview-htmx` tracks request state.
310
+ `pywebview-htmx` tracks request state by resolved target. Controls that share
311
+ the same `py-target` coordinate request ordering and `drop` behavior. Controls
312
+ without `py-target` use the triggering element as their request state scope.
313
+ Loading state is counted separately per resolved `py-wait` target so shared
314
+ spinners stay active until all in-flight requests finish.
306
315
 
307
316
  ### `latest-wins` (default)
308
317
 
309
- Multiple rapid requests are allowed; stale responses are ignored.
310
- Only latest request updates the DOM.
318
+ Multiple rapid requests are allowed; stale responses in the same request state
319
+ scope are ignored. Only the latest request for that scope updates the DOM.
311
320
 
312
321
  ### `drop`
313
322
 
314
- If a request is already in flight for that element, new triggers are ignored and
315
- `py:ignored` is emitted.
323
+ If a request is already in flight for the same request state scope, new triggers
324
+ are ignored and `py:ignored` is emitted.
316
325
 
317
326
  ## Dynamic Content Re-processing
318
327
 
@@ -44,6 +44,11 @@ pdm install
44
44
  pdm add pywebview-htmx
45
45
  ```
46
46
 
47
+ ## Maintainer Docs
48
+
49
+ For maintainer and agent-facing repository guidance, see
50
+ [`docs/index.md`](docs/index.md).
51
+
47
52
  ## Agent Skill
48
53
 
49
54
  This repository includes an installable coding-agent skill at
@@ -215,7 +220,7 @@ Unknown value falls back to `innerHTML`.
215
220
  ### `py-wait` (optional)
216
221
 
217
222
  CSS selector of element receiving `.py-waiting` while request is in flight.
218
- If missing/empty/unresolvable, the triggering element is used.
223
+ If missing, empty, invalid, or unresolvable, the triggering element is used.
219
224
 
220
225
  ### `py-policy` (optional)
221
226
 
@@ -262,17 +267,21 @@ document.body.addEventListener("py:error", (event) => {
262
267
 
263
268
  ## Concurrency Behavior
264
269
 
265
- Per trigger element, `pywebview-htmx` tracks request state.
270
+ `pywebview-htmx` tracks request state by resolved target. Controls that share
271
+ the same `py-target` coordinate request ordering and `drop` behavior. Controls
272
+ without `py-target` use the triggering element as their request state scope.
273
+ Loading state is counted separately per resolved `py-wait` target so shared
274
+ spinners stay active until all in-flight requests finish.
266
275
 
267
276
  ### `latest-wins` (default)
268
277
 
269
- Multiple rapid requests are allowed; stale responses are ignored.
270
- Only latest request updates the DOM.
278
+ Multiple rapid requests are allowed; stale responses in the same request state
279
+ scope are ignored. Only the latest request for that scope updates the DOM.
271
280
 
272
281
  ### `drop`
273
282
 
274
- If a request is already in flight for that element, new triggers are ignored and
275
- `py:ignored` is emitted.
283
+ If a request is already in flight for the same request state scope, new triggers
284
+ are ignored and `py:ignored` is emitted.
276
285
 
277
286
  ## Dynamic Content Re-processing
278
287
 
@@ -6,7 +6,7 @@ authors = [
6
6
  { name = "B.T. Franklin", email = "brandon.franklin@gmail.com" },
7
7
  ]
8
8
  dependencies = [
9
- "pywebview>=6.1",
9
+ "pywebview>=6.2.1",
10
10
  ]
11
11
  requires-python = ">=3.14"
12
12
  readme = "README.md"
@@ -21,14 +21,14 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.3.0"
24
+ version = "0.3.1"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
28
28
 
29
29
  [build-system]
30
30
  requires = [
31
- "pdm-backend",
31
+ "pdm-backend>=2.4.9",
32
32
  ]
33
33
  build-backend = "pdm.backend"
34
34
 
@@ -65,6 +65,6 @@ select = [
65
65
 
66
66
  [dependency-groups]
67
67
  dev = [
68
- "pytest>=9.0.2",
69
- "ruff>=0.15.4",
68
+ "pytest>=9.0.3",
69
+ "ruff>=0.15.17",
70
70
  ]
@@ -8,10 +8,16 @@
8
8
  requestPolicy: "latest-wins",
9
9
  };
10
10
  const requestState = new WeakMap();
11
+ const waitState = new WeakMap();
11
12
 
12
13
  function $(selector, root = document) {
13
14
  if (!selector) return null;
14
- return root.querySelector(selector);
15
+ try {
16
+ return root.querySelector(selector);
17
+ } catch (error) {
18
+ console.warn("PyWebview HTMX: invalid selector", selector, error);
19
+ return null;
20
+ }
15
21
  }
16
22
 
17
23
  function triggerEvent(target, eventName, detail = {}) {
@@ -126,15 +132,38 @@
126
132
  return await func(params);
127
133
  }
128
134
 
129
- function getRequestState(element) {
130
- let state = requestState.get(element);
135
+ function getRequestState(key) {
136
+ let state = requestState.get(key);
131
137
  if (!state) {
132
138
  state = { lastIssued: 0, inFlightCount: 0 };
133
- requestState.set(element, state);
139
+ requestState.set(key, state);
134
140
  }
135
141
  return state;
136
142
  }
137
143
 
144
+ function getRequestStateKey(element, targetSelector) {
145
+ if (!targetSelector) {
146
+ return element;
147
+ }
148
+ return $(targetSelector) || element;
149
+ }
150
+
151
+ function addWaiting(waitTarget) {
152
+ const count = (waitState.get(waitTarget) || 0) + 1;
153
+ waitState.set(waitTarget, count);
154
+ waitTarget.classList.add("py-waiting");
155
+ }
156
+
157
+ function removeWaiting(waitTarget) {
158
+ const count = Math.max(0, (waitState.get(waitTarget) || 0) - 1);
159
+ if (count === 0) {
160
+ waitState.delete(waitTarget);
161
+ waitTarget.classList.remove("py-waiting");
162
+ return;
163
+ }
164
+ waitState.set(waitTarget, count);
165
+ }
166
+
138
167
  function shouldPreventDefault(eventName) {
139
168
  return eventName === "submit";
140
169
  }
@@ -181,7 +210,7 @@
181
210
  }
182
211
 
183
212
  const params = buildRequestParams(element, eventName, event);
184
- const state = getRequestState(element);
213
+ const state = getRequestState(getRequestStateKey(element, targetSelector));
185
214
  const requestPolicy = getRequestPolicy(element);
186
215
  if (requestPolicy === "drop" && state.inFlightCount > 0) {
187
216
  triggerEvent(element, "py:ignored", { reason: "in-flight" });
@@ -198,7 +227,7 @@
198
227
  requestId,
199
228
  });
200
229
 
201
- waitTarget.classList.add("py-waiting");
230
+ addWaiting(waitTarget);
202
231
 
203
232
  try {
204
233
  const response = await invokePython(functionName, params);
@@ -239,9 +268,7 @@
239
268
  triggerEvent(element, "py:error", { error, requestId });
240
269
  } finally {
241
270
  state.inFlightCount = Math.max(0, state.inFlightCount - 1);
242
- if (state.inFlightCount === 0) {
243
- waitTarget.classList.remove("py-waiting");
244
- }
271
+ removeWaiting(waitTarget);
245
272
  }
246
273
  });
247
274
  });
@@ -0,0 +1,369 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import vm from "node:vm";
4
+
5
+ class DomEvent {
6
+ constructor(type, options = {}) {
7
+ this.type = type;
8
+ this.bubbles = Boolean(options.bubbles);
9
+ this.cancelable = Boolean(options.cancelable);
10
+ this.detail = options.detail || {};
11
+ this.defaultPrevented = false;
12
+ this.target = null;
13
+ this.currentTarget = null;
14
+ }
15
+
16
+ preventDefault() {
17
+ if (this.cancelable) {
18
+ this.defaultPrevented = true;
19
+ }
20
+ }
21
+ }
22
+
23
+ class DomCustomEvent extends DomEvent {}
24
+
25
+ class ClassList {
26
+ constructor() {
27
+ this.tokens = new Set();
28
+ }
29
+
30
+ add(...tokens) {
31
+ tokens.forEach((token) => this.tokens.add(token));
32
+ }
33
+
34
+ remove(...tokens) {
35
+ tokens.forEach((token) => this.tokens.delete(token));
36
+ }
37
+
38
+ contains(token) {
39
+ return this.tokens.has(token);
40
+ }
41
+ }
42
+
43
+ class Element {
44
+ constructor(tagName, ownerDocument) {
45
+ this.tagName = tagName.toUpperCase();
46
+ this.ownerDocument = ownerDocument;
47
+ this.parentElement = null;
48
+ this.children = [];
49
+ this.attributes = new Map();
50
+ this.classList = new ClassList();
51
+ this.listeners = new Map();
52
+ this._innerHTML = "";
53
+ this.id = "";
54
+ }
55
+
56
+ append(...children) {
57
+ children.forEach((child) => {
58
+ child.parentElement = this;
59
+ child.ownerDocument = this.ownerDocument;
60
+ this.children.push(child);
61
+ });
62
+ }
63
+
64
+ setAttribute(name, value) {
65
+ const stringValue = String(value);
66
+ this.attributes.set(name, stringValue);
67
+ if (name === "id") {
68
+ this.id = stringValue;
69
+ }
70
+ }
71
+
72
+ getAttribute(name) {
73
+ return this.attributes.has(name) ? this.attributes.get(name) : null;
74
+ }
75
+
76
+ hasAttribute(name) {
77
+ return this.attributes.has(name);
78
+ }
79
+
80
+ matches(selector) {
81
+ return this.ownerDocument.matchesSelector(this, selector);
82
+ }
83
+
84
+ querySelector(selector) {
85
+ return this.ownerDocument.querySelectorFrom(selector, this);
86
+ }
87
+
88
+ querySelectorAll(selector) {
89
+ return this.ownerDocument.querySelectorAllFrom(selector, this);
90
+ }
91
+
92
+ addEventListener(type, listener) {
93
+ const listeners = this.listeners.get(type) || [];
94
+ listeners.push(listener);
95
+ this.listeners.set(type, listeners);
96
+ }
97
+
98
+ dispatchEvent(event) {
99
+ if (!event.target) {
100
+ event.target = this;
101
+ }
102
+ event.currentTarget = this;
103
+
104
+ for (const listener of this.listeners.get(event.type) || []) {
105
+ listener.call(this, event);
106
+ }
107
+
108
+ if (event.bubbles && this.parentElement) {
109
+ this.parentElement.dispatchEvent(event);
110
+ }
111
+ return !event.defaultPrevented;
112
+ }
113
+
114
+ set innerHTML(value) {
115
+ this._innerHTML = String(value);
116
+ this.children = [];
117
+ }
118
+
119
+ get innerHTML() {
120
+ return this._innerHTML;
121
+ }
122
+
123
+ set outerHTML(value) {
124
+ if (!this.parentElement) {
125
+ this.innerHTML = value;
126
+ return;
127
+ }
128
+
129
+ const replacement = this.ownerDocument.createElement("div");
130
+ replacement.innerHTML = value;
131
+ const index = this.parentElement.children.indexOf(this);
132
+ this.parentElement.children.splice(index, 1, replacement);
133
+ replacement.parentElement = this.parentElement;
134
+ }
135
+
136
+ insertAdjacentHTML(position, value) {
137
+ assert.equal(position, "beforeend");
138
+ this._innerHTML += String(value);
139
+ }
140
+ }
141
+
142
+ class Document {
143
+ constructor() {
144
+ this.listeners = new Map();
145
+ this.body = new Element("body", this);
146
+ }
147
+
148
+ createElement(tagName) {
149
+ return new Element(tagName, this);
150
+ }
151
+
152
+ addEventListener(type, listener) {
153
+ const listeners = this.listeners.get(type) || [];
154
+ listeners.push(listener);
155
+ this.listeners.set(type, listeners);
156
+ }
157
+
158
+ querySelector(selector) {
159
+ return this.querySelectorFrom(selector, this.body);
160
+ }
161
+
162
+ querySelectorAll(selector) {
163
+ return this.querySelectorAllFrom(selector, this.body);
164
+ }
165
+
166
+ querySelectorFrom(selector, root) {
167
+ return this.querySelectorAllFrom(selector, root)[0] || null;
168
+ }
169
+
170
+ querySelectorAllFrom(selector, root) {
171
+ this.validateSelector(selector);
172
+ const matches = [];
173
+
174
+ const visit = (node) => {
175
+ for (const child of node.children) {
176
+ if (this.matchesSelector(child, selector)) {
177
+ matches.push(child);
178
+ }
179
+ visit(child);
180
+ }
181
+ };
182
+
183
+ visit(root);
184
+ return matches;
185
+ }
186
+
187
+ matchesSelector(element, selector) {
188
+ this.validateSelector(selector);
189
+ if (selector === "[py-call]") {
190
+ return element.hasAttribute("py-call");
191
+ }
192
+ if (selector.startsWith("#")) {
193
+ return element.id === selector.slice(1);
194
+ }
195
+ throw new SyntaxError(`Unsupported selector in test harness: ${selector}`);
196
+ }
197
+
198
+ validateSelector(selector) {
199
+ if (!selector || selector === "[" || selector.includes("###")) {
200
+ throw new SyntaxError(`Invalid selector: ${selector}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ function makeElement(document, tagName, attributes = {}) {
206
+ const element = document.createElement(tagName);
207
+ for (const [name, value] of Object.entries(attributes)) {
208
+ element.setAttribute(name, value);
209
+ }
210
+ return element;
211
+ }
212
+
213
+ function createHarness(runtimeSource) {
214
+ const document = new Document();
215
+ const warnings = [];
216
+ const context = {
217
+ clearTimeout,
218
+ console: {
219
+ ...console,
220
+ warn: (...args) => warnings.push(args.map(String).join(" ")),
221
+ },
222
+ CustomEvent: DomCustomEvent,
223
+ document,
224
+ Promise,
225
+ setTimeout,
226
+ };
227
+ context.window = context;
228
+ context.globalThis = context;
229
+
230
+ vm.createContext(context);
231
+ vm.runInContext(runtimeSource, context);
232
+
233
+ context.pywebviewHtmx.config.settleDelay = 0;
234
+ context.pywebviewHtmx.config.swapDelay = 0;
235
+
236
+ return { document, warnings, window: context };
237
+ }
238
+
239
+ async function flushAsyncWork() {
240
+ await Promise.resolve();
241
+ await new Promise((resolve) => setTimeout(resolve, 0));
242
+ await Promise.resolve();
243
+ }
244
+
245
+ function click(element) {
246
+ element.dispatchEvent(new DomEvent("click", { bubbles: true, cancelable: true }));
247
+ }
248
+
249
+ async function testInvalidWaitSelectorFallsBackAndDoesNotAbort(runtimeSource) {
250
+ const { document, warnings, window } = createHarness(runtimeSource);
251
+ const result = makeElement(document, "div", { id: "result" });
252
+ const badWait = makeElement(document, "button", {
253
+ "py-call": "load",
254
+ "py-target": "#result",
255
+ "py-wait": "[",
256
+ });
257
+ const laterButton = makeElement(document, "button", { "py-call": "later" });
258
+ document.body.append(badWait, result, laterButton);
259
+
260
+ let resolveLoad;
261
+ window.pywebview = {
262
+ api: {
263
+ load: () => new Promise((resolve) => {
264
+ resolveLoad = resolve;
265
+ }),
266
+ later: () => "<p>later</p>",
267
+ },
268
+ };
269
+
270
+ window.pywebviewHtmx.process(document.body);
271
+ assert.equal(badWait.getAttribute("data-pywebview-bound"), "true");
272
+ assert.equal(laterButton.getAttribute("data-pywebview-bound"), "true");
273
+ assert.ok(warnings.some((warning) => warning.includes("invalid selector")));
274
+
275
+ click(badWait);
276
+ assert.equal(badWait.classList.contains("py-waiting"), true);
277
+
278
+ resolveLoad("<p>loaded</p>");
279
+ await flushAsyncWork();
280
+
281
+ assert.equal(result.innerHTML, "<p>loaded</p>");
282
+ assert.equal(badWait.classList.contains("py-waiting"), false);
283
+ }
284
+
285
+ async function testLatestWinsIsSharedByTarget(runtimeSource) {
286
+ const { document, window } = createHarness(runtimeSource);
287
+ const result = makeElement(document, "div", { id: "race-result" });
288
+ const wait = makeElement(document, "span", { id: "race-status" });
289
+ const slow = makeElement(document, "button", {
290
+ "py-call": "long_task",
291
+ "py-target": "#race-result",
292
+ "py-wait": "#race-status",
293
+ });
294
+ const fast = makeElement(document, "button", {
295
+ "py-call": "long_task",
296
+ "py-target": "#race-result",
297
+ "py-wait": "#race-status",
298
+ });
299
+ document.body.append(slow, fast, wait, result);
300
+
301
+ const requests = [];
302
+ window.pywebview = {
303
+ api: {
304
+ long_task: () => new Promise((resolve) => {
305
+ requests.push(resolve);
306
+ }),
307
+ },
308
+ };
309
+
310
+ window.pywebviewHtmx.process(document.body);
311
+ click(slow);
312
+ click(fast);
313
+
314
+ assert.equal(requests.length, 2);
315
+ assert.equal(wait.classList.contains("py-waiting"), true);
316
+
317
+ requests[1]("<p>fast</p>");
318
+ await flushAsyncWork();
319
+
320
+ assert.equal(result.innerHTML, "<p>fast</p>");
321
+ assert.equal(wait.classList.contains("py-waiting"), true);
322
+
323
+ requests[0]("<p>slow</p>");
324
+ await flushAsyncWork();
325
+
326
+ assert.equal(result.innerHTML, "<p>fast</p>");
327
+ assert.equal(wait.classList.contains("py-waiting"), false);
328
+ }
329
+
330
+ async function testDropPolicyIsSharedByTarget(runtimeSource) {
331
+ const { document, window } = createHarness(runtimeSource);
332
+ const result = makeElement(document, "div", { id: "drop-result" });
333
+ const first = makeElement(document, "button", {
334
+ "py-call": "long_task",
335
+ "py-policy": "drop",
336
+ "py-target": "#drop-result",
337
+ });
338
+ const second = makeElement(document, "button", {
339
+ "py-call": "long_task",
340
+ "py-policy": "drop",
341
+ "py-target": "#drop-result",
342
+ });
343
+ document.body.append(first, second, result);
344
+
345
+ const requests = [];
346
+ window.pywebview = {
347
+ api: {
348
+ long_task: () => new Promise((resolve) => {
349
+ requests.push(resolve);
350
+ }),
351
+ },
352
+ };
353
+
354
+ window.pywebviewHtmx.process(document.body);
355
+ click(first);
356
+ click(second);
357
+
358
+ assert.equal(requests.length, 1);
359
+ requests[0]("<p>first</p>");
360
+ await flushAsyncWork();
361
+ assert.equal(result.innerHTML, "<p>first</p>");
362
+ }
363
+
364
+ const runtimePath = process.argv[2];
365
+ const runtimeSource = fs.readFileSync(runtimePath, "utf8");
366
+
367
+ await testInvalidWaitSelectorFallsBackAndDoesNotAbort(runtimeSource);
368
+ await testLatestWinsIsSharedByTarget(runtimeSource);
369
+ await testDropPolicyIsSharedByTarget(runtimeSource);
@@ -20,12 +20,25 @@ JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
20
20
  "event.preventDefault();",
21
21
  ),
22
22
  ),
23
- (74, ("let state = requestState.get(element);", "if (!state) {")),
23
+ (
24
+ 74,
25
+ (
26
+ "function getRequestState(key)",
27
+ "let state = requestState.get(key);",
28
+ "requestState.set(key, state);",
29
+ ),
30
+ ),
24
31
  (75, ("lastIssued: 0", "state.lastIssued = requestId;")),
25
32
  (76, ("inFlightCount: 0", "state.inFlightCount += 1;")),
26
33
  (77, ('requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
27
34
  (78, ('triggerEvent(element, "py:trigger"', "requestId,")),
28
- (79, ('waitTarget.classList.add("py-waiting")',)),
35
+ (
36
+ 79,
37
+ (
38
+ "function addWaiting(waitTarget)",
39
+ 'waitTarget.classList.add("py-waiting")',
40
+ ),
41
+ ),
29
42
  (80, ("if (requestId !== state.lastIssued) { return; }",)),
30
43
  (81, ("const target = targetSelector ? $(targetSelector) : element;",)),
31
44
  (82, (" : element;",)),
@@ -50,7 +63,7 @@ JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
50
63
  (
51
64
  94,
52
65
  (
53
- "if (state.inFlightCount === 0)",
66
+ "function removeWaiting(waitTarget)",
54
67
  'waitTarget.classList.remove("py-waiting")',
55
68
  ),
56
69
  ),
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ HARNESS = REPO_ROOT / "tests/js_runtime_behavior_harness.mjs"
12
+ RUNTIME = REPO_ROOT / "src/pywebview_htmx/static/runtime.js"
13
+
14
+
15
+ def test_runtime_behaviors_execute_in_dom_harness() -> None:
16
+ node = shutil.which("node")
17
+ if node is None:
18
+ pytest.skip("Node.js is required for JavaScript runtime behavior tests")
19
+
20
+ result = subprocess.run(
21
+ [node, str(HARNESS), str(RUNTIME)],
22
+ capture_output=True,
23
+ check=False,
24
+ text=True,
25
+ )
26
+
27
+ assert result.returncode == 0, result.stdout + result.stderr
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ REPO_ROOT = Path(__file__).resolve().parents[1]
8
+ REQUIRED_DOCS = [
9
+ "AGENTS.md",
10
+ "docs/index.md",
11
+ "docs/architecture.md",
12
+ "docs/development.md",
13
+ "docs/releasing.md",
14
+ ]
15
+ AGENT_REQUIRED_ROUTES = [
16
+ "README.md",
17
+ "docs/index.md",
18
+ "src/pywebview_htmx/runtime.py",
19
+ "src/pywebview_htmx/static/runtime.js",
20
+ "app.py",
21
+ "pyproject.toml",
22
+ "docs/releasing.md",
23
+ "tests/",
24
+ "skills/use-pywebview-htmx/",
25
+ ]
26
+ LOCAL_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
27
+
28
+
29
+ def _read_repo_file(path: str) -> str:
30
+ return (REPO_ROOT / path).read_text(encoding="utf-8")
31
+
32
+
33
+ def _iter_local_links(markdown_path: Path) -> list[str]:
34
+ links: list[str] = []
35
+ for raw_link in LOCAL_MARKDOWN_LINK_RE.findall(
36
+ markdown_path.read_text(encoding="utf-8"),
37
+ ):
38
+ target = raw_link.split("#", 1)[0].strip()
39
+ if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target):
40
+ continue
41
+ links.append(target)
42
+ return links
43
+
44
+
45
+ def test_required_agent_legibility_docs_exist() -> None:
46
+ missing = [path for path in REQUIRED_DOCS if not (REPO_ROOT / path).exists()]
47
+ assert not missing, f"Missing agent-legibility docs: {missing}"
48
+
49
+
50
+ def test_new_docs_have_resolvable_local_markdown_links() -> None:
51
+ checked_paths = [REPO_ROOT / path for path in REQUIRED_DOCS]
52
+ broken_links: list[str] = []
53
+
54
+ for markdown_path in checked_paths:
55
+ for target in _iter_local_links(markdown_path):
56
+ resolved = (markdown_path.parent / target).resolve()
57
+ link_description = f"{markdown_path.relative_to(REPO_ROOT)} -> {target}"
58
+ try:
59
+ resolved.relative_to(REPO_ROOT)
60
+ except ValueError:
61
+ broken_links.append(link_description)
62
+ continue
63
+ if not resolved.exists():
64
+ broken_links.append(link_description)
65
+
66
+ assert not broken_links, f"Broken local Markdown links: {broken_links}"
67
+
68
+
69
+ def test_agents_md_routes_to_core_repo_surfaces() -> None:
70
+ agents_md = _read_repo_file("AGENTS.md")
71
+ missing_routes = [
72
+ route for route in AGENT_REQUIRED_ROUTES if route not in agents_md
73
+ ]
74
+ assert not missing_routes, f"AGENTS.md is missing route(s): {missing_routes}"
75
+
76
+
77
+ def test_development_guidance_captures_package_policy() -> None:
78
+ combined_guidance = "\n".join(
79
+ _read_repo_file(path) for path in ("AGENTS.md", "docs/development.md")
80
+ ).lower()
81
+
82
+ required_fragments = [
83
+ "pdm install",
84
+ "pdm run lint",
85
+ "pdm run test",
86
+ "pdm run python app.py",
87
+ "do not use pip to add",
88
+ ">=",
89
+ "latest version",
90
+ ]
91
+ missing_fragments = [
92
+ fragment for fragment in required_fragments if fragment not in combined_guidance
93
+ ]
94
+ assert not missing_fragments, (
95
+ "Development guidance is missing durable instruction(s): "
96
+ f"{missing_fragments}"
97
+ )
98
+
99
+
100
+ def test_release_guidance_captures_tag_first_release_notes_flow() -> None:
101
+ release_guidance = _read_repo_file("docs/releasing.md")
102
+
103
+ required_fragments = [
104
+ "release-notes-scribe",
105
+ "Draft Release Notes",
106
+ "git tag",
107
+ "git push origin",
108
+ ".github/workflows/draft-release-notes.yml",
109
+ ".github/workflows/python-publish.yml",
110
+ "PyPI",
111
+ ]
112
+ missing_fragments = [
113
+ fragment for fragment in required_fragments if fragment not in release_guidance
114
+ ]
115
+
116
+ assert not missing_fragments, (
117
+ "Release guidance must preserve the tag-first release notes process. "
118
+ f"Missing: {missing_fragments}"
119
+ )
120
+
121
+
122
+ def test_typed_classifier_has_pep_561_marker() -> None:
123
+ pyproject = _read_repo_file("pyproject.toml")
124
+ assert "Typing :: Typed" in pyproject
125
+ assert (REPO_ROOT / "src/pywebview_htmx/py.typed").is_file()
File without changes