diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1c1d925..7dfee4c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -220,7 +220,7 @@ jobs: apt-get install -y -qq --no-install-recommends \ ca-certificates curl git jq build-essential pkg-config \ 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 - name: Clone repo at this commit @@ -247,24 +247,17 @@ jobs: apt-get install -y -qq --no-install-recommends nodejs corepack enable - # scripts/seed-sandbox.sh drives the real AddLibrary binding - # through playwright-cli, so the CLI has to be on PATH. - - name: Playwright CLI - run: npm install -g @playwright/cli - + # @playwright/cli is gone with v2. seed-sandbox.sh drove the real + # AddLibrary binding through a browser because `window.go` was the + # only way in; v3 answers the same call over HTTP, so the seed is + # curl now and needs no CLI, no second Chromium and no shared + # PLAYWRIGHT_BROWSERS_PATH revision dance. - name: Browsers working-directory: /src/e2e run: | 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 npx playwright install --with-deps chromium webkit - playwright-cli install-browser chromium # oto/v3 talks to libasound directly, and a container has no # 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 # 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 - # Xvfb that dev-headless.sh runs the app in. It needs no system - # D-Bus and no kernel module, which is why it is reachable from a - # container at all. + # audio takes 13.5 s. Verified under the private session bus + # dev-headless.sh runs the app in. It needs no system D-Bus and + # no kernel module, which is why it is reachable from a container + # at all. - name: Real-time audio sink run: | set -eu diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index ae60822..56a2b47 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -21,12 +21,21 @@ here has disappeared. 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. -- **Time out every binding call.** A bound Go method called with wrong - argument types makes the backend log `error parsing arguments` and - **never fire the callback**, so the promise hangs forever. Use - `window.__yjEvents.call(path, args, ms)` (browser) or `callBinding` - (specs), never a bare `window.go.…`. When one hangs anyway, - `make dev-logs` — `.dev/app.log` is the only place the reason appears. +- **Call a binding through the bridge.** `window.go` does not exist + under Wails v3 — the bindings are bundled modules, not a global — so + use `window.__yjEvents.call(path, args, ms)` (browser) or + `callBinding` (specs). Both post to the runtime's own endpoint by + method name, so they work on any page, including one with no init + 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`.** `` intercepts all pointer events until a library exists, and the click fails with a Playwright interception error that reads like a selector diff --git a/.pi/skills/yellowjacket-dev/references/harness.md b/.pi/skills/yellowjacket-dev/references/harness.md index 7b30a7c..e9d6d8c 100644 --- a/.pi/skills/yellowjacket-dev/references/harness.md +++ b/.pi/skills/yellowjacket-dev/references/harness.md @@ -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 command prints a path under `outputDir`; read that. Only the tail 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 - 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 matches "Add queue to playlist"; transport controls need `exact: true`. diff --git a/.playwright/init-events.js b/.playwright/init-events.js index 9d75d21..fee7ff9 100644 --- a/.playwright/init-events.js +++ b/.playwright/init-events.js @@ -7,31 +7,34 @@ * other events arrive from Go whenever they arrive. An assertion that * 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 * wait a promise that settles on a matching event (or rejects * with the list of events that *did* arrive, which is the * single most useful failure message this harness can give) - * call a bound Go method that is guaranteed to settle: a binding - * invoked with wrong argument types makes the backend log - * "error parsing arguments" and never fire the callback, so - * the in-page promise hangs forever. Timing out here fixes - * that once instead of in every eval. + * call a bound Go method, by name, over the runtime's own HTTP + * endpoint — no dependence on the app's bundle + * bindings every binding call the *app* made, which is what turns + * "did that refetch the library" from an inference into a + * fact (e2e/perf/measure.mjs labels and reads these) * - * WHERE IT HOOKS. Not EventsOn. Every backend event enters the page - * at exactly one place — wails' ipc_websocket.js does + * WHERE IT HOOKS. Two places, and neither is `EventsOn`. * - * 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 - * single choke point captures all 46 events whether or not the app - * subscribes to them, and needs one wrap rather than 46. - * - * `window.wails` does not exist yet when this script runs, so we install - * 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. + * Outbound, `fetch`: v3 routes every runtime call — binding calls, event + * emits, window and dialog calls — through one POST to /wails/runtime. + * 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 + * an object graph, and cannot miss one made before the harness looked. * * INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into * the next, so a recorder that re-registers double-counts. Tests call @@ -44,8 +47,27 @@ 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; const log = []; + const bindings = []; const waiters = new Set(); const summarize = () => { @@ -56,6 +78,25 @@ 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 entry = { seq: ++seq, name, data, dir, t: Date.now() }; log.push(entry); @@ -89,7 +130,7 @@ }; const api = { - version: 1, + version: 2, /** Every recorded event, oldest first. */ get log() { @@ -101,10 +142,30 @@ 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() { const n = log.length; log.length = 0; + bindings.length = 0; return n; }, @@ -177,13 +238,11 @@ async ready(timeoutMs) { const deadline = Date.now() + (timeoutMs || 15000); for (;;) { - if (window.go?.queue?.Queue?.GetState) { - try { - await api.call("queue.Queue.GetState", [], 2000); - return true; - } catch { - /* backend not up yet */ - } + try { + await api.call("queue.Queue.GetState", [], 2000); + return true; + } catch { + /* backend not up yet */ } if (Date.now() > deadline) { 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]) * - * A binding called with the wrong argument types never fires its - * callback — the reason appears only in .dev/app.log. Without a - * timeout the caller waits forever; with one it gets told where - * to look. + * This posts to the runtime's own endpoint rather than reaching + * into the page for a binding function, because v3 has no + * `window.go` and the generated bindings are ordinary bundled + * 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) { - const parts = String(path).split("."); - let fn = window.go; - for (const p of parts) { - fn = fn?.[p]; - } - if (typeof fn !== "function") { - return Promise.reject( - new Error(`__yjEvents.call: no such binding: ${path}`), - ); - } + const request = nativeFetch("/wails/runtime", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-wails-client-id": window._wails?.clientId ?? "", + }, + body: JSON.stringify({ + 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([ - Promise.resolve(fn(...(args || []))), + request, new Promise((_, reject) => setTimeout( () => reject( new Error( `__yjEvents.call(${path}) did not settle in ` + - `${timeoutMs || 10000}ms — almost always wrong ` + - `argument types; check .dev/app.log for ` + - `"error parsing arguments"`, + `${timeoutMs || 10000}ms — the runtime endpoint ` + + `hung, which is not how a bad argument fails; ` + + `check .dev/app.log`, ), ), timeoutMs || 10000, @@ -241,62 +328,92 @@ writable: false, }); - // Wrap `obj[method]` once, routing every invocation through `tap`. - const wrap = (obj, method, tap) => { - const original = obj[method]; - if (typeof original !== "function" || original.__yjWrapped) { - return; - } - 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; - }; + // ── Inbound ────────────────────────────────────────────────────── + // + // The runtime keeps whatever `window._wails` already is, so creating + // it here and defining an accessor on the one property we care about + // means the wrap happens the moment the runtime module is evaluated. + window._wails = window._wails || {}; - // Install an accessor that wraps on first assignment, then collapses - // back into an ordinary property. - const hookOnAssign = (name, onAssign) => { - let value; - Object.defineProperty(window, name, { - configurable: true, - enumerable: true, - get: () => value, - set: (v) => { - value = v; + let dispatch; + + Object.defineProperty(window._wails, "dispatchWailsEvent", { + configurable: true, + enumerable: true, + get: () => dispatch, + set: (fn) => { + dispatch = function (event) { try { - onAssign(v); + record(event?.name, argsOf(event?.data), "in"); } catch { - /* ditto */ + /* a broken recorder must never break the app */ } - Object.defineProperty(window, name, { - value: v, - configurable: true, - enumerable: true, - writable: true, - }); - }, + return fn.apply(this, arguments); + }; + }, + }); + + // ── 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"); - }); - }); })(); diff --git a/backend/app.go b/backend/app.go index 48ad648..1e3e8bc 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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 } diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 0f8b551..703f751 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -1414,7 +1414,18 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex // by relevance (popularity-blended). Returns nil when the index // hasn't finished its initial build. 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 } diff --git a/backend/startup.go b/backend/startup.go new file mode 100644 index 0000000..ffbb35e --- /dev/null +++ b/backend/startup.go @@ -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 +} diff --git a/e2e/perf/measure.mjs b/e2e/perf/measure.mjs index 5258bc1..ac4b125 100644 --- a/e2e/perf/measure.mjs +++ b/e2e/perf/measure.mjs @@ -56,6 +56,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from '@playwright/test'; +import { methodIDs } from '../support/method-ids.mjs'; + const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, '../..'); 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'; +/** + * 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 // views the audit named as retaining: explore (two unbounded caches), // 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 - * user action provoked and how much they returned. + * Say which bindings a user action provoked, and how much they + * returned. * - * Post-hoc wrapping is safe because `frontend/wailsjs` looks its target - * up at call time (`window['go']['library']['Library']['GetAllTracks']()`), - * so a store holding an imported wrapper still lands here. + * This used to walk `window.go` and wrap every bound method in place, + * which worked because v2's generated stubs looked their target up at + * 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 = `() => { - // Runs twice: once as an initScript (before window.go exists, which - // 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; +const INSTRUMENT = `(names) => { + if (window.__yjPerf) return; - if (first) { - const calls = []; - window.__yjPerf = { - calls, - reset: () => { calls.length = 0; }, - since: (t) => calls.filter((c) => c.start >= t), - longtasks: [], - }; - } + window.__yjEvents.measureBytes = true; - const calls = window.__yjPerf.calls; - - const wrap = (obj, key, path) => { - const fn = obj[key]; - if (typeof fn !== 'function' || fn.__yjPerfWrapped) return; - const wrapped = function (...args) { - const start = performance.now(); - let out; - try { out = fn.apply(this, args); } catch (e) { throw e; } - return Promise.resolve(out).then((v) => { - let bytes = 0; - try { bytes = JSON.stringify(v ?? null).length; } catch { bytes = -1; } - calls.push({ path, start, ms: performance.now() - start, bytes }); - return v; - }); - }; - wrapped.__yjPerfWrapped = true; - obj[key] = wrapped; + window.__yjPerf = { + get calls() { + return window.__yjEvents.bindings.map((c) => ({ + path: names[c.methodID] || ('#' + c.methodID), + methodID: c.methodID, + start: c.start, + ms: c.ms, + bytes: c.bytes, + })); + }, + reset: () => { window.__yjEvents.reset(); }, + since: (t) => window.__yjPerf.calls.filter((c) => c.start >= t), + longtasks: [], }; - 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 // parse on the main thread shows up here and nowhere else. try { @@ -352,7 +341,15 @@ async function measureTrackChange(page) { const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath); 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); // Settle mid-track before starting to record. Starting playback @@ -1046,14 +1043,15 @@ const SCROLL_SETTLE_MS = 260; async function measureScroll(page) { // -- M3: the track list, with the Art column staged on. -- 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: 'artistName' }, { id: 'trackLength' }], - ); + ], 15000); - return prior.map((c) => ({ id: c.id })); + return (prior ?? []).map((c) => ({ id: c.id })); }); const scrollView = async (view, tag) => { @@ -1141,7 +1139,9 @@ async function measureScroll(page) { const artists = await scrollView('artists', 'artists-view'); await page.evaluate( - (cols) => window.go.config.Config.SetTrackListColumns(cols), + (cols) => window.__yjEvents.call( + 'config.Config.SetTrackListColumns', [cols], 15000, + ), priorColumns, ); @@ -1605,7 +1605,15 @@ async function measurePlayerBarPass(page) { 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('player.Player.Pause', [], 5000).catch(() => {}); await new Promise((r) => setTimeout(r, 600)); @@ -1804,7 +1812,9 @@ async function run(label) { const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await context.addInitScript({ path: BRIDGE }); - await context.addInitScript(`(${INSTRUMENT})()`); + await context.addInitScript( + `(${INSTRUMENT})(${JSON.stringify(METHOD_NAMES)})`, + ); const page = await context.newPage(); const client = await context.newCDPSession(page); @@ -1813,8 +1823,10 @@ async function run(label) { const t0 = Date.now(); await page.goto(BASE_URL, { waitUntil: 'load' }); await page.evaluate(() => window.__yjEvents.ready(30000)); - // Wrapping runs before `window.go` exists; re-run now that it does. - await page.evaluate(`(${INSTRUMENT})()`); + // No second instrumentation pass. The old one existed because + // 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 = { label, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4b8a114..4503df5 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,8 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; /** - * These specs drive the *real* application: the Wails dev server on - * :34115 serves the real frontend with real bindings on `window.go`, + * These specs drive the *real* application: Wails v3's server mode on + * :34115 serves the real frontend with the real generated bindings, * bridged to the same Go backend a desktop window would use. Nothing * here is mocked. * diff --git a/e2e/specs/album-actions.spec.ts b/e2e/specs/album-actions.spec.ts index f3a1a20..3eac2de 100644 --- a/e2e/specs/album-actions.spec.ts +++ b/e2e/specs/album-actions.spec.ts @@ -16,6 +16,14 @@ import type { Page } from '@playwright/test'; * none, so Play was wired, labelled correctly, clicked cleanly and * 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.beforeEach(async ({ app }) => { await openFirstAlbum(app); @@ -55,16 +63,6 @@ test.describe('playing an album from its page', () => { 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 }) => { // Every one of them was a