Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/openxdox/web/views/swb-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,16 +311,29 @@ export function sessionEndingReport(branch) {
// mount reads the module-level install, which is the shape every affordance in
// this file has always used; `firstEditTransport` passes its own, because its
// returned transport outlives the call that built it (Copilot review, round 3).
//
// THE CALL SPELLINGS ARE PINNED. `tests/test_session_confinement.py` holds both
// write transports, this one and `swb-create.js`, to the model's own two names at
// their two call sites, so `submitSession` shadows those names with the pair it
// is to use instead of calling the pair through local aliases. The pinned text is
// deliberately not quoted in this comment: a needle quoted in its own source
// satisfies itself. `MODULE_TRANSPORT` is the module-level pair, each reaching
// the CURRENT install at call time exactly as the two consts above do, so with no
// `bound` the behaviour is the one the earlier ternaries gave.
const MODULE_TRANSPORT = {
consoleHeaders: (...args) => consoleHeaders(...args),
withConsoleRepair: (...args) => withConsoleRepair(...args),
};

async function submitSession(affordance, body, fetcher, caps, repair, bound) {
const doFetch = fetcher || fetch;
const headersOf = bound ? bound.consoleHeaders : consoleHeaders;
const repairWith = bound ? bound.withConsoleRepair : withConsoleRepair;
const { consoleHeaders, withConsoleRepair } = bound || MODULE_TRANSPORT;
Comment thread
brettheap marked this conversation as resolved.
const send = async () => {
const response = await doFetch(sessionRoute(affordance), {
method: "POST",
// the human-console header among them (FR-019's third clause): a write that
// cannot present this serve's token is refused before its body is parsed
headers: headersOf(caps),
headers: consoleHeaders(caps),
body: JSON.stringify(body),
});
try {
Expand All @@ -329,7 +342,7 @@ async function submitSession(affordance, body, fetcher, caps, repair, bound) {
return { ok: false, message: "malformed response (HTTP " + response.status + ")" };
}
};
return repairWith(send, repair);
return withConsoleRepair(send, repair);
}

// ---- the doxBench Save transport (010-doxbench-editor-chat T080) -----------
Expand Down
205 changes: 205 additions & 0 deletions tests/test_swb_session_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""`swb-session.js`'s write transport, EXECUTED: which model's header and repair
Comment thread
brettheap marked this conversation as resolved.
policy a session request is sent through.

WHY THIS FILE EXISTS. `submitSession` is the ONE request site of the session
column (see the comment above it in `views/swb-session.js`). It takes the two
model functions it needs, `consoleHeaders` and `withConsoleRepair`, from one of
two places: the model namespace its caller validated (`bound`, which is what
`firstEditTransport` passes, because the transport it returns outlives the call
that built it), or, with none, the module-level seam that reads the model the
current mount installed.

`tests/test_session_confinement.py` pins how those two calls are SPELLED, and
only that: it reads the file as text. A pass there says the two names appear at
their call sites, not that the right model answers them. Copilot's review of the
change that restored those spellings (openXdox-code#42) saw the gap: always
selecting the module-level pair, or wiring either wrapper to the wrong function,
left every committed test green. These two probes close it. They run the shipped
module under node, out of a COMPOSED bundle, and assert on the request that
reaches the injected fetcher:

* BOUND: two transports are built from two models that differ in the very two
functions under test, then both are called. Each request must carry its own
model's header and go through its own model's repair policy. A `submitSession`
that always read the module-level install would send both through the model
built LAST.
* UNBOUND: a mounted session form is submitted. The request must carry the
header the mounted model's `consoleHeaders` builds from the page's `caps`, and
go through that model's `withConsoleRepair`. A module-level pair that is wired
to the wrong function sends no header, or throws before it sends.

WHAT THE HARNESS DOES NOT MEASURE. The outcome panel the column writes AFTER the
request needs the shell's `page-overlay` host, which a stub DOM does not provide,
so the form's click handler rejects after the fetcher has been called. The probe
records that rejection and asserts on the REQUEST only, which is what these
tests are about.

A CREATED file, with no manifest row (RULED OQ-C), outside the declared
exclusion: it needs openDox's `web/` bundle and node, as
`tests/test_gate_loop_probes.py` does, and no openxFactory module.
"""

from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

import opendox_bundle

NODE = shutil.which("node")

_DOM_STUB = r"""
class Node {
constructor(tag) {
this.tag = tag; this.children = []; this.attrs = {}; this.listeners = {};
this.className = ""; this._text = ""; this.type = ""; this.title = "";
this.value = ""; this.checked = false; this.disabled = false;
this.hidden = false;
}
get textContent() { return this._text; }
set textContent(v) { this._text = String(v); this.children = []; }
set innerHTML(v) {
if (v !== "") throw new Error("innerHTML assigned " + v);
this.children = []; this._text = "";
}
appendChild(c) { this.children.push(c); return c; }
append(...kids) { for (const k of kids) this.appendChild(k); }
setAttribute(k, v) { this.attrs[k] = String(v); }
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
focus() {}
click() { for (const fn of this.listeners.click || []) fn({}); }
}
globalThis.document = { createElement: (tag) => new Node(tag) };
function flatten(node, out = []) {
out.push(node);
for (const c of node.children) flatten(c, out);
return out;
}
globalThis.buttons = (node) => flatten(node).filter((n) => n.tag === "button");
globalThis.settle = () => new Promise((r) => setTimeout(r, 0));
// The outcome panel needs a shell host this stub does not have (see the module
// docstring): the click handler rejects AFTER the request. Recorded, not fatal.
globalThis.rejections = [];
process.on("unhandledRejection", (e) => { globalThis.rejections.push(String(e)); });
"""


@pytest.fixture
def views(tmp_path) -> Path:
"""The COMPOSED bundle's `views/`, copied into `tmp_path` and marked as an
ES-module tree (a copy has no ancestor `package.json`, so node would read the
`export` syntax as CommonJS)."""
target = tmp_path / "web"
shutil.copytree(opendox_bundle.composed(), target)
(target / "package.json").write_text('{"type": "module"}\n', encoding="utf-8")
return target / "views"


def _run_node(body: str, views: Path) -> dict:
"""Run an ES-module harness against the composed bundle and return the ONE
JSON object it prints last."""
source = (_DOM_STUB
+ f"\nconst VIEWS = {json.dumps(views.resolve().as_uri() + '/')};\n"
+ body)
script = views.parent / "harness.mjs"
script.write_text(source, encoding="utf-8")
proc = subprocess.run([NODE, str(script)], capture_output=True, text=True,
timeout=120)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])


@pytest.mark.skipif(NODE is None, reason="node is not installed")
def test_js_each_built_transport_sends_through_its_own_models_header_and_repair(
Comment thread
brettheap marked this conversation as resolved.
views) -> None:
"""BOUND. Two transports, two models that differ in exactly the two functions
`submitSession` takes from the namespace it was handed. BOTH are built before
EITHER is called, which is the interleaving a module-level read cannot survive:
the second build re-points the module-level install."""
result = _run_node("""
const column = await import(VIEWS + "swb-session.js");
const model = await import(VIEWS + "staging-workbench-model.js");
const repairs = [];
const make = (tag) => ({
...model,
consoleHeaders: (caps) => ({ "x-probe-model": tag,
"x-probe-token": String(caps.console_token) }),
withConsoleRepair: async (send, repair) => { repairs.push(tag); return send(); },
});
const sent = [];
const fetcher = async (route, opts) => {
sent.push({ route, method: opts.method, headers: opts.headers });
return { ok: true, status: 200, json: async () => ({ ok: true }) };
};
const req = { key: { repository: "openxFactory", tile_kind: "staged",
tile_id: "t-1" },
document: "d.md", content: "x" };
const first = column.firstEditTransport({
fetcher, caps: { console_token: "A" }, model: make("first") });
const second = column.firstEditTransport({
fetcher, caps: { console_token: "B" }, model: make("second") });
await first(req);
await second(req);
console.log(JSON.stringify({ sent, repairs }));
""", views)
assert [s["method"] for s in result["sent"]] == ["POST", "POST"]
assert [s["headers"] for s in result["sent"]] == [
{"x-probe-model": "first", "x-probe-token": "A"},
{"x-probe-model": "second", "x-probe-token": "B"},
], "each request must carry the header ITS OWN model built, from its own caps"
assert result["repairs"] == ["first", "second"], (
"each request must go through ITS OWN model's repair policy")


@pytest.mark.skipif(NODE is None, reason="node is not installed")
def test_js_a_mounted_form_sends_through_the_mounted_models_header_and_repair(
views) -> None:
"""UNBOUND. A session form is mounted with a model whose `consoleHeaders` and
`withConsoleRepair` are markers, an affordance is opened and its submit
pressed. With no `bound` namespace, `submitSession` reaches the model through
the module-level pair, and the request must show both markers."""
result = _run_node("""
const column = await import(VIEWS + "swb-session.js");
const model = await import(VIEWS + "staging-workbench-model.js");
const repairs = [];
const mounted = {
...model,
consoleHeaders: (caps) => ({ "x-probe-model": "mounted",
"x-probe-token": String(caps.console_token) }),
withConsoleRepair: async (send, repair) => { repairs.push("mounted"); return send(); },
};
const sent = [];
const fetcher = async (route, opts) => {
sent.push({ route, method: opts.method, headers: opts.headers });
return { ok: true, status: 200, json: async () => ({ ok: true }) };
};
const host = document.createElement("div");
const caps = { actions: { gate: true, session: true }, actor: "brett",
console_token: "tok1" };
column.mountSessionAffordances(host, null, {
model: mounted, caps, fetcher, actor: "brett",
session: { scope: { kind: "staged", id: "t-1" },
posture: { branch: "session/t-1", repository: "openxFactory" } },
});
const openers = buttons(host);
openers[0].click();
await settle();
const submits = buttons(host).filter(
(b) => !openers.includes(b) && b.textContent !== "cancel");
for (const b of submits) b.click();
await settle(); await settle();
console.log(JSON.stringify({ openers: openers.length, submits: submits.length,
sent, repairs, rejections: globalThis.rejections }));
""", views)
assert result["openers"] >= 1 and result["submits"] == 1, result

Check warning on line 198 in tests/test_swb_session_transport.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=opensoft_openXdox-code&issues=AaERUssVrIqpKK-MuLiw&open=AaERUssVrIqpKK-MuLiw&pullRequest=42
assert [s["route"] for s in result["sent"]] == ["/actions/gate/edit-document"], result
assert result["sent"][0]["method"] == "POST"
assert result["sent"][0]["headers"] == {
"x-probe-model": "mounted", "x-probe-token": "tok1"}, (
"the request must carry the header the mounted model's consoleHeaders built")
assert result["repairs"] == ["mounted"], (
"the request must go through the mounted model's repair policy")
Loading