From 93affd01701fd9a7d63cf848ca19ea970deab289 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:10:03 +0000 Subject: [PATCH 1/2] Multi-playing: honest loadProfile/setActiveProfile across tabs (#453) Each profile is its own browser tab, and three scripted multi-playing calls behaved worse than they had to: 1. loadProfile(): a browser opens one new tab per user gesture, so the second call in an alias, and any call from a trigger, was refused with nothing left for the user to act on. The refusal now also leaves an "Open profile " link on its own line in the main console; the click is the gesture the call lacked, so one click opens the profile. A click that finds the profile already open, or is itself blocked, says so instead of opening a duplicate tab. 2. setActiveProfile(): for a profile in another tab it answered true without switching. A page cannot bring another tab forward, so it now answers false with that reason and asks the target tab (over the profiles-presence channel) to request focus and flash its title until the user switches to it, when sysProfileFocusChangeEvent fires. 3. raiseGlobalEvent(): other tabs handle the event a task later, where desktop runs every host's handlers inline. That is inherent in separate tabs, so it is recorded rather than changed. All three are recorded in e2e/knownDivergences.ts, docs/MUDLET_API.md and the luaCompletions descriptions; tests/scripting/multiProfile453.test.ts covers each point. Fixes #453 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUGrWWjYht6BA7w5asDWRi --- docs/MUDLET_API.md | 6 +- e2e/knownDivergences.ts | 40 ++++- src/scripting/ScriptingAPI.ts | 90 ++++++++-- src/scripting/lua/bindings/session.ts | 4 + src/scripting/lua/luaCompletions.ts | 6 +- src/scripting/profilesPresence.ts | 23 ++- tests/scripting/multiProfile453.test.ts | 216 ++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 30 deletions(-) create mode 100644 tests/scripting/multiProfile453.test.ts diff --git a/docs/MUDLET_API.md b/docs/MUDLET_API.md index 1d1cd116..76735999 100644 --- a/docs/MUDLET_API.md +++ b/docs/MUDLET_API.md @@ -420,8 +420,8 @@ Mudlet Web-specific extras (not on the wiki): `getMapMode`/`setMapMode("viewing" | `killKey(id)` | ✅ | | | `killTimer(id)` | ✅ | | | `killTrigger(name\|id)` | ✅ | String → name-based delete; numeric → temp-trigger disposer | -| `loadProfile(name)` | ✅ | Opens the named profile in a NEW browser tab (`?profile=&connect=1`) and connects — each profile lives in its own tab (per-profile lock), so the calling profile stays open alongside, matching Mudlet's multi-profile model. Returns `false` for an unknown name, the profile already open in this tab, or a blocked popup. `window.open` needs a user gesture: works from a key/button/alias, may be blocked from a trigger | -| `setActiveProfile(name)` | ✅ | `true`, or `false` plus why not (empty name, no such profile, not loaded); the name matches case-insensitively. A page can't switch browser tabs, so for a profile open in another tab this answers `true` without bringing it forward | +| `loadProfile(name)` | ✅ | Opens the named profile in a NEW browser tab (`?profile=&connect=1`) and connects — each profile lives in its own tab (per-profile lock), so the calling profile stays open alongside, matching Mudlet's multi-profile model. Returns `nil` plus a message for an unknown name, a profile already open, or a blocked popup. A browser opens one new tab per user gesture, so the second `loadProfile` in one alias, and any from a trigger/timer/event, is blocked; the refusal then leaves an **Open profile _name_** link in the main console that opens it on click (`knownDivergences.ts`, #453) | +| `setActiveProfile(name)` | ✅ | `true` for this tab's own profile (asks for window focus), or `false` plus why not (empty name, no such profile, not loaded); the name matches case-insensitively. A page can't switch browser tabs, so for a profile open in another tab it returns `false` ("is open in another browser tab…") and that tab flashes its title until the user switches to it (`knownDivergences.ts`, #453) | | `permAlias(name, parent, pattern, code)` | ✅ | Pattern is a single PCRE string (Mudlet TAlias.mRegexCode). Returns the new id, or -1 | | `permGroup(name, type [, parent])` | ✅ | Creates a group node in the requested family | | `permPromptTrigger(name, parent, code)` | ✅ | Persistent trigger firing on every server prompt (GA/EOR); single `prompt`-type pattern, never a group. Returns the new id or -1 | @@ -434,7 +434,7 @@ Mudlet Web-specific extras (not on the wiki): `getMapMode`/`setMapMode("viewing" | `permKey(name, parent, modifier, key, code)` | ✅ | `modifier` is the Qt::KeyboardModifier int (1=shift, 2=ctrl, 4=alt, 8=meta; -1 → none). `key` accepts a Qt::Key int or a KeyboardEvent.code string | | `printCmdLine([name,] text)` | ✅ | Routes to overlay cmd lines, per-userwindow cmd lines, or the main bar | | `raiseEvent(name, ...)` | ✅ | | -| `raiseGlobalEvent(name, ...)` | ✅ | Cross-tab via `BroadcastChannel` (`GlobalEventChannel`). Fires the event in every OTHER open profile (each runs in its own browser tab), never the sender; incoming events dispatch through `emitEvent` so `registerAnonymousEventHandler` handlers fire. Args limited to string/number/boolean/nil (matches Mudlet); the sender's profile name is appended as the last arg. No per-profile opt-in (matches Mudlet's `postInterHostEvent`) | +| `raiseGlobalEvent(name, ...)` | ✅ | Cross-tab via `BroadcastChannel` (`GlobalEventChannel`). Fires the event in every OTHER open profile (each runs in its own browser tab), never the sender; incoming events dispatch through `emitEvent` so `registerAnonymousEventHandler` handlers fire. Args limited to string/number/boolean/nil (matches Mudlet); the sender's profile name is appended as the last arg. No per-profile opt-in (matches Mudlet's `postInterHostEvent`). **Asynchronous:** the other profiles' handlers run a task later, not before `raiseGlobalEvent` returns as on desktop — to ask another profile for a value, have it reply with an event of its own and act on the value in that handler, not on the next line (`knownDivergences.ts`, #453) | | `registerNamedTimer(parent, name, delay, code)` | ✅ | IDManager.lua | | `registerNamedTrigger(parent, name, pattern, code)` | ✅ | IDManager.lua | | `remainingTime(id)` | ✅ | JS-exposed | diff --git a/e2e/knownDivergences.ts b/e2e/knownDivergences.ts index 0f602700..a32c0e42 100644 --- a/e2e/knownDivergences.ts +++ b/e2e/knownDivergences.ts @@ -716,15 +716,47 @@ export const PLATFORM_DIVERGENCES: PlatformDivergence[] = [ + 'to rewrite it. Closing it means validating every argument up front in every shim.', issue: '#276', }, + { + api: 'loadProfile(name)', + behaviour: + 'Opens the profile in a new browser tab. Desktop opens every profile a script asks for; here the ' + + 'browser allows one new tab per user gesture, so the second loadProfile() in one alias, and any ' + + 'loadProfile() from a trigger, timer or event handler, answers nil plus "the browser blocked the new ' + + 'tab". That refusal also leaves a line in the main console with an "Open profile " link, ' + + 'which opens the profile when clicked.', + reason: + 'Each profile lives in its own browser tab, and a page may only open a tab while handling a click or ' + + 'key press - once per gesture, and never from code the game\'s output started. No page can lift ' + + 'that, so the link turns the refused call into one click instead of a silent failure.', + issue: '#453', + }, { api: 'setActiveProfile(name)', behaviour: - 'For a profile open in another browser tab it answers true, as desktop does, but that tab is not ' - + 'brought to the front. For this tab\'s own profile it asks for window focus.', + 'For a profile open in another browser tab it answers false plus "is open in another browser tab, ' + + 'which a page cannot bring to the front", where desktop switches to it and answers true; the other ' + + 'tab flashes its title until the user switches to it, and sysProfileFocusChangeEvent fires when ' + + 'they do. For this tab\'s own profile it asks for window focus and answers true.', reason: 'Each profile lives in its own browser tab, and browsers only let the user switch tabs - a page ' - + 'cannot focus another tab. The refusals (empty name, no such profile, not loaded) match.', - issue: '#276', + + 'cannot focus another tab. Answering true would tell a "jump to the profile that needs attention" ' + + 'script it had worked when nothing moved. The refusals (empty name, no such profile, not loaded) ' + + 'match.', + issue: '#453', + }, + { + api: 'raiseGlobalEvent(name, ...)', + behaviour: + 'Other profiles\' handlers run a task later, not during the call: desktop has run them all by the ' + + 'time raiseGlobalEvent returns, so a profile that asks another for a value and reads the reply on ' + + 'the next line sees it there, and sees the old value here. Have the reply raise an event of its ' + + 'own and act on it in that handler.', + reason: + 'Desktop\'s profiles share one process, and HostManager::postInterHostEvent calls raiseEvent on each ' + + 'host in turn. Here each profile is a separate browser tab with its own Lua state, and the only way ' + + 'between tabs is a BroadcastChannel message, which the receiving tab handles on a later task. One ' + + 'tab cannot run another tab\'s Lua synchronously.', + issue: '#453', }, { api: 'rex.config()', diff --git a/src/scripting/ScriptingAPI.ts b/src/scripting/ScriptingAPI.ts index 54fa1729..5d530be2 100644 --- a/src/scripting/ScriptingAPI.ts +++ b/src/scripting/ScriptingAPI.ts @@ -1170,6 +1170,14 @@ export class ScriptingAPI { this.presence = new ProfilesPresence(connectionId, () => this.session.status === 'connected'); // Another tab's closeProfile(). this.presence.onCloseRequested = () => { this.closeMudlet(); }; + // Another tab's setActiveProfile(). No page can bring + // itself forward either, so ask for focus (honoured where the browser + // allows it) and flash the tab title until the user switches here — + // which is when sysProfileFocusChangeEvent fires, as on desktop. + this.presence.onAttentionRequested = () => { + try { window.focus(); } catch { /* not focusable here */ } + flashTitle(); + }; // Re-announce this tab's connected state to other tabs on connect/ // disconnect (for their getProfiles). Deferred to a microtask so the // session's own status handler has run before we read session.status. @@ -1539,21 +1547,64 @@ export class ScriptingAPI { * Returns null on success, or the message for Mudlet's `nil, message` * refusal: an unknown name, a profile already open (in this tab or any * other — the loaded set is the same one getProfiles() reports), or a popup - * the browser blocked. NOTE: `window.open` needs a user gesture, so this - * works from a key/button/alias but a browser may block it from a trigger - * (no Mudlet equivalent to that limitation). */ + * the browser blocked. `window.open` needs a user gesture and a browser + * allows one new tab per gesture, so a trigger (no gesture) or the second + * loadProfile in one alias is refused (mudlet-web#453). That can't be + * lifted from a page, so a refusal leaves a link in the main console that + * opens the profile on click — the click is the gesture the call lacked. */ loadProfile(name: string): string | null { const target = name ?? ''; const conn = useAppStore.getState().connections.find(c => c.name === target); if (!conn) return `loadProfile: profile '${target}' does not exist`; - if (conn.id === this.connectionId || this.presence.loadedIds().includes(conn.id)) { - return `loadProfile: profile '${target}' is already loaded`; - } + if (this.isProfileLoaded(conn.id)) return `loadProfile: profile '${target}' is already loaded`; + if (this.openProfileTab(conn.id)) return null; + this.echoOpenProfileLink(conn.id, target); + return `loadProfile: could not open profile '${target}', the browser blocked the new tab; ` + + 'click the link in the main window to open it'; + } + + /** True when profile `id` is open in this tab or any other. */ + private isProfileLoaded(id: string): boolean { + return id === this.connectionId || this.presence.loadedIds().includes(id); + } + + /** Open profile `id` in a new tab and dial it. False when the browser + * refused the tab. */ + private openProfileTab(id: string): boolean { const url = new URL(window.location.href); - url.searchParams.set('profile', conn.id); + url.searchParams.set('profile', id); url.searchParams.set('connect', '1'); - const w = window.open(url.toString(), '_blank'); - return w ? null : `loadProfile: could not open profile '${target}', the browser blocked the new tab`; + return !!window.open(url.toString(), '_blank'); + } + + /** The fallback for a loadProfile the browser refused: a line of its own + * in the main console carrying a link that opens the profile. A click on + * it that finds the profile open by then (another link, the user) just + * says so rather than opening a second tab the lock would park. */ + private echoOpenProfileLink(id: string, name: string): void { + const hyperlink: FormatHyperlink = { + onClick: () => { + if (this.isProfileLoaded(id)) { + this.echoInfoLine(`Profile '${name}' is already open.`); + } else if (!this.openProfileTab(id)) { + this.echoInfoLine(`The browser blocked opening profile '${name}'; allow pop-ups for this site and try again.`); + } + }, + title: `Open profile '${name}' in a new tab`, + }; + const lead = this.echoOnMatchedLine || this.mainConsole.currentPartial.text.length > 0 ? '\n' : ''; + this.echoMain(`${lead}The browser blocked opening profile '${name}' from a script. `); + this.echoMain(`Open profile ${name}`, this.standardLinkState('main', hyperlink)); + this.echoMain('\n'); + this.drainMain(); + } + + /** A plain line of client feedback in the main console, on a line of its + * own. */ + private echoInfoLine(text: string): void { + const lead = this.echoOnMatchedLine || this.mainConsole.currentPartial.text.length > 0 ? '\n' : ''; + this.echoMain(`${lead}${text}\n`); + this.drainMain(); } /** Mudlet `closeProfile(name)`. Closes the named open profile — this one, @@ -1584,11 +1635,13 @@ export class ScriptingAPI { * case-insensitively against the profiles, then against the bundled games * (a game with no profile is "not loaded"). * - * Returns null on success or the refusal message. Each profile has a tab - * of its own here, and a page can't bring another tab forward — browsers - * only let the user switch tabs — so for a profile open elsewhere this - * answers true, as desktop does, without the switch; for this tab's own - * profile it asks for the window's focus. */ + * Returns null on success or the refusal message. For this tab's own + * profile it asks for the window's focus and succeeds. Every other profile + * has a tab of its own, and a page can't bring another tab forward — + * browsers only let the user switch tabs — so that is refused rather than + * reported as done (mudlet-web#453): the target tab is asked to flash its + * title instead, and the message says so, so a script can tell the user + * where to look. */ setActiveProfile(name: string): string | null { const requested = name ?? ''; if (requested === '') return 'setActiveProfile: profile name cannot be empty'; @@ -1598,13 +1651,14 @@ export class ScriptingAPI { .find(c => c.name.toLowerCase() === lower); const canonical = conn?.name ?? BUNDLED_GAMES.find(g => g.name.toLowerCase() === lower)?.name; if (canonical === undefined) return `setActiveProfile: profile '${requested}' does not exist`; - const loaded = conn !== undefined - && (conn.id === this.connectionId || this.presence.loadedIds().includes(conn.id)); - if (!loaded) return `setActiveProfile: profile '${canonical}' is not loaded`; + if (!conn || !this.isProfileLoaded(conn.id)) return `setActiveProfile: profile '${canonical}' is not loaded`; if (conn.id === this.connectionId) { try { window.focus(); } catch { /* not focusable here */ } + return null; } - return null; + const flagged = this.presence.requestAttention(conn.id); + return `setActiveProfile: profile '${canonical}' is open in another browser tab, which a page cannot bring ` + + (flagged ? 'to the front; that tab\'s title is flashing instead' : 'to the front'); } /** Mudlet `getCommandSeparator()`. Returns the profile's command separator diff --git a/src/scripting/lua/bindings/session.ts b/src/scripting/lua/bindings/session.ts index cc7c86f4..2311bb1f 100644 --- a/src/scripting/lua/bindings/session.ts +++ b/src/scripting/lua/bindings/session.ts @@ -227,6 +227,10 @@ export function installSessionBindings( // the sending profile's name as the final arg; args are limited to // string/number/boolean/nil. The channel itself is constructed by the // runtime and handed in, since it needs the runtime's event emitter. + // The other tabs handle it on a later task, where desktop has run every + // other profile's handlers before this returns — a known divergence + // (e2e/knownDivergences.ts, mudlet-web#453): one tab cannot run another's + // Lua synchronously. lua.global.set('raiseGlobalEvent', (event: unknown, ...args: unknown[]) => { if (typeof event !== 'string' || event.length === 0) { throw new Error('raiseGlobalEvent: missing argument #1 (eventName as a string expected!)'); diff --git a/src/scripting/lua/luaCompletions.ts b/src/scripting/lua/luaCompletions.ts index c7834795..a512adac 100644 --- a/src/scripting/lua/luaCompletions.ts +++ b/src/scripting/lua/luaCompletions.ts @@ -680,9 +680,9 @@ const MUDLET_GLOBALS: Completion[] = [ fn('ioprint', '(...)', 'Print to the developer console (Mudlet prints to stdout; in the browser the closest analogue is the devtools console).'), fn('getProfileTabNumber', '([name]) → number', 'Tab index of a profile. mudlet is a single-profile web app, so this always returns 1.'), fn('getProfiles', '() → {[name]=info}', 'Table keyed by profile name, one entry per configured connection: { host, port, loaded, connected, description }. loaded = open in some tab (each profile lives in its own browser tab); connected = connected to its game (live for this tab, last-announced for others). Cross-tab via Web Locks + a BroadcastChannel.'), - fn('loadProfile', '(name) → true | nil, err', 'Open the named profile in a new browser tab and connect to it (each profile lives in its own tab). The calling profile stays open. Returns nil plus a message for an unknown name, a profile that is already open, or a blocked popup. Needs a user gesture, so it works from a key/button/alias but a browser may block it from a trigger.'), + fn('loadProfile', '(name) → true | nil, err', 'Open the named profile in a new browser tab and connect to it (each profile lives in its own tab). The calling profile stays open. Returns nil plus a message for an unknown name, a profile that is already open, or a blocked popup. A browser opens one tab per click/key press, so a second loadProfile in the same alias, or any from a trigger, is blocked; a blocked call leaves an "Open profile" link in the main window that opens it on click.'), fn('closeProfile', '(name) → true | nil, err', 'Close the named open profile — this one, or one open in another tab — disconnecting it and returning its tab to the connection screen. Returns nil plus a message when no open profile has that name.'), - fn('setActiveProfile', '(name) → true | false, err', 'Bring an open profile to the front (name matched case-insensitively). false plus a message for an empty name, a profile that does not exist, or one that is not loaded. A page cannot switch browser tabs, so for a profile open in another tab this answers true without switching.'), + fn('setActiveProfile', '(name) → true | false, err', 'Bring an open profile to the front (name matched case-insensitively). false plus a message for an empty name, a profile that does not exist, or one that is not loaded. A page cannot switch browser tabs, so for a profile open in another tab this returns false and that tab flashes its title instead.'), variable('SESSION', 'The id of this profile\'s session — 1 (a browser tab holds one profile)'), variable('SCRIPT_NAME', 'Name of the interpreter: "Global Lua Session Interpreter"'), variable('SCRIPT_ID', 'Id of the interpreter: -1'), @@ -693,7 +693,7 @@ const MUDLET_GLOBALS: Completion[] = [ fn('removeFileWatch', '(path) → bool', 'Stop watching a path previously registered with addFileWatch. Returns false if the path was not watched.'), // Events fn('raiseEvent', '(name, ...)', 'Fire a named event'), - fn('raiseGlobalEvent', '(name, ...) → true', 'Fire an event in every OTHER open profile (browser tab), not this one. Args: string/number/boolean/nil; the sending profile name is appended as the last argument.'), + fn('raiseGlobalEvent', '(name, ...) → true', 'Fire an event in every OTHER open profile (browser tab), not this one. Args: string/number/boolean/nil; the sending profile name is appended as the last argument. Delivered a moment later, not before this returns: wait for a reply event rather than reading a value the other profile sets on the next line.'), fn('registerAnonymousEventHandler', '(name, fn) → id', 'Register an event handler, returns an ID'), fn('killAnonymousEventHandler', '(id) → bool', 'Remove an event handler by ID'), // Formatting diff --git a/src/scripting/profilesPresence.ts b/src/scripting/profilesPresence.ts index 7f8fc7a2..e4cdab2b 100644 --- a/src/scripting/profilesPresence.ts +++ b/src/scripting/profilesPresence.ts @@ -5,7 +5,8 @@ type PresenceMsg = | { t: 'state'; id: string; connected: boolean } | { t: 'query' } | { t: 'bye'; id: string } - | { t: 'close'; id: string }; + | { t: 'close'; id: string } + | { t: 'attention'; id: string }; /** * Cross-tab view of which profiles are open and which are connected — backs @@ -32,7 +33,10 @@ type PresenceMsg = * * The same channel carries `closeProfile(name)` for a profile open in another * tab: the caller broadcasts `close` with the target's id and the tab owning it - * closes itself through {@link onCloseRequested}. + * closes itself through {@link onCloseRequested}. `setActiveProfile(name)` for + * a profile open in another tab sends `attention` the same way: a page cannot + * bring another tab forward, so the owning tab flags itself instead + * ({@link onAttentionRequested}). */ export class ProfilesPresence { private channel: BroadcastChannel | null = null; @@ -43,6 +47,9 @@ export class ProfilesPresence { private readonly remoteConnected = new Map(); /** Called when another tab asks this profile to close (`closeProfile`). */ onCloseRequested: (() => void) | null = null; + /** Called when another tab asks this profile to come to the front + * (`setActiveProfile`). */ + onAttentionRequested: (() => void) | null = null; constructor( private readonly ownId: string, @@ -87,6 +94,14 @@ export class ProfilesPresence { return true; } + /** Ask the tab that has profile `id` open to draw the user's attention to + * itself. False when there is no channel to carry the request. */ + requestAttention(id: string): boolean { + if (!this.channel) return false; + this.channel.postMessage({ t: 'attention', id } satisfies PresenceMsg); + return true; + } + /** Connected state for a profile id: own → live, others → last announced * (false when never announced). Callers gate this on `loaded` so a crashed * tab's stale "connected" can't outlive its lock. */ @@ -102,6 +117,10 @@ export class ProfilesPresence { if (m.id === this.ownId) this.onCloseRequested?.(); return; } + if (m.t === 'attention') { + if (m.id === this.ownId) this.onAttentionRequested?.(); + return; + } if (m.t === 'state' && m.id !== this.ownId) { this.remoteConnected.set(m.id, !!m.connected); } diff --git a/tests/scripting/multiProfile453.test.ts b/tests/scripting/multiProfile453.test.ts new file mode 100644 index 00000000..b6a4fbba --- /dev/null +++ b/tests/scripting/multiProfile453.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestRuntime, type TestRuntime } from '../createTestRuntime'; +import { useAppStore } from '../../src/storage/appStore'; +import { AnsiAwareBuffer } from '../../src/mud/text/FormatState'; +import { ProfilesPresence } from '../../src/scripting/profilesPresence'; +import { GlobalEventChannel } from '../../src/scripting/GlobalEventChannel'; +import { PLATFORM_DIVERGENCES } from '../../e2e/knownDivergences'; + +// mudlet-web#453 — scripted multi-playing. Each profile is a browser tab, so: +// 1. loadProfile() can only open one tab per user gesture (none from a +// trigger); a refused call leaves a clickable "Open profile" line instead +// of failing silently. +// 2. setActiveProfile() for a profile in another tab can't bring it forward, +// so it answers false and flags that tab rather than claiming success. +// 3. raiseGlobalEvent() reaches other tabs a task later — recorded, not fixed. + +/** Mirrors ScriptingEngine.processFlushBatch for one network line with a + * trigger body running on it (see trigger-echo-placement.test.ts). */ +function feedLine(env: TestRuntime, text: string, triggerBody?: string): void { + const buffer = new AnsiAwareBuffer(text); + env.api.beginLine(buffer); + if (triggerBody) env.run(triggerBody); + env.api.endLine(); + if (!buffer.deleted) env.session.events.emit('message', buffer, 'mud', Date.now()); + env.api.flushDeferredEcho(); +} + +interface Presence { + loadedIds: () => string[]; + requestAttention: (id: string) => boolean; +} + +describe('loadProfile when the browser blocks the new tab (#453)', () => { + let env: TestRuntime; + let opened: string[]; + /** window.open's answers, in order; once used up it answers null (blocked). */ + let allow: boolean[]; + let lines: AnsiAwareBuffer[]; + + beforeEach(async () => { + env = await createTestRuntime(); + opened = []; + allow = []; + lines = []; + const w = globalThis.window as unknown as { + location: { href: string }; + open: (url: string, target: string) => unknown; + }; + w.location = { href: 'https://app.example/' }; + w.open = (url: string) => { + if (!allow.shift()) return null; + opened.push(new URL(url).searchParams.get('profile') ?? ''); + return {}; + }; + env.session.events.on('message', (line?: AnsiAwareBuffer | string) => { + if (line instanceof AnsiAwareBuffer) lines.push(line); + }); + useAppStore.setState(s => ({ + connections: [ + ...s.connections.filter(c => c.id !== 'alt-profile' && c.id !== 'third-profile'), + { id: 'alt-profile', name: 'Alt', url: 'ws://localhost' }, + { id: 'third-profile', name: 'Third', url: 'ws://localhost' }, + ], + })); + }); + + afterEach(() => { + env.dispose(); + useAppStore.setState(s => ({ + connections: s.connections.filter(c => c.id !== 'alt-profile' && c.id !== 'third-profile'), + })); + }); + + /** The "Open profile " link's click handler on the line holding it. */ + function linkFor(name: string): () => void { + const line = lines.find(l => l.text.includes(`Open profile ${name}`)); + expect(line, `no "Open profile ${name}" line in ${JSON.stringify(env.mainOutput)}`).toBeDefined(); + const onClick = line!.getStateAt(line!.text.indexOf(`Open profile ${name}`))?.hyperlink?.onClick; + expect(onClick).toBeTypeOf('function'); + return () => onClick!({} as MouseEvent); + } + + it('opens the first of two profiles from one alias and leaves a link for the second', () => { + allow = [true]; // one gesture, one tab + const result = env.run(` + local a, b = loadProfile("Alt"); local c, d = loadProfile("Third") + return tostring(a) .. "|" .. tostring(b) .. "|" .. tostring(c) .. "|" .. tostring(d)`); + expect(result).toBe( + "true|nil|nil|loadProfile: could not open profile 'Third', the browser blocked the new tab; " + + 'click the link in the main window to open it'); + expect(opened).toEqual(['alt-profile']); + expect(env.mainOutput).toContain("The browser blocked opening profile 'Third' from a script. Open profile Third"); + + // The click is a gesture of its own, so the browser lets it through. + allow = [true]; + linkFor('Third')(); + expect(opened).toEqual(['alt-profile', 'third-profile']); + }); + + it('puts the link on a line of its own after the line a trigger fired on', () => { + feedLine(env, 'AUTOLOAD Alt', 'loadProfile("Alt")'); + expect(opened).toEqual([]); + expect(env.mainOutput).toEqual([ + 'AUTOLOAD Alt', + "The browser blocked opening profile 'Alt' from a script. Open profile Alt", + ]); + allow = [true]; + linkFor('Alt')(); + expect(opened).toEqual(['alt-profile']); + }); + + it('does not open a second tab from a link once the profile is open', () => { + env.run('loadProfile("Alt")'); + const presence = (env.api as unknown as { presence: Presence }).presence; + presence.loadedIds = () => ['test-connection', 'alt-profile']; + allow = [true]; + linkFor('Alt')(); + expect(opened).toEqual([]); + expect(env.mainOutput.at(-1)).toBe("Profile 'Alt' is already open."); + }); + + it('says so when the click itself is refused', () => { + env.run('loadProfile("Alt")'); + linkFor('Alt')(); // allow is empty: still blocked (pop-ups disabled outright) + expect(env.mainOutput.at(-1)).toMatch(/^The browser blocked opening profile 'Alt'; allow pop-ups/); + }); +}); + +describe('setActiveProfile for a profile in another tab (#453)', () => { + let env: TestRuntime; + let presence: Presence; + let flagged: string[]; + + beforeEach(async () => { + env = await createTestRuntime(); + presence = (env.api as unknown as { presence: Presence }).presence; + flagged = []; + presence.requestAttention = (id) => { flagged.push(id); return true; }; + useAppStore.setState(s => ({ + connections: [ + ...s.connections.filter(c => c.id !== 'main-profile'), + { id: 'main-profile', name: 'Main', url: 'ws://localhost' }, + ], + })); + }); + + afterEach(() => { + env.dispose(); + useAppStore.setState(s => ({ connections: s.connections.filter(c => c.id !== 'main-profile') })); + }); + + it('answers false with the reason, and flags the other tab, rather than claiming a switch', () => { + presence.loadedIds = () => ['test-connection', 'main-profile']; + expect(env.run('local ok, err = setActiveProfile("main") return tostring(ok) .. "|" .. err')).toBe( + "false|setActiveProfile: profile 'Main' is open in another browser tab, which a page cannot bring " + + "to the front; that tab's title is flashing instead"); + expect(flagged).toEqual(['main-profile']); + }); + + it('still answers true for this tab\'s own profile, without flagging anything', () => { + expect(env.run('return setActiveProfile("Test")')).toBe(true); + expect(flagged).toEqual([]); + }); + + it('flags nothing for a profile that is not loaded', () => { + expect(env.run('local ok, err = setActiveProfile("Main") return tostring(ok) .. "|" .. err')) + .toBe("false|setActiveProfile: profile 'Main' is not loaded"); + expect(flagged).toEqual([]); + }); +}); + +describe('ProfilesPresence attention requests', () => { + it('delivers an attention request to the tab that owns the profile, and only to it', async () => { + const a = new ProfilesPresence('profile-a', () => false); + const b = new ProfilesPresence('profile-b', () => false); + const c = new ProfilesPresence('profile-c', () => false); + const bFlagged = vi.fn(); + const cFlagged = vi.fn(); + b.onAttentionRequested = bFlagged; + c.onAttentionRequested = cFlagged; + try { + expect(a.requestAttention('profile-b')).toBe(true); + await vi.waitFor(() => expect(bFlagged).toHaveBeenCalledTimes(1)); + expect(cFlagged).not.toHaveBeenCalled(); + } finally { + a.destroy(); + b.destroy(); + c.destroy(); + } + }); +}); + +describe('raiseGlobalEvent timing (#453)', () => { + // Pins the recorded divergence: another tab's handler has not run when + // raiseGlobalEvent returns, only on a later task. If this ever starts + // running inline, the knownDivergences entry and docs are stale. + it('reaches the other profile after the call returns, not during it', async () => { + const received: unknown[][] = []; + const main = new GlobalEventChannel(() => {}, () => 'Main'); + const alt = new GlobalEventChannel((_name, args) => received.push(args), () => 'Alt'); + try { + main.raise('whatHP', []); + expect(received).toEqual([]); + await vi.waitFor(() => expect(received).toEqual([['Main']])); + } finally { + main.close(); + alt.close(); + } + }); + + it('every point of the issue is recorded as a known divergence', () => { + const apis = PLATFORM_DIVERGENCES.filter(d => d.issue === '#453').map(d => d.api); + expect(apis).toEqual(['loadProfile(name)', 'setActiveProfile(name)', 'raiseGlobalEvent(name, ...)']); + }); +}); From c4a8d3453e70d867dc920dba439afa5c73d6ac9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:53:57 +0000 Subject: [PATCH 2/2] Pin raiseGlobalEvent's delivery path (#453) Investigated whether the cross-profile delay has an avoidable part: it doesn't. raise() posts synchronously and the receiving tab dispatches into Lua straight from the BroadcastChannel message event, so the browser's cross-tab hop is the only delay. Say so where a later change would add one, and test that the sender never receives its own global event, as desktop's postInterHostEvent skips the sending host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUGrWWjYht6BA7w5asDWRi --- src/scripting/GlobalEventChannel.ts | 4 ++++ tests/scripting/multiProfile453.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/scripting/GlobalEventChannel.ts b/src/scripting/GlobalEventChannel.ts index bb460131..3b8b64be 100644 --- a/src/scripting/GlobalEventChannel.ts +++ b/src/scripting/GlobalEventChannel.ts @@ -30,6 +30,10 @@ export class GlobalEventChannel { ) { if (typeof BroadcastChannel === 'undefined') return; this.channel = new BroadcastChannel('mudlet:global-events'); + // Dispatched straight from the message event — no timer, await or + // batching. The BroadcastChannel hop is the only delay between tabs + // (mudlet-web#453; desktop runs other profiles' handlers inline), so + // nothing here may add another. this.channel.onmessage = (e: MessageEvent) => { const msg = e.data as GlobalEventMessage | null; if (!msg || typeof msg.name !== 'string') return; diff --git a/tests/scripting/multiProfile453.test.ts b/tests/scripting/multiProfile453.test.ts index b6a4fbba..a9f2fd68 100644 --- a/tests/scripting/multiProfile453.test.ts +++ b/tests/scripting/multiProfile453.test.ts @@ -209,6 +209,21 @@ describe('raiseGlobalEvent timing (#453)', () => { } }); + it('never delivers to the sending profile, as desktop\'s postInterHostEvent skips the sender', async () => { + const mine: string[] = []; + const theirs: string[] = []; + const main = new GlobalEventChannel((name) => mine.push(name), () => 'Main'); + const alt = new GlobalEventChannel((name) => theirs.push(name), () => 'Alt'); + try { + main.raise('ping', []); + await vi.waitFor(() => expect(theirs).toEqual(['ping'])); + expect(mine).toEqual([]); + } finally { + main.close(); + alt.close(); + } + }); + it('every point of the issue is recorded as a known divergence', () => { const apis = PLATFORM_DIVERGENCES.filter(d => d.issue === '#453').map(d => d.api); expect(apis).toEqual(['loadProfile(name)', 'setActiveProfile(name)', 'raiseGlobalEvent(name, ...)']);