openvibers/openvibe.shared Commits

4.209.0: the Quest guide on any page, and the current Live step in the Dock (#469)

OP OpenVibers committed 2 days ago · 28acdef021 · 14 files, +147 −17

* Expose the Quest guide to browser pages with opaque step links

* Align Shared release status and README version

* Show Live's current step in the Dock agent view

* Expose quest guide in shared browser files

* Wait for late rendered quest controls

* Refresh Dock asset revisions for the current agent step

* Mount quest cards from declared page hosts

* Merge remote-tracking branch 'origin/main' into quest-guide-finish-1110

# Conflicts:
#	CHANGELOG.md

* Start quest guide from shared navbar on signed-in sites

* Merge remote-tracking branch 'origin/quest-guide-finish-1110' into release/4207

# Conflicts:
#	CHANGELOG.md
#	STATUS.json
#	package-lock.json
#	package.json

* Merge remote-tracking branch 'origin/feat/agents-everywhere-step-shared-1111b' into release/4207

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	dock.js
#	navbar.js
#	package-lock.json
#	package.json

* 4.209.0: the Quest guide on any page, and the current Live step in the Dock's agent view (carries #464 and #466)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f78c021..0a4bc5f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 4.209.0 — 2026-11-11
++
++**The Quest guide on any page, and the current Live step in the Dock's agent view.**
++
++- **The Quest guide for browser pages (#464).** Pages can start the Quest guide from an opaque `qstep` link once the host
++ resolves the step; it waits for late controls and checks the Events-backed completion read, and the shared navbar
++ starts it from its own sign-in.
++- **The Dock's agent view shows the current Live step (#466)** when the stream supplies one; older streams keep showing
++ the task without a step line.
++
## 4.208.0 — 2026-11-11
**One move engine for every floating thing: the picture-in-picture's drag, flick, tuck, pinch, corner and place, now
ddiff --git a/README.md b/README.md
iindex a9f6e6d..2e2b49b 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.208.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.209.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex dc7222a..68a23c4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.208.0",
++ "version": "4.209.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"features": [
++ "the Quest guide on any page and the current Live step in the Dock's agent view (4.209.0)",
"one move engine for a floating thing: OVFloat.attach gives the PiP's drag, flick, tuck and kept place to any box, and a panel window adopts it (4.208.0)",
"the menu Feed searches Community with examples and uses the one composer for pictures and files; the Chat summary opens the Vibe tab (4.206.0)",
"the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
ddiff --git a/dock-agent.js b/dock-agent.js
iindex ca8ad5e..459dacf 100644
---- a/dock-agent.js
++++ b/dock-agent.js
@@@ -16,7 +16,7 @@
// curtain state and the viewer count. The embed renews its own ticket while it plays.
//
// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.
--// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.
++// Live's S2 current step comes through Codes when available; an older stream simply has no step line.
// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.
// ═══════════════════════════════════════════════════════════════
(function (root) {
@@@ -97,10 +97,11 @@
container.classList.add('ovda');
const stage = h('div', 'ovda-stage');
const line = h('div', 'ovda-line');
++ const step = h('div', 'ovda-meta ovda-step');
const meta = h('div', 'ovda-meta');
const acts = h('div', 'ovda-acts');
const head = h('div', 'ovda-head');
-- head.append(line, meta, acts);
++ head.append(line, meta, step, acts);
const body = h('div');
body.append(stage, head);
container.append(body);
@@@ -178,6 +179,11 @@
else title.textContent = text;
line.append(title);
++ const current = agent.current_step;
++ step.textContent = current && typeof current.title === 'string' && current.title.trim()
++ ? `Step: ${current.title} · ${current.status || 'started'}` : '';
++ step.hidden = !step.textContent;
++
// The meta: the state, the viewers, the curtain, the worked time.
meta.textContent = '';
const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
ddiff --git a/dock.js b/dock.js
iindex 0a0d762..9c11289 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '061fd680f4f7', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '061fd680f4f7', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/files.js b/files.js
iindex e45bd88..3c5c1d2 100644
---- a/files.js
++++ b/files.js
@@@ -73,6 +73,7 @@ const BROWSER = Object.freeze([
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'quest.js', // the daily quest card any site can show (OpenVibeQuest.mount); draw with quest.css
++ 'guide.js', // the opaque quest step guide (OpenVibeQuestGuide.startFromLocation)
'tooltip.js',
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'ov-sync.js', // one timeline for many players (seven domains B4): OVSync.attach(media, { room, socket }); no dependencies
ddiff --git a/guide.js b/guide.js
iindex addee93..2ea78a4 100644
---- a/guide.js
++++ b/guide.js
@@@ -1,7 +1,9 @@
'use strict';
++(function (root) {
++
/** Point at a real hooked control without activating it or covering it. The host owns pointer styling. */
--function startQuestGuide({ step, nextHref, root = document, onDone = () => {} } = {}) {
++function startQuestGuide({ step, nextHref, root = document, onDone = () => {}, checkDone = null, pollMs = 5000 } = {}) {
if (!step || !/^[a-z][a-z0-9_.-]{1,63}$/.test(step.hook || '') || !step.event_type) return () => {};
const control = root.querySelector(`[data-ov-guide="${CSS.escape(step.hook)}"]`);
if (!control) return () => {};
@@@ -15,6 +17,7 @@ function startQuestGuide({ step, nextHref, root = document, onDone = () => {} }
const done = (event) => {
if (finished || !event || event.detail?.event_type !== step.event_type) return;
finished = true;
++ if (poll) clearInterval(poll);
note.replaceChildren(root.createTextNode('Done. '));
if (nextHref) {
const link = root.createElement('a'); link.href = nextHref; link.textContent = 'Next step'; note.append(link);
@@@ -22,12 +25,40 @@ function startQuestGuide({ step, nextHref, root = document, onDone = () => {} }
onDone(step);
};
root.addEventListener('ov:quest:step-done', done);
++ const poll = typeof checkDone === 'function' && !step.done ? setInterval(async () => {
++ try { const latest = await checkDone(); if (latest?.step?.done) done({ detail: { event_type: step.event_type } }); }
++ catch { /* the Events receipt may still be arriving; the next read retries */ }
++ }, pollMs) : null;
++ if (poll && typeof poll.unref === 'function') poll.unref();
const dismiss = (event) => {
if (event.key === 'Escape' || (event.type === 'pointerdown' && !note.contains(event.target) && !control.contains(event.target))) stop();
};
-- function stop() { root.removeEventListener('ov:quest:step-done', done); root.removeEventListener('keydown', dismiss); root.removeEventListener('pointerdown', dismiss); note.remove(); }
++ function stop() { if (poll) clearInterval(poll); root.removeEventListener('ov:quest:step-done', done); root.removeEventListener('keydown', dismiss); root.removeEventListener('pointerdown', dismiss); note.remove(); }
root.addEventListener('keydown', dismiss); root.addEventListener('pointerdown', dismiss);
++ if (step.done) done({ detail: { event_type: step.event_type } });
return stop;
}
--module.exports = { startQuestGuide };
++/** Resolve only a short opaque step id. The host supplies an authenticated reader. */
++async function startFromLocation({ load, location = root.location, document: page = root.document, onDone } = {}) {
++ if (typeof load !== 'function' || !location || !page) return () => {};
++ const id = new URL(location.href).searchParams.get('qstep');
++ if (!id || !/^[A-Za-z0-9_-]{12,48}$/.test(id)) return () => {};
++ const answer = await load(id);
++ if (!answer || !answer.step || !answer.step.hook) return () => {};
++ // Many sites render the target after their page data arrives. Watch briefly instead of losing the guide.
++ const selector = `[data-ov-guide="${CSS.escape(answer.step.hook)}"]`;
++ if (!page.querySelector(selector) && page.defaultView?.MutationObserver) await new Promise((resolve) => {
++ const observer = new page.defaultView.MutationObserver(() => {
++ if (page.querySelector(selector)) { observer.disconnect(); clearTimeout(timer); resolve(); }
++ });
++ const timer = setTimeout(() => { observer.disconnect(); resolve(); }, 15000);
++ observer.observe(page.documentElement, { childList: true, subtree: true });
++ });
++ return startQuestGuide({ step: answer.step, nextHref: answer.next_href, root: page, onDone, checkDone: () => load(id) });
++}
++
++const api = { startQuestGuide, startFromLocation };
++if (typeof module === 'object' && module.exports) module.exports = api;
++if (root && root.document) root.OpenVibeQuestGuide = api;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/navbar.js b/navbar.js
iindex a020f6f..19d7542 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '2dc02f5569ac', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b5a78ad67810', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '0ec4ae476c63', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b5a78ad67810', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
@@@ -1939,6 +1939,26 @@
}
}
++ let _questGuideStarted = false;
++ function startQuestGuide() {
++ if (_questGuideStarted || !_config.token || !root.location || !/[?&]qstep=/.test(root.location.search || '')) return;
++ _questGuideStarted = true;
++ const script = document.createElement('script');
++ script.src = siblingV('guide.js');
++ script.onload = () => {
++ if (!root.OpenVibeQuestGuide) return;
++ void root.OpenVibeQuestGuide.startFromLocation({ load: async (id) => {
++ try {
++ const response = await fetch(`https://openvibe.quest/api/v1/quests/today/steps/${encodeURIComponent(id)}`, {
++ headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'omit',
++ });
++ return response.ok ? response.json() : null;
++ } catch { return null; }
++ } });
++ };
++ document.head.appendChild(script);
++ }
++
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
@@@ -1950,6 +1970,7 @@
render();
recordHistory();
enableHandoff();
++ startQuestGuide();
} else {
maybeSilentLogin();
}
@@@ -2631,6 +2652,7 @@
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
else if (!_config.user) refreshAuthState();
else { recordHistory(); enableHandoff(); }
++ startQuestGuide();
return el;
},
@@@ -2688,7 +2710,7 @@
/** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
openWallet,
refreshWallet() { _walletAt = 0; loadWalletChips(); },
-- setUser(user, token) { if (root.OpenVibeWallet && (_config.user !== user || (token !== undefined && token !== _config.token))) root.OpenVibeWallet.close(); _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
++ setUser(user, token) { if (root.OpenVibeWallet && (_config.user !== user || (token !== undefined && token !== _config.token))) root.OpenVibeWallet.close(); _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; const result = render(); startQuestGuide(); return result; },
/** Single-page apps: mark the link whose `page` matches as active. */
setActive(page) { _activePage = page || null; renderAppBar(); if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
/** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
ddiff --git a/package-lock.json b/package-lock.json
iindex 990411d..942ede1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.208.0",
++ "version": "4.209.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.208.0",
++ "version": "4.209.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 12c71ae..5fec42b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.208.0",
++ "version": "4.209.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/quest.js b/quest.js
iindex 0e0c4ee..389bd3c 100644
---- a/quest.js
++++ b/quest.js
@@@ -191,6 +191,12 @@
return api;
}
-- root.OpenVibeQuest = { mount };
-- if (typeof module === 'object' && module.exports) module.exports = { mount };
++ /** Mount every explicit quest host in a page. The site supplies its authenticated reader. */
++ function mountAll({ load, root: page = document } = {}) {
++ if (typeof load !== 'function') throw new TypeError('OpenVibeQuest.mountAll: load() must return the quest data');
++ return [...page.querySelectorAll('[data-ov-quest]')].map((host) => mount(host, { load: () => load(host) }));
++ }
++
++ root.OpenVibeQuest = { mount, mountAll };
++ if (typeof module === 'object' && module.exports) module.exports = { mount, mountAll };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 6b49e25..ed34c4e 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -460,6 +460,7 @@ async function drag(p, from, to) {
const view = () => ({
id: AGENT_ID, state: 'live', title: 'Fix the flaky upload test',
task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },
++ current_step: { title: 'Run the checkout tests', status: 'started' },
curtain: { up: false }, viewers: 2, worked_seconds: 720, visibility: 'private',
page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test`, last_frame_url: null,
@@@ -475,6 +476,7 @@ async function drag(p, from, to) {
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line'));
++ await eventually(async () => assert.ok((await p.evaluate("document.querySelector('.ovda-step').textContent")).includes('Run the checkout tests'), 'the current step'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min'));
await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list'));
ddiff --git a/test/navbar-auth.test.js b/test/navbar-auth.test.js
iindex 62811af..b0529cf 100644
---- a/test/navbar-auth.test.js
++++ b/test/navbar-auth.test.js
@@@ -387,6 +387,15 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-avatar'));
}
++ // A signed-in destination starts the shared guide only when an opaque step link is present.
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.chat', href: 'https://openvibe.chat/?qstep=abc123def456' });
++ const scripts = [];
++ document.head.appendChild = (el) => scripts.push(el.src);
++ navbar.init({ service: 'chat', token: 'person-token', user: USER });
++ assert.strictEqual(scripts.filter((src) => /guide\.js/.test(src)).length, 1);
++ }
++
// The top-level SDK route reads Network's first-party session even where third-party cookies are blocked; a guest
// comes back with sso=none. It runs when there is a reason to think the person has a session: this site signed them
// in before ('account'), or they came from another OpenVibe site. A first visit from elsewhere keeps the quiet
ddiff --git a/test/quest.test.js b/test/quest.test.js
iindex 6ede322..6424685 100644
---- a/test/quest.test.js
++++ b/test/quest.test.js
@@@ -3,14 +3,16 @@
// OpenVibe.Live home card, marks the next step, tolerates a missing `more`, and redraws on refresh. The guide's
// own test stays here too: it points at a hooked control and finishes on its event.
const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const vm = require('node:vm');
const { parseHTML } = require('linkedom');
--const { startQuestGuide } = require('../guide');
++const { startQuestGuide, startFromLocation } = require('../guide');
const files = require('../files');
const { window, document } = parseHTML('<!doctype html><html><head></head><body></body></html>');
global.window = window;
global.document = document;
--const { mount } = require('../quest');
++const { mount, mountAll } = require('../quest');
const step = (site, sentence, extra = {}) => ({ site: `openvibe.${site}`, sentence, href: `https://openvibe.${site}/`, ...extra });
const quest = (extra = {}) => ({
@@@ -29,7 +31,14 @@ function host() {
(async () => {
assert.ok(files.isBrowserFile('quest.js'), 'sites serve quest.js');
++ assert.ok(files.isBrowserFile('guide.js'), 'sites serve guide.js');
assert.ok(files.isBrowserFile('quest.css'), 'sites serve quest.css');
++ const declarativeHost = host();
++ declarativeHost.setAttribute('data-ov-quest', '');
++ const [declarative] = mountAll({ load: async () => ({ signed_in: false, quest: quest() }) });
++ assert.strictEqual(declarative, declarativeHost._ovq);
++ await declarative.refresh();
++ assert.equal(declarative.el.hidden, false);
// Signed out: today's quest, the three doors, no streak and no progress.
const out = mount(host(), { load: async () => ({ signed_in: false, quest: quest(), more: { quests: 24, sites: 8 } }) });
@@@ -144,5 +153,38 @@ function host() {
stop();
assert.equal(note.removed, true);
++ let requested = null;
++ const stopFromLink = await startFromLocation({
++ location: { href: 'https://openvibe.pics/?qstep=AbC_0123456789' }, document: guideRoot,
++ load: async (id) => { requested = id; return { step: { hook: 'pics.upload', help: 'Choose a picture', event_type: 'mediahub.picture.uploaded' } }; },
++ });
++ assert.equal(requested, 'AbC_0123456789');
++ assert.equal(control.arrived, true);
++ stopFromLink();
++ let mountedLater = false;
++ const delayedRoot = { ...guideRoot, querySelector: () => mountedLater ? control : null,
++ documentElement: {}, defaultView: { MutationObserver: class {
++ constructor(notify) { this.notify = notify; }
++ observe() { mountedLater = true; this.notify(); }
++ disconnect() {}
++ } } };
++ const stopDelayed = await startFromLocation({ location: { href: 'https://openvibe.pics/?qstep=AbC_0123456789' },
++ document: delayedRoot, load: async () => ({ step: { hook: 'pics.upload', help: 'Choose a picture', event_type: 'mediahub.picture.uploaded' } }) });
++ assert.equal(mountedLater, true);
++ stopDelayed();
++ let observed = false;
++ const stopPolled = startQuestGuide({ root: guideRoot,
++ step: { hook: 'pics.upload', help: 'Choose a picture', event_type: 'mediahub.picture.uploaded' },
++ checkDone: async () => ({ step: { done: true } }), pollMs: 1, onDone: () => { observed = true; } });
++ await new Promise((resolve) => setTimeout(resolve, 10));
++ assert.equal(observed, true, 'the guide follows the Events-backed step read');
++ stopPolled();
++ requested = null;
++ await startFromLocation({ location: { href: 'https://openvibe.pics/?qstep=plain text' }, document: guideRoot, load: async (id) => { requested = id; } });
++ assert.equal(requested, null);
++ const browser = { document: guideRoot };
++ vm.runInNewContext(fs.readFileSync(require.resolve('../guide'), 'utf8'), { window: browser });
++ assert.equal(typeof browser.OpenVibeQuestGuide.startFromLocation, 'function');
++
process.stdout.write('quest card and guide passed\n');
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });