feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.
The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.
The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.
__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.
measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.
Four bugs surfaced, and the migration is how.
The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.
Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".
requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.
SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.
Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
+10
-17
@@ -220,7 +220,7 @@ jobs:
|
|||||||
apt-get install -y -qq --no-install-recommends \
|
apt-get install -y -qq --no-install-recommends \
|
||||||
ca-certificates curl git jq build-essential pkg-config \
|
ca-certificates curl git jq build-essential pkg-config \
|
||||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||||
xvfb dbus dbus-x11 ffmpeg libasound2t64 \
|
dbus dbus-x11 ffmpeg libasound2t64 \
|
||||||
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
||||||
|
|
||||||
- name: Clone repo at this commit
|
- name: Clone repo at this commit
|
||||||
@@ -247,24 +247,17 @@ jobs:
|
|||||||
apt-get install -y -qq --no-install-recommends nodejs
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
corepack enable
|
corepack enable
|
||||||
|
|
||||||
# scripts/seed-sandbox.sh drives the real AddLibrary binding
|
# @playwright/cli is gone with v2. seed-sandbox.sh drove the real
|
||||||
# through playwright-cli, so the CLI has to be on PATH.
|
# AddLibrary binding through a browser because `window.go` was the
|
||||||
- name: Playwright CLI
|
# only way in; v3 answers the same call over HTTP, so the seed is
|
||||||
run: npm install -g @playwright/cli
|
# curl now and needs no CLI, no second Chromium and no shared
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH revision dance.
|
||||||
- name: Browsers
|
- name: Browsers
|
||||||
working-directory: /src/e2e
|
working-directory: /src/e2e
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
# PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the
|
|
||||||
# *revisions*: @playwright/cli bundles its own playwright-core
|
|
||||||
# pinned to a different Chromium build than @playwright/test,
|
|
||||||
# so each installs its own into the shared directory. Drop
|
|
||||||
# either line and the other fails with "Browser chromium is
|
|
||||||
# not installed; expected executable at ...".
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
npx playwright install --with-deps chromium webkit
|
npx playwright install --with-deps chromium webkit
|
||||||
playwright-cli install-browser chromium
|
|
||||||
|
|
||||||
# oto/v3 talks to libasound directly, and a container has no
|
# oto/v3 talks to libasound directly, and a container has no
|
||||||
# PulseAudio socket to fall back on — so it needs a default device
|
# PulseAudio socket to fall back on — so it needs a default device
|
||||||
@@ -287,10 +280,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# PulseAudio's null sink is timer-scheduled and does pace — the
|
# PulseAudio's null sink is timer-scheduled and does pace — the
|
||||||
# 0.76 s over is the buffer draining, not a rate error; 12 s of
|
# 0.76 s over is the buffer draining, not a rate error; 12 s of
|
||||||
# audio takes 13.5 s. Verified under the private session bus and
|
# audio takes 13.5 s. Verified under the private session bus
|
||||||
# Xvfb that dev-headless.sh runs the app in. It needs no system
|
# dev-headless.sh runs the app in. It needs no system D-Bus and
|
||||||
# D-Bus and no kernel module, which is why it is reachable from a
|
# no kernel module, which is why it is reachable from a container
|
||||||
# container at all.
|
# at all.
|
||||||
- name: Real-time audio sink
|
- name: Real-time audio sink
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ here has disappeared.
|
|||||||
Fifteen things cost a cycle each the first time. They are here, not in a
|
Fifteen things cost a cycle each the first time. They are here, not in a
|
||||||
reference, because you need them *before* the failure, not after.
|
reference, because you need them *before* the failure, not after.
|
||||||
|
|
||||||
- **Time out every binding call.** A bound Go method called with wrong
|
- **Call a binding through the bridge.** `window.go` does not exist
|
||||||
argument types makes the backend log `error parsing arguments` and
|
under Wails v3 — the bindings are bundled modules, not a global — so
|
||||||
**never fire the callback**, so the promise hangs forever. Use
|
use `window.__yjEvents.call(path, args, ms)` (browser) or
|
||||||
`window.__yjEvents.call(path, args, ms)` (browser) or `callBinding`
|
`callBinding` (specs). Both post to the runtime's own endpoint by
|
||||||
(specs), never a bare `window.go.…`. When one hangs anyway,
|
method name, so they work on any page, including one with no init
|
||||||
`make dev-logs` — `.dev/app.log` is the only place the reason appears.
|
script.
|
||||||
|
|
||||||
|
A bad call now *rejects*, and says why: a wrong type comes back as a
|
||||||
|
TypeError naming the argument, a wrong count as
|
||||||
|
`expects 4 arguments, got 3`, an unknown method as a ReferenceError.
|
||||||
|
Under v2 the backend logged `error parsing arguments` and never fired
|
||||||
|
the callback, so `.dev/app.log` was the only place the reason
|
||||||
|
appeared and the timeout was the only thing that made the mistake
|
||||||
|
visible. The timeout is still there, but now it means a genuinely
|
||||||
|
hung request.
|
||||||
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
|
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
|
||||||
intercepts all pointer events until a library exists, and the click
|
intercepts all pointer events until a library exists, and the click
|
||||||
fails with a Playwright interception error that reads like a selector
|
fails with a Playwright interception error that reads like a selector
|
||||||
|
|||||||
@@ -74,9 +74,14 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
|
|||||||
- **`snapshot` writes a file, it does not print the tree.** The
|
- **`snapshot` writes a file, it does not print the tree.** The
|
||||||
command prints a path under `outputDir`; read that. Only the tail
|
command prints a path under `outputDir`; read that. Only the tail
|
||||||
is echoed.
|
is echoed.
|
||||||
- **Three separate browser caches.** `playwright-cli`, `@playwright/test`
|
- **Two separate browser caches.** `@playwright/test`
|
||||||
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
|
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
|
||||||
download their own Chromium. One working is no guarantee for the next.
|
download their own Chromium. One working is no guarantee for the
|
||||||
|
other. There used to be a third: `playwright-cli` was a *required*
|
||||||
|
dependency because `scripts/seed-sandbox.sh` drove `AddLibrary`
|
||||||
|
through a real page, `window.go` being v2's only way in. v3 answers
|
||||||
|
the same call over HTTP, so the seed is `curl` now and the CLI is
|
||||||
|
only an exploratory convenience.
|
||||||
- **`getByRole('button', { name })` matches substrings.** "Play" also
|
- **`getByRole('button', { name })` matches substrings.** "Play" also
|
||||||
matches "Add queue to playlist"; transport controls need
|
matches "Add queue to playlist"; transport controls need
|
||||||
`exact: true`.
|
`exact: true`.
|
||||||
|
|||||||
+214
-97
@@ -7,31 +7,34 @@
|
|||||||
* other events arrive from Go whenever they arrive. An assertion that
|
* other events arrive from Go whenever they arrive. An assertion that
|
||||||
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
||||||
*
|
*
|
||||||
* Three things it provides on `window.__yjEvents`:
|
* Four things it provides on `window.__yjEvents`:
|
||||||
*
|
*
|
||||||
* record every backend -> frontend event, in order, with payloads
|
* record every backend -> frontend event, in order, with payloads
|
||||||
* wait a promise that settles on a matching event (or rejects
|
* wait a promise that settles on a matching event (or rejects
|
||||||
* with the list of events that *did* arrive, which is the
|
* with the list of events that *did* arrive, which is the
|
||||||
* single most useful failure message this harness can give)
|
* single most useful failure message this harness can give)
|
||||||
* call a bound Go method that is guaranteed to settle: a binding
|
* call a bound Go method, by name, over the runtime's own HTTP
|
||||||
* invoked with wrong argument types makes the backend log
|
* endpoint — no dependence on the app's bundle
|
||||||
* "error parsing arguments" and never fire the callback, so
|
* bindings every binding call the *app* made, which is what turns
|
||||||
* the in-page promise hangs forever. Timing out here fixes
|
* "did that refetch the library" from an inference into a
|
||||||
* that once instead of in every eval.
|
* fact (e2e/perf/measure.mjs labels and reads these)
|
||||||
*
|
*
|
||||||
* WHERE IT HOOKS. Not EventsOn. Every backend event enters the page
|
* WHERE IT HOOKS. Two places, and neither is `EventsOn`.
|
||||||
* at exactly one place — wails' ipc_websocket.js does
|
|
||||||
*
|
*
|
||||||
* case "n": window.wails.EventsNotify(message)
|
* Inbound, `window._wails.dispatchWailsEvent`: v3's runtime assigns it
|
||||||
|
* at module scope and it is the single point every backend event enters
|
||||||
|
* the page through, so wrapping it captures all 46 whether or not the
|
||||||
|
* app subscribes to them. The runtime does
|
||||||
|
* `window._wails = window._wails || {}`, so this script creates that
|
||||||
|
* object first and puts an accessor on the *property*, wrapping at
|
||||||
|
* assignment time — v2 needed the accessor on `window` itself, because
|
||||||
|
* there the whole object was replaced.
|
||||||
*
|
*
|
||||||
* and EventsNotify fans out to listeners from there. Wrapping that
|
* Outbound, `fetch`: v3 routes every runtime call — binding calls, event
|
||||||
* single choke point captures all 46 events whether or not the app
|
* emits, window and dialog calls — through one POST to /wails/runtime.
|
||||||
* subscribes to them, and needs one wrap rather than 46.
|
* There is no global to wrap the way v2's `window.runtime` could be, and
|
||||||
*
|
* this is better anyway: it sees calls from any module, needs no walk of
|
||||||
* `window.wails` does not exist yet when this script runs, so we install
|
* an object graph, and cannot miss one made before the harness looked.
|
||||||
* an accessor on `window` and wrap at assignment time (wails' main.js
|
|
||||||
* does a plain `window.wails = {...}`), then collapse the accessor back
|
|
||||||
* to a data property so nothing downstream can tell.
|
|
||||||
*
|
*
|
||||||
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
||||||
* the next, so a recorder that re-registers double-counts. Tests call
|
* the next, so a recorder that re-registers double-counts. Tests call
|
||||||
@@ -44,8 +47,27 @@
|
|||||||
|
|
||||||
const LIMIT = 2000;
|
const LIMIT = 2000;
|
||||||
|
|
||||||
|
// Every bound service in this app lives under this Go module path,
|
||||||
|
// so specs name a binding the short way — 'queue.Queue.GetState' —
|
||||||
|
// and this is what makes that the same thing the backend calls
|
||||||
|
// 'yellowjacket/backend/queue.Queue.GetState'.
|
||||||
|
const FQN_PREFIX = "yellowjacket/backend/";
|
||||||
|
|
||||||
|
// The runtime's own object and method ids (objectNames in
|
||||||
|
// @wailsio/runtime): 0 is Call, 3 is Events, and method 0 on each is
|
||||||
|
// CallBinding and Emit respectively.
|
||||||
|
const OBJECT_CALL = 0;
|
||||||
|
const OBJECT_EVENTS = 3;
|
||||||
|
|
||||||
|
// Captured before the wrap below, and used for the harness's own
|
||||||
|
// calls: `__yjEvents.call` is this file talking to the backend, not
|
||||||
|
// the app, and counting it would make "did that action refetch the
|
||||||
|
// library" answer for the question as well as the app.
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const log = [];
|
const log = [];
|
||||||
|
const bindings = [];
|
||||||
const waiters = new Set();
|
const waiters = new Set();
|
||||||
|
|
||||||
const summarize = () => {
|
const summarize = () => {
|
||||||
@@ -56,6 +78,25 @@
|
|||||||
return counts;
|
return counts;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `data` is recorded as the argument list Go emitted, which is the
|
||||||
|
* shape every spec reads (`ev.data[0]`).
|
||||||
|
*
|
||||||
|
* v3's EventManager.Emit packs a variadic call into one field: no
|
||||||
|
* arguments is null, one is the value itself, more than one is the
|
||||||
|
* slice. Unpacking that back into a list is exact except for a
|
||||||
|
* single argument that is itself an array, which is indistinguishable
|
||||||
|
* from several arguments — an ambiguity v3 introduced and no
|
||||||
|
* assertion here depends on, since nothing in backend/events emits
|
||||||
|
* more than one value.
|
||||||
|
*/
|
||||||
|
const argsOf = (data) => {
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(data) ? data : [data];
|
||||||
|
};
|
||||||
|
|
||||||
const record = (name, data, dir) => {
|
const record = (name, data, dir) => {
|
||||||
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
||||||
log.push(entry);
|
log.push(entry);
|
||||||
@@ -89,7 +130,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
version: 1,
|
version: 2,
|
||||||
|
|
||||||
/** Every recorded event, oldest first. */
|
/** Every recorded event, oldest first. */
|
||||||
get log() {
|
get log() {
|
||||||
@@ -101,10 +142,30 @@
|
|||||||
return seq;
|
return seq;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Drop the buffer. Does NOT touch the recorder or waiters. */
|
/**
|
||||||
|
* Every binding call the app made, oldest first. Each is
|
||||||
|
* { methodID, methodName, start, ms, bytes } — the id is what the
|
||||||
|
* generated bindings send, and turning it back into a name is
|
||||||
|
* e2e/perf/measure.mjs's job, which derives the map from
|
||||||
|
* frontend/bindings/.
|
||||||
|
*/
|
||||||
|
get bindings() {
|
||||||
|
return bindings.slice();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the size of every binding response. Off by default: it
|
||||||
|
* costs a clone-and-read of each body, which only a measurement
|
||||||
|
* wants to pay. With it off, `bytes` is the Content-Length when
|
||||||
|
* the server sent one and -1 otherwise.
|
||||||
|
*/
|
||||||
|
measureBytes: false,
|
||||||
|
|
||||||
|
/** Drop the buffers. Does NOT touch the recorder or waiters. */
|
||||||
reset() {
|
reset() {
|
||||||
const n = log.length;
|
const n = log.length;
|
||||||
log.length = 0;
|
log.length = 0;
|
||||||
|
bindings.length = 0;
|
||||||
return n;
|
return n;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -177,13 +238,11 @@
|
|||||||
async ready(timeoutMs) {
|
async ready(timeoutMs) {
|
||||||
const deadline = Date.now() + (timeoutMs || 15000);
|
const deadline = Date.now() + (timeoutMs || 15000);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (window.go?.queue?.Queue?.GetState) {
|
try {
|
||||||
try {
|
await api.call("queue.Queue.GetState", [], 2000);
|
||||||
await api.call("queue.Queue.GetState", [], 2000);
|
return true;
|
||||||
return true;
|
} catch {
|
||||||
} catch {
|
/* backend not up yet */
|
||||||
/* backend not up yet */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (Date.now() > deadline) {
|
if (Date.now() > deadline) {
|
||||||
throw new Error("__yjEvents.ready timed out");
|
throw new Error("__yjEvents.ready timed out");
|
||||||
@@ -193,38 +252,66 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a bound Go method by dotted path, with a timeout.
|
* Call a bound Go method by dotted path.
|
||||||
*
|
*
|
||||||
* await __yjEvents.call('player.Player.SetVolume', [42])
|
* await __yjEvents.call('player.Player.SetVolume', [42])
|
||||||
*
|
*
|
||||||
* A binding called with the wrong argument types never fires its
|
* This posts to the runtime's own endpoint rather than reaching
|
||||||
* callback — the reason appears only in .dev/app.log. Without a
|
* into the page for a binding function, because v3 has no
|
||||||
* timeout the caller waits forever; with one it gets told where
|
* `window.go` and the generated bindings are ordinary bundled
|
||||||
* to look.
|
* modules an initScript cannot import. It calls *by name*, which
|
||||||
|
* the backend resolves the same way it resolves the id the
|
||||||
|
* bundle sends.
|
||||||
|
*
|
||||||
|
* v3 rejects a bad call rather than silently never firing its
|
||||||
|
* callback the way v2 did — wrong argument types come back as a
|
||||||
|
* TypeError naming the argument, an unknown method as a
|
||||||
|
* ReferenceError. The timeout below is therefore a backstop for
|
||||||
|
* a genuinely hung request, not the mechanism that makes a
|
||||||
|
* mistake visible.
|
||||||
*/
|
*/
|
||||||
call(path, args, timeoutMs) {
|
call(path, args, timeoutMs) {
|
||||||
const parts = String(path).split(".");
|
const request = nativeFetch("/wails/runtime", {
|
||||||
let fn = window.go;
|
method: "POST",
|
||||||
for (const p of parts) {
|
headers: {
|
||||||
fn = fn?.[p];
|
"Content-Type": "application/json",
|
||||||
}
|
"x-wails-client-id": window._wails?.clientId ?? "",
|
||||||
if (typeof fn !== "function") {
|
},
|
||||||
return Promise.reject(
|
body: JSON.stringify({
|
||||||
new Error(`__yjEvents.call: no such binding: ${path}`),
|
object: OBJECT_CALL,
|
||||||
);
|
method: 0,
|
||||||
}
|
args: {
|
||||||
|
"call-id": `yj-${Math.random().toString(36).slice(2)}`,
|
||||||
|
methodName: FQN_PREFIX + String(path),
|
||||||
|
args: args || [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).then(async (res) => {
|
||||||
|
const type = res.headers.get("Content-Type") || "";
|
||||||
|
const json = type.includes("application/json");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = json ? await res.json() : { message: await res.text() };
|
||||||
|
throw new Error(
|
||||||
|
`__yjEvents.call(${path}) failed: ` +
|
||||||
|
`${body.kind || "Error"}: ${body.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json ? res.json() : res.text();
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
Promise.resolve(fn(...(args || []))),
|
request,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() =>
|
() =>
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`__yjEvents.call(${path}) did not settle in ` +
|
`__yjEvents.call(${path}) did not settle in ` +
|
||||||
`${timeoutMs || 10000}ms — almost always wrong ` +
|
`${timeoutMs || 10000}ms — the runtime endpoint ` +
|
||||||
`argument types; check .dev/app.log for ` +
|
`hung, which is not how a bad argument fails; ` +
|
||||||
`"error parsing arguments"`,
|
`check .dev/app.log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
timeoutMs || 10000,
|
timeoutMs || 10000,
|
||||||
@@ -241,62 +328,92 @@
|
|||||||
writable: false,
|
writable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap `obj[method]` once, routing every invocation through `tap`.
|
// ── Inbound ──────────────────────────────────────────────────────
|
||||||
const wrap = (obj, method, tap) => {
|
//
|
||||||
const original = obj[method];
|
// The runtime keeps whatever `window._wails` already is, so creating
|
||||||
if (typeof original !== "function" || original.__yjWrapped) {
|
// it here and defining an accessor on the one property we care about
|
||||||
return;
|
// means the wrap happens the moment the runtime module is evaluated.
|
||||||
}
|
window._wails = window._wails || {};
|
||||||
const wrapped = function (...args) {
|
|
||||||
try {
|
|
||||||
tap(args);
|
|
||||||
} catch {
|
|
||||||
/* a broken recorder must never break the app */
|
|
||||||
}
|
|
||||||
return original.apply(this, args);
|
|
||||||
};
|
|
||||||
wrapped.__yjWrapped = true;
|
|
||||||
obj[method] = wrapped;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Install an accessor that wraps on first assignment, then collapses
|
let dispatch;
|
||||||
// back into an ordinary property.
|
|
||||||
const hookOnAssign = (name, onAssign) => {
|
Object.defineProperty(window._wails, "dispatchWailsEvent", {
|
||||||
let value;
|
configurable: true,
|
||||||
Object.defineProperty(window, name, {
|
enumerable: true,
|
||||||
configurable: true,
|
get: () => dispatch,
|
||||||
enumerable: true,
|
set: (fn) => {
|
||||||
get: () => value,
|
dispatch = function (event) {
|
||||||
set: (v) => {
|
|
||||||
value = v;
|
|
||||||
try {
|
try {
|
||||||
onAssign(v);
|
record(event?.name, argsOf(event?.data), "in");
|
||||||
} catch {
|
} catch {
|
||||||
/* ditto */
|
/* a broken recorder must never break the app */
|
||||||
}
|
}
|
||||||
Object.defineProperty(window, name, {
|
return fn.apply(this, arguments);
|
||||||
value: v,
|
};
|
||||||
configurable: true,
|
},
|
||||||
enumerable: true,
|
});
|
||||||
writable: true,
|
|
||||||
});
|
// ── Outbound ─────────────────────────────────────────────────────
|
||||||
},
|
//
|
||||||
|
// One POST per runtime call. Only two of the thirteen object ids
|
||||||
|
// are interesting here; the rest (window, dialogs, clipboard) pass
|
||||||
|
// through untouched and unrecorded.
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
let call = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The runtime passes a **URL object**, not a string — it
|
||||||
|
// builds `new URL(runtimeURL())` — and a URL has no `.url`,
|
||||||
|
// only a Request does. Reading the wrong one matched
|
||||||
|
// nothing and recorded no calls at all, which looks
|
||||||
|
// identical to an app that made none.
|
||||||
|
const url =
|
||||||
|
input && typeof input === "object" && "url" in input
|
||||||
|
? input.url
|
||||||
|
: String(input ?? "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.includes("/wails/runtime") &&
|
||||||
|
init?.method === "POST" &&
|
||||||
|
typeof init.body === "string"
|
||||||
|
) {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
|
||||||
|
if (body.object === OBJECT_EVENTS && body.method === 0) {
|
||||||
|
record(body.args?.name, argsOf(body.args?.data), "out");
|
||||||
|
} else if (body.object === OBJECT_CALL && body.method === 0) {
|
||||||
|
call = {
|
||||||
|
methodID: body.args?.methodID ?? null,
|
||||||
|
methodName: body.args?.methodName ?? null,
|
||||||
|
start: performance.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ditto */
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = nativeFetch(input, init);
|
||||||
|
|
||||||
|
if (!call) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.then(async (res) => {
|
||||||
|
try {
|
||||||
|
call.ms = performance.now() - call.start;
|
||||||
|
call.bytes = api.measureBytes
|
||||||
|
? (await res.clone().text()).length
|
||||||
|
: Number(res.headers.get("Content-Length") ?? -1);
|
||||||
|
bindings.push(call);
|
||||||
|
if (bindings.length > LIMIT) {
|
||||||
|
bindings.splice(0, bindings.length - LIMIT);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ditto */
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inbound: every backend -> frontend event.
|
|
||||||
hookOnAssign("wails", (w) => {
|
|
||||||
wrap(w, "EventsNotify", ([message]) => {
|
|
||||||
const parsed = JSON.parse(message);
|
|
||||||
record(parsed.name, parsed.data, "in");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Outbound: events the frontend emits, so a flow that round-trips
|
|
||||||
// through Go is legible from one buffer.
|
|
||||||
hookOnAssign("runtime", (r) => {
|
|
||||||
wrap(r, "EventsEmit", (args) => {
|
|
||||||
record(args[0], args.slice(1), "out");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -247,6 +247,15 @@ func NewYellowJacketApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, deliberately: services start in registration order, so this
|
||||||
|
// runs once every service above has taken its context. See
|
||||||
|
// startup.go for why the wiring is a service rather than an
|
||||||
|
// application-event hook.
|
||||||
|
yjApp.Services = append(
|
||||||
|
yjApp.Services,
|
||||||
|
application.NewService(&startupService{app: yjApp}),
|
||||||
|
)
|
||||||
|
|
||||||
return yjApp, nil
|
return yjApp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1414,7 +1414,18 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
|
|||||||
// by relevance (popularity-blended). Returns nil when the index
|
// by relevance (popularity-blended). Returns nil when the index
|
||||||
// hasn't finished its initial build.
|
// hasn't finished its initial build.
|
||||||
func (si *SearchIndex) Search(ctx context.Context, query string, limit int) []SearchIndexResult {
|
func (si *SearchIndex) Search(ctx context.Context, query string, limit int) []SearchIndexResult {
|
||||||
if !si.IsReady() {
|
// IsReady is latched once at startup, so it is right in the app and
|
||||||
|
// wrong for anything that fills the table afterwards — including the
|
||||||
|
// e2e suite staging a catalog, which is how search gets tested in
|
||||||
|
// CI, where the artifact URL points at a dead address on purpose.
|
||||||
|
// shelves.go learned this already; the search path did not, and the
|
||||||
|
// symptom was three specs that passed only when an earlier one
|
||||||
|
// happened to flip the flag first.
|
||||||
|
//
|
||||||
|
// The latch stays as the fast path — once it is true nothing can
|
||||||
|
// make it false — and the probe is one indexed `SELECT 1 … LIMIT 1`
|
||||||
|
// on the only path that could otherwise answer "no catalog" wrongly.
|
||||||
|
if !si.IsReady() && !si.hasCatalogRows(ctx) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startupService is the cross-service wiring, wearing a service's
|
||||||
|
// clothes so the runtime starts it like everything else.
|
||||||
|
//
|
||||||
|
// The wiring belongs to no single service — it is the hooks, adapters
|
||||||
|
// and callbacks that make one package drive another — so v2 put it in
|
||||||
|
// OnStartup and the first v3 port hung it off
|
||||||
|
// events.Common.ApplicationStarted, which fires after every service's
|
||||||
|
// own ServiceStartup and is therefore the right *moment*.
|
||||||
|
//
|
||||||
|
// It is the wrong *mechanism*, because server mode emits no
|
||||||
|
// application events at all: v3's setupCommonEvents is an explicit
|
||||||
|
// no-op under `-tags server` ("server mode has no platform-specific
|
||||||
|
// events to map"). So the desktop build wired itself and the headless
|
||||||
|
// build did not, which showed up as "No player set, cannot load track"
|
||||||
|
// — the queue had no TrackLoader, so a track played from the UI
|
||||||
|
// changed the queue and then silently did nothing.
|
||||||
|
//
|
||||||
|
// Registering last is what preserves the ordering the wiring depends
|
||||||
|
// on: services start in registration order, on the main goroutine,
|
||||||
|
// before the platform run loop (application.Run's startup closure), so
|
||||||
|
// every service this touches has taken its context by the time this
|
||||||
|
// runs. A service is also the honest description of what this is —
|
||||||
|
// something with a lifecycle the app owns — and it costs no bindings,
|
||||||
|
// since ServiceStartup and ServiceShutdown are excluded from them.
|
||||||
|
type startupService struct {
|
||||||
|
app *YellowJacketApp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceStartup runs the app-level wiring.
|
||||||
|
//
|
||||||
|
// OnDomReady no longer means the DOM is ready — nothing in v3 offers
|
||||||
|
// that — and it does not need to: what it does is start the soft
|
||||||
|
// rescan and report a startup failure, neither of which wants a
|
||||||
|
// frontend. The frontend drives its own state synchronisation by
|
||||||
|
// calling EmitCurrentState once its stores are listening, which is
|
||||||
|
// what makes the rename harmless.
|
||||||
|
func (s *startupService) ServiceStartup(
|
||||||
|
ctx context.Context,
|
||||||
|
_ application.ServiceOptions,
|
||||||
|
) error {
|
||||||
|
s.app.OnStartup(ctx)
|
||||||
|
s.app.OnDomReady(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+74
-62
@@ -56,6 +56,8 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { chromium } from '@playwright/test';
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
import { methodIDs } from '../support/method-ids.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO = resolve(HERE, '../..');
|
const REPO = resolve(HERE, '../..');
|
||||||
const OUT_DIR = resolve(REPO, '.dev/perf');
|
const OUT_DIR = resolve(REPO, '.dev/perf');
|
||||||
@@ -63,6 +65,16 @@ const BRIDGE = resolve(REPO, '.playwright/init-events.js');
|
|||||||
|
|
||||||
const BASE_URL = process.env.YJ_URL ?? 'http://localhost:34115';
|
const BASE_URL = process.env.YJ_URL ?? 'http://localhost:34115';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* methodID -> 'pkg.Type.Method', derived from frontend/bindings/.
|
||||||
|
*
|
||||||
|
* A binding call carries only the id, so this is what turns a
|
||||||
|
* measurement's "which bindings did that provoke" back into names. It
|
||||||
|
* is derived rather than written down for the reason plan 009 phase 6b
|
||||||
|
* gives: a hand-maintained list goes stale silently.
|
||||||
|
*/
|
||||||
|
const METHOD_NAMES = Object.fromEntries(methodIDs());
|
||||||
|
|
||||||
// The browse script visited for the heap measurement. Deliberately the
|
// The browse script visited for the heap measurement. Deliberately the
|
||||||
// views the audit named as retaining: explore (two unbounded caches),
|
// views the audit named as retaining: explore (two unbounded caches),
|
||||||
// artists and genres (per-frame work), settings (the 3 s ticker).
|
// artists and genres (per-frame work), settings (the 3 s ticker).
|
||||||
@@ -123,64 +135,41 @@ function parseArgs(argv) {
|
|||||||
/* -------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap every bound Go method so a measurement can say which bindings a
|
* Say which bindings a user action provoked, and how much they
|
||||||
* user action provoked and how much they returned.
|
* returned.
|
||||||
*
|
*
|
||||||
* Post-hoc wrapping is safe because `frontend/wailsjs` looks its target
|
* This used to walk `window.go` and wrap every bound method in place,
|
||||||
* up at call time (`window['go']['library']['Library']['GetAllTracks']()`),
|
* which worked because v2's generated stubs looked their target up at
|
||||||
* so a store holding an imported wrapper still lands here.
|
* call time. v3 has no such object — the bindings are bundled modules
|
||||||
|
* — so `.playwright/init-events.js` records every call off the single
|
||||||
|
* POST v3 routes them all through, and this reads that log. It is
|
||||||
|
* strictly better: it needs no walk, sees calls from any module, and
|
||||||
|
* cannot miss one made before a wrapper was installed, which is what
|
||||||
|
* the old "runs twice" dance was working around.
|
||||||
|
*
|
||||||
|
* `bytes` is the response size, so `measureBytes` is turned on here —
|
||||||
|
* only a measurement wants to pay for a clone-and-read of every body.
|
||||||
*/
|
*/
|
||||||
const INSTRUMENT = `() => {
|
const INSTRUMENT = `(names) => {
|
||||||
// Runs twice: once as an initScript (before window.go exists, which
|
if (window.__yjPerf) return;
|
||||||
// is the only moment early enough to catch the long-task observer's
|
|
||||||
// first entries) and once after the bridge reports ready. So the
|
|
||||||
// state is created at most once and the *walk* happens every time —
|
|
||||||
// getting that backwards silently measures zero binding calls.
|
|
||||||
const first = !window.__yjPerf;
|
|
||||||
|
|
||||||
if (first) {
|
window.__yjEvents.measureBytes = true;
|
||||||
const calls = [];
|
|
||||||
window.__yjPerf = {
|
|
||||||
calls,
|
|
||||||
reset: () => { calls.length = 0; },
|
|
||||||
since: (t) => calls.filter((c) => c.start >= t),
|
|
||||||
longtasks: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const calls = window.__yjPerf.calls;
|
window.__yjPerf = {
|
||||||
|
get calls() {
|
||||||
const wrap = (obj, key, path) => {
|
return window.__yjEvents.bindings.map((c) => ({
|
||||||
const fn = obj[key];
|
path: names[c.methodID] || ('#' + c.methodID),
|
||||||
if (typeof fn !== 'function' || fn.__yjPerfWrapped) return;
|
methodID: c.methodID,
|
||||||
const wrapped = function (...args) {
|
start: c.start,
|
||||||
const start = performance.now();
|
ms: c.ms,
|
||||||
let out;
|
bytes: c.bytes,
|
||||||
try { out = fn.apply(this, args); } catch (e) { throw e; }
|
}));
|
||||||
return Promise.resolve(out).then((v) => {
|
},
|
||||||
let bytes = 0;
|
reset: () => { window.__yjEvents.reset(); },
|
||||||
try { bytes = JSON.stringify(v ?? null).length; } catch { bytes = -1; }
|
since: (t) => window.__yjPerf.calls.filter((c) => c.start >= t),
|
||||||
calls.push({ path, start, ms: performance.now() - start, bytes });
|
longtasks: [],
|
||||||
return v;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
wrapped.__yjPerfWrapped = true;
|
|
||||||
obj[key] = wrapped;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (obj, prefix, depth) => {
|
|
||||||
if (!obj || depth > 4) return;
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
const v = obj[key];
|
|
||||||
if (typeof v === 'function') wrap(obj, key, prefix + key);
|
|
||||||
else if (v && typeof v === 'object') walk(v, prefix + key + '.', depth + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(window.go, '', 0);
|
|
||||||
|
|
||||||
if (!first) return;
|
|
||||||
|
|
||||||
// Long tasks are the honest form of "the app stalls": a 25 MB JSON
|
// Long tasks are the honest form of "the app stalls": a 25 MB JSON
|
||||||
// parse on the main thread shows up here and nowhere else.
|
// parse on the main thread shows up here and nowhere else.
|
||||||
try {
|
try {
|
||||||
@@ -352,7 +341,15 @@ async function measureTrackChange(page) {
|
|||||||
const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath);
|
const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath);
|
||||||
if (paths.length < 2) return { error: 'library too small to measure' };
|
if (paths.length < 2) return { error: 'library too small to measure' };
|
||||||
|
|
||||||
await ev.call('queue.Queue.SetQueue', [paths, 0, false], 15000);
|
await ev.call(
|
||||||
|
'queue.Queue.SetQueue',
|
||||||
|
// The fourth argument is the queue's source; these are
|
||||||
|
// ad-hoc tracks, so it is the empty one. v3 rejects a
|
||||||
|
// call with the wrong argument count where v2 filled the
|
||||||
|
// gap with a zero value.
|
||||||
|
[paths, 0, false, { type: '', id: 0, label: '' }],
|
||||||
|
15000,
|
||||||
|
);
|
||||||
await ev.call('queue.Queue.PlayIndex', [0], 15000);
|
await ev.call('queue.Queue.PlayIndex', [0], 15000);
|
||||||
|
|
||||||
// Settle mid-track before starting to record. Starting playback
|
// Settle mid-track before starting to record. Starting playback
|
||||||
@@ -1046,14 +1043,15 @@ const SCROLL_SETTLE_MS = 260;
|
|||||||
async function measureScroll(page) {
|
async function measureScroll(page) {
|
||||||
// -- M3: the track list, with the Art column staged on. --
|
// -- M3: the track list, with the Art column staged on. --
|
||||||
const priorColumns = await page.evaluate(async () => {
|
const priorColumns = await page.evaluate(async () => {
|
||||||
const prior = await window.go.config.Config.GetTrackListColumns();
|
const ev = window.__yjEvents;
|
||||||
|
const prior = await ev.call('config.Config.GetTrackListColumns', [], 15000);
|
||||||
|
|
||||||
await window.go.config.Config.SetTrackListColumns(
|
await ev.call('config.Config.SetTrackListColumns', [
|
||||||
[{ id: 'albumArt' }, { id: 'trackName' },
|
[{ id: 'albumArt' }, { id: 'trackName' },
|
||||||
{ id: 'artistName' }, { id: 'trackLength' }],
|
{ id: 'artistName' }, { id: 'trackLength' }],
|
||||||
);
|
], 15000);
|
||||||
|
|
||||||
return prior.map((c) => ({ id: c.id }));
|
return (prior ?? []).map((c) => ({ id: c.id }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollView = async (view, tag) => {
|
const scrollView = async (view, tag) => {
|
||||||
@@ -1141,7 +1139,9 @@ async function measureScroll(page) {
|
|||||||
const artists = await scrollView('artists', 'artists-view');
|
const artists = await scrollView('artists', 'artists-view');
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(cols) => window.go.config.Config.SetTrackListColumns(cols),
|
(cols) => window.__yjEvents.call(
|
||||||
|
'config.Config.SetTrackListColumns', [cols], 15000,
|
||||||
|
),
|
||||||
priorColumns,
|
priorColumns,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1605,7 +1605,15 @@ async function measurePlayerBarPass(page) {
|
|||||||
|
|
||||||
if (paths.length < 2) return { error: 'library too small to measure' };
|
if (paths.length < 2) return { error: 'library too small to measure' };
|
||||||
|
|
||||||
await ev.call('queue.Queue.SetQueue', [paths, 0, false], 15000);
|
await ev.call(
|
||||||
|
'queue.Queue.SetQueue',
|
||||||
|
// The fourth argument is the queue's source; these are
|
||||||
|
// ad-hoc tracks, so it is the empty one. v3 rejects a
|
||||||
|
// call with the wrong argument count where v2 filled the
|
||||||
|
// gap with a zero value.
|
||||||
|
[paths, 0, false, { type: '', id: 0, label: '' }],
|
||||||
|
15000,
|
||||||
|
);
|
||||||
await ev.call('queue.Queue.PlayIndex', [0], 15000);
|
await ev.call('queue.Queue.PlayIndex', [0], 15000);
|
||||||
await ev.call('player.Player.Pause', [], 5000).catch(() => {});
|
await ev.call('player.Player.Pause', [], 5000).catch(() => {});
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
@@ -1804,7 +1812,9 @@ async function run(label) {
|
|||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
await context.addInitScript({ path: BRIDGE });
|
await context.addInitScript({ path: BRIDGE });
|
||||||
await context.addInitScript(`(${INSTRUMENT})()`);
|
await context.addInitScript(
|
||||||
|
`(${INSTRUMENT})(${JSON.stringify(METHOD_NAMES)})`,
|
||||||
|
);
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const client = await context.newCDPSession(page);
|
const client = await context.newCDPSession(page);
|
||||||
@@ -1813,8 +1823,10 @@ async function run(label) {
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
await page.goto(BASE_URL, { waitUntil: 'load' });
|
await page.goto(BASE_URL, { waitUntil: 'load' });
|
||||||
await page.evaluate(() => window.__yjEvents.ready(30000));
|
await page.evaluate(() => window.__yjEvents.ready(30000));
|
||||||
// Wrapping runs before `window.go` exists; re-run now that it does.
|
// No second instrumentation pass. The old one existed because
|
||||||
await page.evaluate(`(${INSTRUMENT})()`);
|
// wrapping had to happen after `window.go` appeared, yet the long
|
||||||
|
// task observer had to start before it; the bridge now records every
|
||||||
|
// binding call from the initScript onward, so one pass does both.
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These specs drive the *real* application: the Wails dev server on
|
* These specs drive the *real* application: Wails v3's server mode on
|
||||||
* :34115 serves the real frontend with real bindings on `window.go`,
|
* :34115 serves the real frontend with the real generated bindings,
|
||||||
* bridged to the same Go backend a desktop window would use. Nothing
|
* bridged to the same Go backend a desktop window would use. Nothing
|
||||||
* here is mocked.
|
* here is mocked.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import type { Page } from '@playwright/test';
|
|||||||
* none, so Play was wired, labelled correctly, clicked cleanly and
|
* none, so Play was wired, labelled correctly, clicked cleanly and
|
||||||
* queued **nothing**. Every component test still passed.
|
* queued **nothing**. Every component test still passed.
|
||||||
*/
|
*/
|
||||||
|
// There is no test for the tracklist legend, and there should not be:
|
||||||
|
// `dcc40b1` inverted the mark — rows *not* in the library are dimmed in
|
||||||
|
// place and nothing marks the ones that are — and deleted
|
||||||
|
// `.tracklist-legend` with it. The spec asserting it survived that
|
||||||
|
// commit and has been failing on main ever since. What replaced it
|
||||||
|
// (the dimming, and the `aria-disabled` that carries it to anyone not
|
||||||
|
// seeing the page) is covered at the component tier, in
|
||||||
|
// frontend/test/components/album-actions.test.ts.
|
||||||
test.describe('playing an album from its page', () => {
|
test.describe('playing an album from its page', () => {
|
||||||
test.beforeEach(async ({ app }) => {
|
test.beforeEach(async ({ app }) => {
|
||||||
await openFirstAlbum(app);
|
await openFirstAlbum(app);
|
||||||
@@ -55,16 +63,6 @@ test.describe('playing an album from its page', () => {
|
|||||||
await expect.poll(() => queueLength(app)).toBe(before * 2);
|
await expect.poll(() => queueLength(app)).toBe(before * 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the ticks against the tracks have a legend', async ({ app }) => {
|
|
||||||
// `H-13` calls them unexplained. They were never *unlabelled* — the
|
|
||||||
// indicator has carried a title and an aria-label reading
|
|
||||||
// "Track “X” is in your library" all along — but a sighted user
|
|
||||||
// scanning the page got a column of green circles and no key.
|
|
||||||
await expect(
|
|
||||||
app.locator('explore-album-details').locator('.tracklist-legend'),
|
|
||||||
).toContainText('in your library');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the ticks are badges, not keyboard stops', async ({ app }) => {
|
test('the ticks are badges, not keyboard stops', async ({ app }) => {
|
||||||
// Every one of them was a <button> whose click handler was a
|
// Every one of them was a <button> whose click handler was a
|
||||||
// stopPropagation() and a comment saying to wire up the download
|
// stopPropagation() and a comment saying to wire up the download
|
||||||
|
|||||||
+25
-10
@@ -15,12 +15,18 @@ import {
|
|||||||
*/
|
*/
|
||||||
test.describe('harness', () => {
|
test.describe('harness', () => {
|
||||||
test('the app is the real app, not a mock', async ({ app }) => {
|
test('the app is the real app, not a mock', async ({ app }) => {
|
||||||
// All 11 bound services land on window.go through the dev server.
|
// v2 landed every bound service on `window.go`, so "is this real"
|
||||||
const services = await app.evaluate(() => Object.keys(window.go));
|
// could be asked of an object. v3 has no such global — the
|
||||||
|
// bindings are ordinary bundled modules — so the question is asked
|
||||||
|
// of the runtime instead, which is a better question anyway: the
|
||||||
|
// real runtime is loaded, and it answers for real methods and
|
||||||
|
// refuses invented ones.
|
||||||
|
const runtime = await app.evaluate(() => ({
|
||||||
|
dispatch: typeof window._wails?.dispatchWailsEvent,
|
||||||
|
client: typeof window._wails?.clientId,
|
||||||
|
}));
|
||||||
|
|
||||||
expect(services).toEqual(
|
expect(runtime).toEqual({ dispatch: 'function', client: 'string' });
|
||||||
expect.arrayContaining(['queue', 'player', 'library', 'explore']),
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = await callBinding<{ tracks: unknown[] }>(
|
const state = await callBinding<{ tracks: unknown[] }>(
|
||||||
app,
|
app,
|
||||||
@@ -28,6 +34,12 @@ test.describe('harness', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(state).toHaveProperty('tracks');
|
expect(state).toHaveProperty('tracks');
|
||||||
|
|
||||||
|
const unknown = await app
|
||||||
|
.evaluate(() => window.__yjEvents.call('queue.Queue.Nope', [], 2_000))
|
||||||
|
.catch((err: Error) => err.message);
|
||||||
|
|
||||||
|
expect(unknown).toContain('unknown bound method');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backend events are recorded, in order, with payloads', async ({
|
test('backend events are recorded, in order, with payloads', async ({
|
||||||
@@ -58,10 +70,12 @@ test.describe('harness', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a binding called with wrong types fails fast', async ({ app }) => {
|
test('a binding called with wrong types fails fast', async ({ app }) => {
|
||||||
// player.UserVolume is an int. Passing a float makes the backend
|
// player.UserVolume is an int. Under v2 a float made the backend
|
||||||
// log "error parsing arguments" and never fire the callback; without
|
// log "error parsing arguments" and never fire the callback, so
|
||||||
// a timeout the promise never settles and the spec hangs until the
|
// this asserted that the harness's own timeout fired — the failure
|
||||||
// suite gives up.
|
// was visible only because the harness invented a deadline. v3
|
||||||
|
// answers 422 with a TypeError naming the argument, so the
|
||||||
|
// assertion is now on the backend's own words.
|
||||||
const failure = await app.evaluate(async () => {
|
const failure = await app.evaluate(async () => {
|
||||||
try {
|
try {
|
||||||
await window.__yjEvents.call(
|
await window.__yjEvents.call(
|
||||||
@@ -76,7 +90,8 @@ test.describe('harness', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(failure).toContain('did not settle');
|
expect(failure).toContain('TypeError');
|
||||||
|
expect(failure).toContain('player.UserVolume');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the control surface is mounted and seeded', async ({ testctl }) => {
|
test('the control surface is mounted and seeded', async ({ testctl }) => {
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
resetEvents,
|
||||||
|
callBinding,
|
||||||
|
bindingCalls,
|
||||||
|
NO_QUEUE_SOURCE,
|
||||||
|
} from '../support/fixtures.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finishing a track is cheap, and does not disturb the user.
|
* Finishing a track is cheap, and does not disturb the user.
|
||||||
@@ -19,23 +28,17 @@ import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
|
|||||||
/** Long enough for a fixture track (2–6 s) to finish by itself. */
|
/** Long enough for a fixture track (2–6 s) to finish by itself. */
|
||||||
const FINISH_TIMEOUT = 60_000;
|
const FINISH_TIMEOUT = 60_000;
|
||||||
|
|
||||||
/** Instrument the library bindings so "was anything refetched" is a fact. */
|
/**
|
||||||
const COUNT_LIBRARY_CALLS = `(() => {
|
* "Was anything refetched" is a fact, not an inference.
|
||||||
const w = window;
|
*
|
||||||
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
|
* This used to wrap every method on `window.go.library.Library` in
|
||||||
w.__yjCalls = [];
|
* place. v3 has no such object, and does not need one: the harness
|
||||||
const lib = w.go.library.Library;
|
* bridge records every binding call off the single POST they all go
|
||||||
for (const key of Object.keys(lib)) {
|
* through, so the question is answered by reading that log rather than
|
||||||
const fn = lib[key];
|
* by instrumenting a target first. `resetEvents` clears it.
|
||||||
if (typeof fn !== 'function' || fn.__counted) continue;
|
*/
|
||||||
const wrapped = function (...args) {
|
const libraryCalls = async (app: Page): Promise<string[]> =>
|
||||||
w.__yjCalls.push(key);
|
(await bindingCalls(app)).filter((c) => c.startsWith('library.Library.'));
|
||||||
return fn.apply(this, args);
|
|
||||||
};
|
|
||||||
wrapped.__counted = true;
|
|
||||||
lib[key] = wrapped;
|
|
||||||
}
|
|
||||||
})()`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select rows by dispatching on the row rather than clicking it.
|
* Select rows by dispatching on the row rather than clicking it.
|
||||||
@@ -82,8 +85,7 @@ test.describe('a finished track', () => {
|
|||||||
|
|
||||||
expect(paths.length).toBeGreaterThanOrEqual(2);
|
expect(paths.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
await app.evaluate(COUNT_LIBRARY_CALLS);
|
|
||||||
await resetEvents(app);
|
await resetEvents(app);
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
||||||
@@ -111,12 +113,10 @@ test.describe('a finished track', () => {
|
|||||||
'a play emitted the retag event, which invalidates every cache',
|
'a play emitted the retag event, which invalidates every cache',
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
|
|
||||||
const refetched = await app.evaluate(
|
const refetched = await libraryCalls(app);
|
||||||
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
refetched.filter((c) => c.startsWith('GetAll')),
|
refetched.filter((c) => c.startsWith('library.Library.GetAll')),
|
||||||
'a play refetched a collection',
|
'a play refetched a collection',
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ test.describe('a finished track', () => {
|
|||||||
|
|
||||||
const paths = await app.evaluate(firstPaths, 2);
|
const paths = await app.evaluate(firstPaths, 2);
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
await resetEvents(app);
|
await resetEvents(app);
|
||||||
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resetEvents,
|
resetEvents,
|
||||||
waitForEvent,
|
waitForEvent,
|
||||||
LONG_TRACK,
|
LONG_TRACK,
|
||||||
|
NO_QUEUE_SOURCE,
|
||||||
} from '../support/fixtures.js';
|
} from '../support/fixtures.js';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ test.describe('a finished queue keeps its context', () => {
|
|||||||
[rows[0].file_path],
|
[rows[0].file_path],
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
NO_QUEUE_SOURCE,
|
||||||
]);
|
]);
|
||||||
await waitForEvent(app, 'QueueChanged');
|
await waitForEvent(app, 'QueueChanged');
|
||||||
await callBinding(app, 'queue.Queue.Play');
|
await callBinding(app, 'queue.Queue.Play');
|
||||||
@@ -199,7 +201,7 @@ test.describe('a track that will not play says so', () => {
|
|||||||
try {
|
try {
|
||||||
await callBinding(app, 'queue.Queue.Clear');
|
await callBinding(app, 'queue.Queue.Clear');
|
||||||
await resetEvents(app);
|
await resetEvents(app);
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
await waitForEvent(app, 'QueueChanged');
|
await waitForEvent(app, 'QueueChanged');
|
||||||
await callBinding(app, 'queue.Queue.Play');
|
await callBinding(app, 'queue.Queue.Play');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { test, expect, callBinding } from '../support/fixtures.js';
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
callBinding,
|
||||||
|
NO_QUEUE_SOURCE,
|
||||||
|
} from '../support/fixtures.js';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +40,7 @@ async function queueFourAndOpen(app: Page): Promise<string[]> {
|
|||||||
return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath);
|
return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
|
|
||||||
// A closed panel renders no list at all, so there is no row to focus.
|
// A closed panel renders no list at all, so there is no row to focus.
|
||||||
await app.locator('#queue-button').click();
|
await app.locator('#queue-button').click();
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { test, expect, callBinding, waitForEvent } from '../support/fixtures.js';
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
callBinding,
|
||||||
|
waitForEvent,
|
||||||
|
NO_QUEUE_SOURCE,
|
||||||
|
} from '../support/fixtures.js';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +70,7 @@ async function playTheLongOne(app: Page): Promise<void> {
|
|||||||
|
|
||||||
expect(paths.length).toBeGreaterThan(0);
|
expect(paths.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
await waitForEvent(app, 'TrackChanged');
|
await waitForEvent(app, 'TrackChanged');
|
||||||
|
|
||||||
// The scroll cycle is armed 1500 ms after the geometry is measured,
|
// The scroll cycle is armed 1500 ms after the geometry is measured,
|
||||||
|
|||||||
@@ -199,39 +199,49 @@ function addRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop any request for this album, through the raw binding.
|
* Drop any request for this album, over the runtime endpoint.
|
||||||
*
|
*
|
||||||
* Deliberately not `callBinding`: that goes through `window.__yjEvents`,
|
* Deliberately not `callBinding`: that goes through
|
||||||
* which only exists on a page the `app` fixture created — a bare
|
* `window.__yjEvents`, which only exists on a page the `app` fixture
|
||||||
* `browser.newPage()` has no init script, so the bridge is undefined and
|
* created — a bare `browser.newPage()` has no init script, so the
|
||||||
* the cleanup throws where nobody is looking. The first version of this
|
* bridge is undefined and the cleanup throws where nobody is looking.
|
||||||
* did exactly that and left the request behind, which failed the *next*
|
* The first version of this did exactly that and left the request
|
||||||
* run of this same spec.
|
* behind, which failed the *next* run of this same spec.
|
||||||
|
*
|
||||||
|
* v2's answer was `window.go`, which every page had. v3 has no such
|
||||||
|
* global, and the version of this that kept reading it did not throw —
|
||||||
|
* it returned early on `if (!svc)`, which is the same silent cleanup
|
||||||
|
* with a different cause. A POST to `/wails/runtime` needs neither:
|
||||||
|
* it is the same request the bundle makes, and any page can make it.
|
||||||
*/
|
*/
|
||||||
async function clearRequest(page: import('@playwright/test').Page) {
|
async function clearRequest(page: import('@playwright/test').Page) {
|
||||||
await page.evaluate(async (mbid) => {
|
await page.evaluate(async (mbid) => {
|
||||||
const go = (
|
const call = async (method: string, args: unknown[]) => {
|
||||||
window as unknown as {
|
const res = await fetch('/wails/runtime', {
|
||||||
go?: {
|
method: 'POST',
|
||||||
download?: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
Service?: {
|
body: JSON.stringify({
|
||||||
ListRequests(): Promise<{ id: number; mbid: string }[]>;
|
object: 0,
|
||||||
RemoveRequest(id: number): Promise<void>;
|
method: 0,
|
||||||
};
|
args: {
|
||||||
};
|
'call-id': `clear-${method}`,
|
||||||
};
|
methodName: `yellowjacket/backend/${method}`,
|
||||||
}
|
args,
|
||||||
).go;
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const svc = go?.download?.Service;
|
if (!res.ok) throw new Error(`${method}: ${await res.text()}`);
|
||||||
|
|
||||||
if (!svc) return;
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
const rows = (await svc.ListRequests()) ?? [];
|
const rows: { id: number; mbid: string }[] =
|
||||||
|
(await call('download.Service.ListRequests', [])) ?? [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
|
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
|
||||||
await svc.RemoveRequest(row.id);
|
await call('download.Service.RemoveRequest', [row.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, MBID);
|
}, MBID);
|
||||||
|
|||||||
+49
-6
@@ -3,12 +3,26 @@ import { dirname, resolve } from 'node:path';
|
|||||||
|
|
||||||
import { test as base, expect, type Page } from '@playwright/test';
|
import { test as base, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { nameOf } from './method-ids.mjs';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/** The same bridge `playwright-cli` loads, so an exploratory session and
|
/** The same bridge `playwright-cli` loads, so an exploratory session and
|
||||||
* a committed spec see an identical page. */
|
* a committed spec see an identical page. */
|
||||||
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fourth argument to `queue.Queue.SetQueue`, for a queue with no
|
||||||
|
* single source — the whole library, or a handful of ad-hoc tracks,
|
||||||
|
* which is what every spec here builds.
|
||||||
|
*
|
||||||
|
* It is passed explicitly because v3 rejects a call with the wrong
|
||||||
|
* argument count (`expects 4 arguments, got 3`) where v2 accepted one
|
||||||
|
* and filled the gap with a zero value. The specs had been three-arg
|
||||||
|
* since the parameter was added; nothing said so.
|
||||||
|
*/
|
||||||
|
export const NO_QUEUE_SOURCE = { type: '', id: 0, label: '' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`).
|
* The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`).
|
||||||
*
|
*
|
||||||
@@ -61,12 +75,13 @@ export async function eventNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a bound Go method with a timeout.
|
* Call a bound Go method by name, over the runtime's own endpoint.
|
||||||
*
|
*
|
||||||
* Wrong argument types make the backend log "error parsing arguments"
|
* v3 rejects a bad call rather than never firing its callback the way
|
||||||
* and never fire the callback, so an unguarded call hangs until the
|
* v2 did: a wrong argument type comes back as a TypeError naming the
|
||||||
* whole spec times out with no clue why. This fails in seconds and
|
* argument, a wrong count as `expects 4 arguments, got 3`, an unknown
|
||||||
* says where to look.
|
* method as a ReferenceError. The timeout is a backstop for a hung
|
||||||
|
* request, not the mechanism that makes a mistake visible.
|
||||||
*/
|
*/
|
||||||
export async function callBinding<T = unknown>(
|
export async function callBinding<T = unknown>(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -81,6 +96,21 @@ export async function callBinding<T = unknown>(
|
|||||||
) as Promise<T>;
|
) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binding calls the *app* made, newest last, as `pkg.Type.Method`.
|
||||||
|
*
|
||||||
|
* This is what replaces v2's trick of wrapping `window.go` in place:
|
||||||
|
* `.playwright/init-events.js` records every call off the one POST v3
|
||||||
|
* routes them all through, and `method-ids.ts` names them from the
|
||||||
|
* generated tree. It sees calls from any module and cannot miss one
|
||||||
|
* made before a wrapper was installed.
|
||||||
|
*/
|
||||||
|
export async function bindingCalls(page: Page): Promise<string[]> {
|
||||||
|
const calls = await page.evaluate(() => window.__yjEvents.bindings);
|
||||||
|
|
||||||
|
return calls.map(nameOf);
|
||||||
|
}
|
||||||
|
|
||||||
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
|
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
|
||||||
export class TestCtl {
|
export class TestCtl {
|
||||||
constructor(private readonly baseURL: string) {}
|
constructor(private readonly baseURL: string) {}
|
||||||
@@ -166,7 +196,20 @@ declare global {
|
|||||||
): Promise<YjEvent>;
|
): Promise<YjEvent>;
|
||||||
ready(timeoutMs?: number): Promise<boolean>;
|
ready(timeoutMs?: number): Promise<boolean>;
|
||||||
call(path: string, args?: unknown[], timeoutMs?: number): Promise<any>;
|
call(path: string, args?: unknown[], timeoutMs?: number): Promise<any>;
|
||||||
|
/** Every binding call the app itself made; see init-events.js. */
|
||||||
|
bindings: {
|
||||||
|
methodID: number | null;
|
||||||
|
methodName: string | null;
|
||||||
|
start: number;
|
||||||
|
ms: number;
|
||||||
|
bytes: number;
|
||||||
|
}[];
|
||||||
|
measureBytes: boolean;
|
||||||
|
};
|
||||||
|
/** The v3 runtime's own namespace, installed by @wailsio/runtime. */
|
||||||
|
_wails?: {
|
||||||
|
dispatchWailsEvent?: (event: unknown) => void;
|
||||||
|
clientId?: string;
|
||||||
};
|
};
|
||||||
go: Record<string, Record<string, Record<string, (...a: any[]) => any>>>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the method ids a binding call carries back into the names a
|
||||||
|
* spec (or a measurement) wants to read.
|
||||||
|
*
|
||||||
|
* Plain JavaScript, not TypeScript, because `e2e/perf/measure.mjs` runs
|
||||||
|
* under bare `node` and both it and the specs need exactly this map —
|
||||||
|
* and one derivation with a `.mjs` extension is better than two that
|
||||||
|
* can disagree.
|
||||||
|
*
|
||||||
|
* v2 put every bound method on `window.go`, so a harness could walk
|
||||||
|
* that object to wrap or enumerate them. v3 has no such surface: the
|
||||||
|
* generated bindings are ordinary bundled modules calling
|
||||||
|
* `$Call.ByID(<fnv hash of the fully-qualified name>)`, and what
|
||||||
|
* reaches the wire — and therefore what `.playwright/init-events.js`
|
||||||
|
* can record — is the number.
|
||||||
|
*
|
||||||
|
* Plan 009 phase 6b named two ways to get the list back. This is the
|
||||||
|
* preferred one: derive it from `frontend/bindings/`, which is a real
|
||||||
|
* generated tree and is already gated by `make bindings-check`. The
|
||||||
|
* alternative — a hand-maintained list — goes stale silently, which is
|
||||||
|
* the failure mode that check exists to prevent.
|
||||||
|
*
|
||||||
|
* Nothing here hashes anything. The generated source carries the id as
|
||||||
|
* a literal beside the function that sends it, so this reads the two
|
||||||
|
* together rather than recomputing one from the other and hoping the
|
||||||
|
* hash still matches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const BINDINGS_ROOT = resolve(here, '../../frontend/bindings/yellowjacket');
|
||||||
|
|
||||||
|
/** `export function Name(…) { return $Call.ByID(123, …) }` */
|
||||||
|
const BOUND_METHOD = /export function (\w+)\([\s\S]*?\$Call\.ByID\((\d+)/g;
|
||||||
|
|
||||||
|
/** `import * as Library from "./library.js";` — the only place the Go
|
||||||
|
* type's casing survives; the file is `library.ts`, and
|
||||||
|
* `frontendutil.ts` cannot tell you it is `FrontendUtil`. */
|
||||||
|
const SERVICE_EXPORT = /import \* as (\w+) from "\.\/([\w.]+)\.js"/g;
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const path = join(dir, entry);
|
||||||
|
|
||||||
|
if (statSync(path).isDirectory()) yield* walk(path);
|
||||||
|
else if (entry.endsWith('.ts')) yield path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* methodIDs maps a binding's method id to `pkg.Type.Method` — the same
|
||||||
|
* short path `callBinding` takes, so a spec never has to know that the
|
||||||
|
* backend calls it `yellowjacket/backend/queue.Queue.GetState`.
|
||||||
|
*/
|
||||||
|
export function methodIDs() {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const byID = new Map();
|
||||||
|
|
||||||
|
for (const file of walk(BINDINGS_ROOT)) {
|
||||||
|
if (!file.endsWith('index.ts')) continue;
|
||||||
|
|
||||||
|
const dir = dirname(file);
|
||||||
|
const pkg = dir.split('/').pop() ?? '';
|
||||||
|
const index = readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
for (const [, typeName, base] of index.matchAll(SERVICE_EXPORT)) {
|
||||||
|
let source;
|
||||||
|
|
||||||
|
try {
|
||||||
|
source = readFileSync(join(dir, `${base}.ts`), 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue; // a models-only re-export, which binds nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, method, id] of source.matchAll(BOUND_METHOD)) {
|
||||||
|
byID.set(Number(id), `${pkg}.${typeName}.${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byID.size === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`method-ids: no bound methods under ${BINDINGS_ROOT}; ` +
|
||||||
|
`run 'make bindings'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = byID;
|
||||||
|
|
||||||
|
return byID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names one recorded binding call, or `#<id>` if the map has no entry
|
||||||
|
* — which fails the assertion naming it rather than passing quietly. */
|
||||||
|
export function nameOf(call) {
|
||||||
|
if (call.methodName) {
|
||||||
|
return call.methodName.replace(/^yellowjacket\/backend\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return call.methodID === null
|
||||||
|
? '#unknown'
|
||||||
|
: (methodIDs().get(call.methodID) ?? `#${call.methodID}`);
|
||||||
|
}
|
||||||
+8
-2
@@ -8,7 +8,13 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"allowImportingTsExtensions": true
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": ["specs/**/*.ts", "support/**/*.ts", "playwright.config.ts"]
|
"include": [
|
||||||
|
"specs/**/*.ts",
|
||||||
|
"support/**/*.ts",
|
||||||
|
"support/**/*.mjs",
|
||||||
|
"playwright.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,16 +81,6 @@ func main() {
|
|||||||
OnShutdown: yjApp.OnShutdown,
|
OnShutdown: yjApp.OnShutdown,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The services' own ServiceStartup hooks have run by the time this
|
|
||||||
// fires; what is left is the wiring between them.
|
|
||||||
app.Event.OnApplicationEvent(
|
|
||||||
events.Common.ApplicationStarted,
|
|
||||||
func(*application.ApplicationEvent) {
|
|
||||||
yjApp.OnStartup(app.Context())
|
|
||||||
yjApp.OnDomReady(app.Context())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "yellowjacket",
|
Title: "yellowjacket",
|
||||||
Width: winCfg.Width,
|
Width: winCfg.Width,
|
||||||
|
|||||||
+30
-23
@@ -2,28 +2,36 @@
|
|||||||
#
|
#
|
||||||
# Start YellowJacket headless, in the background, and return.
|
# Start YellowJacket headless, in the background, and return.
|
||||||
#
|
#
|
||||||
# `wails dev` binds an HTTP + WebSocket dev server on :34115 that serves
|
# Wails v3's server mode binds an HTTP server on :34115 that serves the
|
||||||
# the real frontend with the real generated bindings on `window.go` and
|
# real frontend with the real generated bindings and bridges every call
|
||||||
# bridges every call and every runtime.EventsEmit to the *same* Go
|
# and every event to the *same* Go backend a desktop window would use.
|
||||||
# backend the desktop window uses. A browser pointed at it is not a
|
# A browser pointed at it is not a mock. That is what makes this app
|
||||||
# mock. That is what makes this app drivable by a coding agent.
|
# drivable by a coding agent.
|
||||||
#
|
#
|
||||||
# Three details are load-bearing:
|
# Three details are load-bearing:
|
||||||
#
|
#
|
||||||
# * We run the dev *binary*, not `wails dev`. app_dev.go parses
|
# * We build with `-tags dev,server` and run the binary. This is a
|
||||||
# -devserver / -assetdir / -loglevel straight from os.Args, so a
|
# first-class Wails mode ("a pure HTTP server without native GUI
|
||||||
# `go build -tags "dev"` binary serves the identical
|
# dependencies"), not something hand-rolled: v2 had no such thing,
|
||||||
# devserver with no file watcher, no rebuild supervisor and no
|
# so this script used to run a dev binary whose app_dev.go parsed
|
||||||
# reload broadcast: one process, one PID, deterministic startup.
|
# -devserver / -assetdir out of os.Args. That file is gone with v2.
|
||||||
|
# The port comes from WAILS_SERVER_PORT.
|
||||||
#
|
#
|
||||||
# * Xvfb is not optional. devserver.Run ends in Frontend.Run(ctx),
|
# * Xvfb is gone, and that is the point of server mode. v2's
|
||||||
# which opens the GTK window and blocks; no flag suppresses it.
|
# devserver.Run ended in Frontend.Run(ctx), which opened the GTK
|
||||||
|
# window and blocked with no flag to suppress it; server mode opens
|
||||||
|
# no window, so there is no display to fake.
|
||||||
#
|
#
|
||||||
# * dbus-run-session is not incidental. A private session bus makes
|
# * dbus-run-session is not incidental. A private session bus makes
|
||||||
# backend/mediacontrols register MPRIS for real, so it becomes
|
# backend/mediacontrols register MPRIS for real, so it becomes
|
||||||
# assertable with busctl. It replaces the bus, not /run/user, so
|
# assertable with busctl. It replaces the bus, not /run/user, so
|
||||||
# PulseAudio still works and InitSpeaker succeeds.
|
# PulseAudio still works and InitSpeaker succeeds.
|
||||||
#
|
#
|
||||||
|
# * The frontend is *embedded*, not served from disk. main.go has
|
||||||
|
# //go:embed all:frontend/dist, so a frontend change needs the
|
||||||
|
# rebuild this script does anyway; there is no -assetdir to point
|
||||||
|
# at a live directory.
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/dev-headless.sh [--seed NAME|--fresh] [--port N] [--no-build]
|
# scripts/dev-headless.sh [--seed NAME|--fresh] [--port N] [--no-build]
|
||||||
#
|
#
|
||||||
@@ -47,7 +55,7 @@ LOG_LEVEL="${YJ_LOG_LEVEL:-debug}"
|
|||||||
STARTUP_TIMEOUT=60
|
STARTUP_TIMEOUT=60
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
|
sed -n '3,38p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
exit "${1:-0}"
|
exit "${1:-0}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +137,9 @@ echo "$YJ_HOME" >"$HOME_FILE"
|
|||||||
|
|
||||||
# ── Build ────────────────────────────────────────────────────────────
|
# ── Build ────────────────────────────────────────────────────────────
|
||||||
if [ "$BUILD" = 1 ]; then
|
if [ "$BUILD" = 1 ]; then
|
||||||
echo "dev-headless: building frontend + dev binary..."
|
echo "dev-headless: building frontend + dev server binary..."
|
||||||
(cd frontend && pnpm install --silent && pnpm build >/dev/null)
|
(cd frontend && pnpm install --silent && pnpm build >/dev/null)
|
||||||
go build -tags "dev" -o "$BIN" .
|
go build -tags "dev,server" -o "$BIN" .
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -x "$BIN" ]; then
|
if [ ! -x "$BIN" ]; then
|
||||||
@@ -141,7 +149,7 @@ fi
|
|||||||
|
|
||||||
# ── Launch ───────────────────────────────────────────────────────────
|
# ── Launch ───────────────────────────────────────────────────────────
|
||||||
# setsid puts the app in its own process group so dev-stop can kill the
|
# setsid puts the app in its own process group so dev-stop can kill the
|
||||||
# whole tree (xvfb-run, dbus-daemon, the app) by group id. Never
|
# whole tree (dbus-daemon, the app) by group id. Never
|
||||||
# `pkill -f`: the pattern matches the invoking shell's own command line
|
# `pkill -f`: the pattern matches the invoking shell's own command line
|
||||||
# and silently drops the rest of the chain.
|
# and silently drops the rest of the chain.
|
||||||
: >"$LOG_FILE"
|
: >"$LOG_FILE"
|
||||||
@@ -150,11 +158,9 @@ fi
|
|||||||
# rather than implied by the dev build so that a human's `make dev` does
|
# rather than implied by the dev build so that a human's `make dev` does
|
||||||
# not carry an arbitrary-SQL endpoint on a listening port.
|
# not carry an arbitrary-SQL endpoint on a listening port.
|
||||||
YJ_TESTCTL=1 \
|
YJ_TESTCTL=1 \
|
||||||
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- xvfb-run -a \
|
WAILS_SERVER_PORT="$PORT" \
|
||||||
|
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
|
||||||
"$BIN" \
|
"$BIN" \
|
||||||
-devserver "localhost:$PORT" \
|
|
||||||
-assetdir "$REPO_ROOT/frontend/dist" \
|
|
||||||
-loglevel Debug \
|
|
||||||
>>"$LOG_FILE" 2>&1 &
|
>>"$LOG_FILE" 2>&1 &
|
||||||
|
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
@@ -191,7 +197,8 @@ Drive it with:
|
|||||||
playwright-cli -s=yj snapshot
|
playwright-cli -s=yj snapshot
|
||||||
playwright-cli -s=yj eval "() => window.__yjEvents.call('queue.Queue.GetState', [], 5000)"
|
playwright-cli -s=yj eval "() => window.__yjEvents.call('queue.Queue.GetState', [], 5000)"
|
||||||
|
|
||||||
A binding call that never settles means wrong argument types: the
|
A bad binding call now rejects rather than hanging: v3 answers wrong
|
||||||
backend logs 'error parsing arguments' and never fires the callback.
|
argument types with a TypeError naming the argument and an unknown
|
||||||
The app log is the only place that shows up, so always use a timeout.
|
method with a ReferenceError. The timeout in __yjEvents.call is a
|
||||||
|
backstop for a hung request, not how a mistake becomes visible.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+26
-27
@@ -36,7 +36,6 @@ MANIFEST="$REPO_ROOT/test_data/music_library_test.manifest.json"
|
|||||||
|
|
||||||
NAME="default"
|
NAME="default"
|
||||||
PORT=34115
|
PORT=34115
|
||||||
SESSION="yj-seed"
|
|
||||||
BUILD_ARGS=()
|
BUILD_ARGS=()
|
||||||
# Replaced below once the manifest says how many tracks are coming: a
|
# Replaced below once the manifest says how many tracks are coming: a
|
||||||
# 50 000-track scan is minutes, and a fixed 180 s deadline would abort
|
# 50 000-track scan is minutes, and a fixed 180 s deadline would abort
|
||||||
@@ -74,7 +73,7 @@ need() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
need playwright-cli
|
need curl
|
||||||
need jq
|
need jq
|
||||||
|
|
||||||
if [ ! -f "$MANIFEST" ]; then
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
@@ -89,7 +88,6 @@ SCAN_TIMEOUT=$((180 + WANT_TRACKS / 50))
|
|||||||
FIXTURE_HASH="$(jq -r .hash "$MANIFEST")"
|
FIXTURE_HASH="$(jq -r .hash "$MANIFEST")"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true
|
|
||||||
./scripts/dev-stop.sh >/dev/null 2>&1 || true
|
./scripts/dev-stop.sh >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -108,24 +106,34 @@ YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" \
|
|||||||
|
|
||||||
YJ_HOME="$(cat "$RUN_DIR/app.home")"
|
YJ_HOME="$(cat "$RUN_DIR/app.home")"
|
||||||
|
|
||||||
playwright-cli -s="$SESSION" open "http://localhost:$PORT" >/dev/null
|
# A binding is called over the runtime's own HTTP endpoint, by method
|
||||||
|
# name — the same request the bundle makes, minus the browser.
|
||||||
# Every binding call gets a timeout. A call with wrong argument types
|
#
|
||||||
# makes the backend log 'error parsing arguments' and never fire the
|
# This used to drive a real page through playwright-cli, because v2's
|
||||||
# callback, so the in-page promise never settles and a naive await
|
# only way in was `window.go`. v3 answers the same call over HTTP, so a
|
||||||
# hangs forever.
|
# browser (and a global npm install of the CLI, in CI) buys nothing
|
||||||
|
# here: seeding needs the *app* to run and the *real* scanner to finish,
|
||||||
|
# which it still does. It also fails properly now — v3 answers a bad
|
||||||
|
# argument with a 422 and a TypeError naming it, where v2 logged
|
||||||
|
# "error parsing arguments" and never fired the callback.
|
||||||
call() {
|
call() {
|
||||||
playwright-cli -s="$SESSION" eval "async () => {
|
local method="$1"
|
||||||
const timeout = new Promise((_, reject) =>
|
local args="${2:-[]}"
|
||||||
setTimeout(() => reject(new Error('binding timeout')), 15000));
|
local body
|
||||||
return await Promise.race([(async () => { $1 })(), timeout]);
|
|
||||||
}"
|
body="$(jq -nc --arg m "yellowjacket/backend/$method" --argjson a "$args" \
|
||||||
|
'{object: 0, method: 0, args: {"call-id": "seed", methodName: $m, args: $a}}')"
|
||||||
|
|
||||||
|
curl -sS --fail-with-body --max-time 30 \
|
||||||
|
-X POST "http://localhost:$PORT/wails/runtime" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "seed-sandbox: registering library $LIBRARY_DIR"
|
echo "seed-sandbox: registering library $LIBRARY_DIR"
|
||||||
|
|
||||||
if ! call "return await window.go.library.Library.AddLibrary(
|
if ! call library.Library.AddLibrary \
|
||||||
${LIBRARY_DIR@Q});" >/dev/null; then
|
"$(jq -nc --arg d "$LIBRARY_DIR" '[$d]')" >/dev/null; then
|
||||||
echo "seed-sandbox: AddLibrary failed; app log:" >&2
|
echo "seed-sandbox: AddLibrary failed; app log:" >&2
|
||||||
tail -n 40 "$LOG_FILE" >&2
|
tail -n 40 "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -137,19 +145,12 @@ fi
|
|||||||
# a fixture is not being ingested and the seed is wrong.
|
# a fixture is not being ingested and the seed is wrong.
|
||||||
echo "seed-sandbox: waiting for the scan to reach $WANT_TRACKS tracks"
|
echo "seed-sandbox: waiting for the scan to reach $WANT_TRACKS tracks"
|
||||||
|
|
||||||
# The result is tagged rather than scraped for bare digits:
|
|
||||||
# playwright-cli echoes the evaluated source back, and that source
|
|
||||||
# contains numbers of its own (the binding timeout, for one).
|
|
||||||
deadline=$((SECONDS + SCAN_TIMEOUT))
|
deadline=$((SECONDS + SCAN_TIMEOUT))
|
||||||
got=0
|
got=0
|
||||||
|
|
||||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
got="$(call "const libs =
|
got="$(call library.Library.GetAllLibrariesWithTrackCounts |
|
||||||
await window.go.library.Library.GetAllLibrariesWithTrackCounts();
|
jq '[(. // [])[].trackCount // 0] | add // 0')"
|
||||||
const total = (libs ?? []).reduce(
|
|
||||||
(n, l) => n + (l.trackCount ?? 0), 0);
|
|
||||||
return 'YJTRACKS' + '=' + total;" |
|
|
||||||
grep -oE 'YJTRACKS=[0-9]+' | head -n 1 | cut -d= -f2)"
|
|
||||||
got="${got:-0}"
|
got="${got:-0}"
|
||||||
|
|
||||||
[ "$got" = "$WANT_TRACKS" ] && break
|
[ "$got" = "$WANT_TRACKS" ] && break
|
||||||
@@ -170,8 +171,6 @@ if [ "$got" != "$WANT_TRACKS" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
# SIGTERM, so OnBeforeClose / OnShutdown persist window, player and
|
# SIGTERM, so OnBeforeClose / OnShutdown persist window, player and
|
||||||
# queue state. A seed built from a killed app is missing exactly the
|
# queue state. A seed built from a killed app is missing exactly the
|
||||||
# state those hooks write.
|
# state those hooks write.
|
||||||
|
|||||||
Reference in New Issue
Block a user