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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
node-version: "22"
- run: npm ci
- run: npm test
- run: npx playwright install --with-deps chromium
- run: npm run test:browser

compatibility:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Add a browser test suite running the refresh modules against real Chromium and
a real Turbo build, covering `component_refresh.js`, which previously had no
tests at all. Every batching defect that reached production passed the jsdom
suite, because jsdom cannot model Turbo applying a morph, task boundaries
between socket deliveries, or abort semantics.
- Verify the database server. Each adapter reports its version against the
oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
Expand Down
10 changes: 4 additions & 6 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@
- Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
rather than assumed
- A JavaScript suite covering the state payload and batched refresh browser
modules, run in CI with Node's test runner and jsdom, with every GitHub
Actions reference pinned to a commit SHA
- A JavaScript suite covering every browser module, run in CI with Node's test
runner and jsdom, plus a browser suite running the same modules against real
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
a commit SHA

## Partially implemented

Expand All @@ -75,9 +76,6 @@
distributed per-actor rate limits and global admission control do not.
- Administration: actor and dead-letter views plus policy hooks exist; richer
filtering, audit records, and bulk-safe tools do not.
- Browser module coverage: the state payload and batched refresh modules have
JavaScript tests; `component_refresh.js`, which drives individual morph
refreshes, does not.
- Outboxes use portable status rows with polling indexes; future versions may
introduce narrow ready/claimed membership tables for very large outboxes.

Expand Down
61 changes: 60 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
"type": "module",
"description": "Browser modules for the Solid Objects Rails engine",
"scripts": {
"test": "node --test test/javascript/*.test.mjs"
"test": "node --test test/javascript/*.test.mjs",
"test:browser": "node --test test/browser/*.test.mjs"
},
"devDependencies": {
"jsdom": "^26.1.0"
"@hotwired/turbo": "^8.0.23",
"jsdom": "^26.1.0",
"playwright": "^1.62.1"
}
}
70 changes: 70 additions & 0 deletions test/browser/browser_test_helper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import http from "node:http"
import { readFile } from "node:fs/promises"
import { chromium } from "playwright"

const ROOT = new URL("../../", import.meta.url)

const FILES = {
"/turbo.js": "node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js",
"/component_refresh.js": "app/assets/javascripts/solid_objects/component_refresh.js",
"/component_batch_refresh.js": "app/assets/javascripts/solid_objects/component_batch_refresh.js",
"/state_payload.js": "app/assets/javascripts/solid_objects/state_payload.js"
}

// Serves the real browser modules alongside a real Turbo build, so tests
// exercise Turbo's own stream processing rather than a stand-in for it.
export async function startServer({ routes = {} } = {}) {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, "http://localhost")
const route = routes[url.pathname]
if (route) return route(request, response, url)

const file = FILES[url.pathname]
if (file) {
const body = await readFile(new URL(file, ROOT), "utf8")
response.writeHead(200, { "Content-Type": "text/javascript" })
return response.end(body)
}

if (url.pathname === "/") {
response.writeHead(200, { "Content-Type": "text/html" })
return response.end(page())
}

response.writeHead(404)
response.end("not found")
})

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
const { port } = server.address()
return { server, origin: `http://127.0.0.1:${port}` }
}

function page() {
return `<!doctype html>
<html>
<body>
<div id="solid-objects-scope">
<turbo-frame id="player" data-solid-objects-revision="1:8">stale player</turbo-frame>
<turbo-frame id="controls" data-solid-objects-revision="1:8">stale controls</turbo-frame>
</div>
<script type="module" src="/turbo.js"></script>
<script type="module" src="/component_refresh.js"></script>
<script type="module" src="/component_batch_refresh.js"></script>
<script type="module" src="/state_payload.js"></script>
</body>
</html>`
}

export function frameHtml(target, revision, body) {
return `<turbo-frame id="${target}" data-solid-objects-revision="${revision}">${body}</turbo-frame>`
}

export async function openPage(origin) {
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(`${origin}/`)
await page.waitForFunction(() => Boolean(customElements.get("solid-objects-refresh")))
return { browser, page }
}
142 changes: 142 additions & 0 deletions test/browser/component_refresh.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import assert from "node:assert/strict"
import { test, before, after } from "node:test"
import { startServer, openPage, frameHtml } from "./browser_test_helper.mjs"

// These exercise what jsdom cannot: Turbo's own stream processing applying a
// morph to a live document. Every batching defect that reached production
// passed the jsdom suite.
let server
let origin
let browser
let page

const responses = new Map()

before(async () => {
const started = await startServer({
routes: {
"/solid_objects/components": (request, response, url) => {
const body = responses.get(url.searchParams.get("token")) ?? ""
response.writeHead(200, { "Content-Type": "text/html" })
response.end(body)
}
}
})
server = started.server
origin = started.origin
const opened = await openPage(origin)
browser = opened.browser
page = opened.page
})

after(async () => {
await browser?.close()
server?.close()
})

async function reset() {
await page.evaluate(() => {
document.querySelectorAll("turbo-stream").forEach((node) => node.remove())
document.getElementById("player").dataset.solidObjectsRevision = "1:8"
document.getElementById("player").textContent = "stale player"
})
}

async function refresh({ target, revision, token }) {
await page.evaluate(({ target, revision, token }) => {
const element = document.createElement("solid-objects-refresh")
element.dataset.target = target
element.dataset.source = `/solid_objects/components?token=${token}&instance_id=1&revision=${revision}`
element.dataset.refreshMethod = "morph"
document.getElementById("solid-objects-scope").append(element)
}, { target, revision, token })
}

test("a morph refresh replaces the frame content in a real browser", async () => {
await reset()
responses.set("fresh", frameHtml("player", "1:9", "fresh player"))

await refresh({ target: "player", revision: 9, token: "fresh" })
await page.waitForFunction(
() => document.getElementById("player").textContent.includes("fresh player"),
null,
{ timeout: 5000 }
)

const revision = await page.evaluate(
() => document.getElementById("player").dataset.solidObjectsRevision
)
assert.equal(revision, "1:9")
})

test("a stale response cannot overwrite a newer frame", async () => {
await reset()
await page.evaluate(() => {
document.getElementById("player").dataset.solidObjectsRevision = "1:12"
document.getElementById("player").textContent = "newer player"
})
responses.set("stale", frameHtml("player", "1:9", "stale response"))

await refresh({ target: "player", revision: 9, token: "stale" })
await page.waitForTimeout(300)

const text = await page.evaluate(() => document.getElementById("player").textContent)
assert.match(text, /newer player/)
})

test("the refresh element removes itself once applied", async () => {
await reset()
responses.set("cleanup", frameHtml("player", "1:9", "applied"))

await refresh({ target: "player", revision: 9, token: "cleanup" })
await page.waitForFunction(
() => document.querySelectorAll("solid-objects-refresh").length === 0,
null,
{ timeout: 5000 }
)

const remaining = await page.evaluate(
() => document.querySelectorAll("solid-objects-refresh").length
)
assert.equal(remaining, 0)
})

test("a response without the target frame reports an error", async () => {
await reset()
responses.set("missing", "<div>no frame here</div>")
await page.evaluate(() => {
window.refreshErrors = []
document.addEventListener("solid-objects:component-refresh-error", (event) => {
window.refreshErrors.push(event.detail.reason)
})
})

await refresh({ target: "player", revision: 9, token: "missing" })
await page.waitForFunction(() => window.refreshErrors?.length > 0, null, { timeout: 5000 })

const errors = await page.evaluate(() => window.refreshErrors)
assert.deepEqual(errors, [ "missing_frame" ])
})

test("morph preserves an element marked permanent", async () => {
await reset()
await page.evaluate(() => {
const target = document.getElementById("player")
target.innerHTML = '<input id="typed" data-turbo-permanent value="">'
document.getElementById("typed").value = "user typing"
})
responses.set(
"permanent",
frameHtml("player", "1:9", '<input id="typed" data-turbo-permanent value="">')
)

await refresh({ target: "player", revision: 9, token: "permanent" })
await page.waitForFunction(
() => document.getElementById("player").dataset.solidObjectsRevision === "1:9",
null,
{ timeout: 5000 }
)

const typed = await page.evaluate(() => document.getElementById("typed")?.value)
assert.equal(typed, "user typing", "morph should preserve permanent elements")
})
Loading