feat(magazine): share an article or magazine as a deep link into driving mode
Share buttons (toolbar, playlist, driving mode) build #s-magazine?doc=..&article=..&mode=drive; opening it routes to the article and starts driving mode with a Tap-to-play prompt if autoplay is blocked. Ids are validated strictly; access is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
270b8d56ca
commit
f5b5993e4d
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=<id>&article=<id>&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback).
|
||||
- **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard.
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -290,6 +290,7 @@ turned into a podcast discussion between two or three voices you pick.
|
||||
playlist player.
|
||||
**Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it.
|
||||
**Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. Older audio, interview-style audio and audio whose text was edited afterwards has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync.
|
||||
**Share a link:** the *Share* button in the toolbar (the whole magazine), the share button in the playlist header and the one at the top left of driving mode (the current article) share a link of the form `<this app's address>/#s-magazine?doc=<magazine>&article=<article>&mode=drive`. Where the browser has a share sheet (phones, and any browser on https) it opens; otherwise the link is copied and a confirmation is announced (on a plain-http address a box with the link is shown to copy by hand). Opening the link goes straight to that article in driving mode with audio, pictures and text; if the browser refuses to start audio without a tap, a large *Tap to play* button appears. A link for the whole magazine opens at its first unfinished article. **Privacy and access:** the link contains only ids, no password or key. It is just an address: whoever opens it needs to reach this app the same way as for any other page (this app has no user accounts; access is controlled by your network or reverse proxy), and an unknown or removed article simply shows a message. Only articles that already have audio can be shared. A link whose parameters are not in the expected form is ignored.
|
||||
|
||||
**Expected result:** the article plays from its headline to its final paragraph.
|
||||
Page furniture (running heads, page numbers), adverts, sidebars and pull quotes
|
||||
|
||||
@ -5286,7 +5286,9 @@ function magPlaylistPlay(articleId, options) {
|
||||
const resumeAt = options?.fromStart || saved.completed ? 0 : Number(saved.seconds || 0);
|
||||
const resume = () => {
|
||||
if (resumeAt > 0 && resumeAt < audio.duration - 1) audio.currentTime = resumeAt;
|
||||
audio.play().catch(() => {});
|
||||
// A browser refuses to start audio without a tap on this page (autoplay policy); a caller that
|
||||
// opened the player from a link passes onPlayBlocked to show a Play prompt instead of failing silently.
|
||||
audio.play().catch((e) => { if (options?.onPlayBlocked) options.onPlayBlocked(e); });
|
||||
};
|
||||
if (audio.readyState >= 1) resume();
|
||||
else audio.addEventListener('loadedmetadata', resume, { once: true });
|
||||
@ -5572,6 +5574,117 @@ function magOpenDriving() {
|
||||
if (!state.driving) magToggleDriving();
|
||||
}
|
||||
|
||||
// ── Share: a deep link that opens an article (or the whole magazine) in driving mode ───────────────
|
||||
// The link is only an address, #s-magazine?doc=<id>&article=<id>&mode=drive, on this app's own origin. It
|
||||
// carries no credentials and grants nothing: whoever opens it reaches the app the same way as anyone
|
||||
// else (the app's network / reverse-proxy access rules apply, and every API call is checked as usual).
|
||||
const MAG_DOC_ID_RE = /^[A-Za-z0-9]{1,40}$/;
|
||||
const MAG_ARTICLE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
// Strict parse of the URL fragment. Anything that is not exactly this shape is ignored (returns null); the ids
|
||||
// are only ever matched against the loaded data and never written into markup.
|
||||
function magParseDeepLink(hash) {
|
||||
const m = /^#?\/?s-magazine\?([^#]*)$/.exec(String(hash || '').trim());
|
||||
if (!m) return null;
|
||||
let params;
|
||||
try { params = new URLSearchParams(m[1]); } catch (_) { return null; }
|
||||
const doc = params.get('doc') || '';
|
||||
const article = params.get('article') || '';
|
||||
if (!MAG_DOC_ID_RE.test(doc) || params.get('mode') !== 'drive') return null;
|
||||
if (article && !MAG_ARTICLE_ID_RE.test(article)) return null;
|
||||
return { doc, article };
|
||||
}
|
||||
|
||||
function magDeepLinkUrl(docId, articleId) {
|
||||
const params = new URLSearchParams({ doc: docId });
|
||||
if (articleId) params.set('article', articleId);
|
||||
params.set('mode', 'drive');
|
||||
return location.origin + location.pathname + '#s-magazine?' + params.toString();
|
||||
}
|
||||
|
||||
function magShareFallback(url) {
|
||||
const sheet = $('mag-share-sheet'), input = $('mag-share-link');
|
||||
if (!sheet || !input) return;
|
||||
input.value = url;
|
||||
sheet.hidden = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function magShareClose() {
|
||||
const sheet = $('mag-share-sheet');
|
||||
if (sheet) sheet.hidden = true;
|
||||
}
|
||||
|
||||
async function magCopyText(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
|
||||
} catch (_) { /* fall through to the legacy path */ }
|
||||
// Plain-http LAN addresses are not secure contexts, where the Clipboard API does not exist.
|
||||
const box = document.createElement('textarea');
|
||||
box.value = text;
|
||||
box.setAttribute('readonly', '');
|
||||
box.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(box);
|
||||
box.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
|
||||
box.remove();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Share one article (articleId) or, without it, the magazine (opens at the first unfinished article).
|
||||
async function magShare(articleId) {
|
||||
if (!state.doc) return;
|
||||
let article = null;
|
||||
if (articleId) {
|
||||
article = state.articles.find((a) => a.id === articleId);
|
||||
if (!article || !MAG_ARTICLE_ID_RE.test(articleId)) return;
|
||||
if (!article.has_audio) return say('Generate this article\'s audio before sharing it', 'err');
|
||||
}
|
||||
const url = magDeepLinkUrl(state.doc.id, article ? article.id : '');
|
||||
const title = article ? (article.title || 'Article') : (state.doc.title || 'Magazine');
|
||||
const text = (article ? 'Listen to "' + title + '"' : 'Listen to ' + title) + (state.doc.title && article ? ' · ' + state.doc.title : '');
|
||||
if (navigator.share) {
|
||||
try { await navigator.share({ title, text, url }); return; }
|
||||
catch (e) { if (e && e.name === 'AbortError') return; } // dismissed the sheet; any other failure: copy instead
|
||||
}
|
||||
if (await magCopyText(url)) say('Link copied. Anyone who can reach this app can open it in driving mode.', 'success');
|
||||
else { say('Copy the link below', 'info'); magShareFallback(url); }
|
||||
}
|
||||
|
||||
function magShareCurrent() { return magShare(state.playlistCurrent || ''); }
|
||||
|
||||
// The Play prompt shown when the browser blocked autoplay for a deep-linked article.
|
||||
function magDrivingStartPrompt(show) {
|
||||
const btn = $('mag-driving-start');
|
||||
if (!btn) return;
|
||||
btn.hidden = !show;
|
||||
if (show) btn.focus();
|
||||
}
|
||||
|
||||
function magDrivingStart() {
|
||||
const audio = $('mag-playlist-audio');
|
||||
if (audio) audio.play().then(() => magDrivingStartPrompt(false)).catch(() => {});
|
||||
}
|
||||
|
||||
// Opens the link's magazine, then driving mode on its article. Returns true when driving mode is open.
|
||||
async function magOpenDeepLink(link) {
|
||||
if (!link) return false;
|
||||
await magOpen(link.doc);
|
||||
if (!state.doc || state.doc.id !== link.doc) return false; // not found or not allowed: magOpen already said so
|
||||
let article = link.article ? state.articles.find((a) => a.id === link.article) : null;
|
||||
if (link.article && !article) { say('That article is not in this magazine any more', 'err'); return false; }
|
||||
if (article && !article.has_audio) { say('That article has no audio yet', 'err'); return false; }
|
||||
const order = _magPlaylistOrder();
|
||||
if (!article) article = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0];
|
||||
if (!article) { say('This magazine has no generated audio yet', 'err'); return false; }
|
||||
magTogglePlaylist(true);
|
||||
magPlaylistPlay(article.id, { onPlayBlocked: () => magDrivingStartPrompt(true) });
|
||||
if (!state.driving) magToggleDriving();
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderDriving() {
|
||||
const a = state.article;
|
||||
if (!a) return;
|
||||
@ -5834,6 +5947,7 @@ function wirePlaylistAudio() {
|
||||
if (!audio || audio.dataset.wired) return;
|
||||
audio.dataset.wired = '1';
|
||||
audio.addEventListener('ended', _magPlaylistNext);
|
||||
audio.addEventListener('playing', () => magDrivingStartPrompt(false));
|
||||
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); });
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const a = state.articles.find((x) => x.id === state.playlistCurrent);
|
||||
@ -5879,6 +5993,10 @@ async function magInit() {
|
||||
if (!$('mag-library-list')) return; // section not mounted
|
||||
magLoadLibrary();
|
||||
magLoadVoices();
|
||||
// A shared link (#s-magazine?doc=..&article=..&mode=drive) wins over "reopen the last magazine".
|
||||
const link = magParseDeepLink(window._ttsvcDeepLink || location.hash);
|
||||
window._ttsvcDeepLink = '';
|
||||
if (link && await magOpenDeepLink(link)) return;
|
||||
let lastId = null;
|
||||
try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {}
|
||||
if (lastId) {
|
||||
@ -6007,6 +6125,12 @@ root.magQueueNext = magQueueNext;
|
||||
root.magStopQueue = magStopQueue;
|
||||
root.magToggleDriving = magToggleDriving;
|
||||
root.magOpenDriving = magOpenDriving;
|
||||
root.magShare = magShare;
|
||||
root.magShareCurrent = magShareCurrent;
|
||||
root.magShareClose = magShareClose;
|
||||
root.magDrivingStart = magDrivingStart;
|
||||
root.magOpenDeepLink = magOpenDeepLink;
|
||||
root.magParseDeepLink = magParseDeepLink;
|
||||
root.magDrivingTogglePlayback = magDrivingTogglePlayback;
|
||||
root.magDrivingNext = magDrivingNext;
|
||||
root.magDrivingSkip = magDrivingSkip;
|
||||
|
||||
@ -20,6 +20,14 @@
|
||||
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library', 's-magazine'];
|
||||
|
||||
// A shared deep link's parameters are consumed by the section it names (magazine.js); keep the raw
|
||||
// fragment, since showing the section rewrites the address bar to the bare section.
|
||||
if (/^#s-magazine\?/.test(location.hash || '')) {
|
||||
window._ttsvcDeepLink = location.hash;
|
||||
// An explicit destination: a first visit must not be diverted to the first-run settings dialog.
|
||||
window._navRequestedSection = 's-magazine';
|
||||
}
|
||||
|
||||
function storedSection() {
|
||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
||||
}
|
||||
@ -31,6 +39,9 @@
|
||||
function hashSection() {
|
||||
var raw = String(location.hash || '').replace(/^#/, '').trim();
|
||||
if (!raw) return '';
|
||||
// A deep link carries parameters after the section (#s-magazine?doc=..); the section is what precedes them.
|
||||
var q = raw.indexOf('?');
|
||||
if (q >= 0) raw = raw.slice(0, q);
|
||||
if (raw.indexOf('section=') === 0) raw = raw.slice(8);
|
||||
raw = decodeURIComponent(raw).replace(/^\/?/, '');
|
||||
return SECTIONS.includes(raw) ? raw : '';
|
||||
@ -450,6 +461,13 @@
|
||||
|
||||
window.addEventListener('hashchange', function () {
|
||||
var section = hashSection();
|
||||
// A link pasted into an already-open tab: open it in place (or, if the magazine module is still
|
||||
// loading, leave it for the magazine's own startup).
|
||||
if (/^#s-magazine\?/.test(location.hash || '')) window._ttsvcDeepLink = location.hash;
|
||||
if (section === 's-magazine' && /^#s-magazine\?/.test(location.hash || '') && typeof window.magParseDeepLink === 'function') {
|
||||
var link = window.magParseDeepLink(location.hash);
|
||||
if (link) { showSection(section); window.magOpenDeepLink(link); return; }
|
||||
}
|
||||
if (section && section !== storedSection()) {
|
||||
if (typeof window.ensureFeatureModules === 'function') window.ensureFeatureModules(section).then(function () { showSection(section); }, function () { showSection(section); });
|
||||
else showSection(section);
|
||||
|
||||
@ -116,6 +116,10 @@
|
||||
title="Open the magazine playlist with large, distraction-free controls">
|
||||
<span class="mdi mdi-car-outline"></span> Driving mode
|
||||
</button>
|
||||
<button type="button" class="btn-sm" id="mag-share-book" onclick="magShare()"
|
||||
aria-label="Share a link that opens this magazine in driving mode" title="Share a link that opens this magazine in driving mode">
|
||||
<span class="mdi mdi-share-variant-outline" aria-hidden="true"></span> Share
|
||||
</button>
|
||||
<button type="button" class="btn-sm primary" onclick="magPlayAll()">
|
||||
<span class="mdi mdi-playlist-play"></span> Play all articles
|
||||
</button>
|
||||
@ -179,6 +183,9 @@
|
||||
<span id="mag-playlist-sub" class="muted"></span>
|
||||
<audio id="mag-playlist-audio" controls preload="none" style="width:100%"></audio>
|
||||
</div>
|
||||
<button type="button" class="btn-sm" onclick="magShareCurrent()" title="Share a link to this article" aria-label="Share a link to this article">
|
||||
<span class="mdi mdi-share-variant-outline" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="btn-sm" onclick="magTogglePlaylist()" title="Close playlist" aria-label="Close playlist">
|
||||
<span class="mdi mdi-close"></span>
|
||||
</button>
|
||||
@ -307,6 +314,12 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="mag-share-sheet" class="mag-share-sheet" role="dialog" aria-label="Share link" hidden>
|
||||
<label for="mag-share-link">Copy this link</label>
|
||||
<input type="text" id="mag-share-link" readonly>
|
||||
<button type="button" class="btn-sm" onclick="magShareClose()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Driving mode overlay ─────────────────────────────────────────── -->
|
||||
<!-- Large controls, minimal text, no interaction required beyond play/
|
||||
pause/skip -- reuses whatever is already playing rather than its
|
||||
@ -315,7 +328,13 @@
|
||||
<button type="button" class="mag-driving-exit" onclick="magToggleDriving()" aria-label="Exit driving mode">
|
||||
<span class="mdi mdi-close"></span>
|
||||
</button>
|
||||
<button type="button" class="mag-driving-share" onclick="magShareCurrent()" aria-label="Share a link to this article" title="Share a link to this article">
|
||||
<span class="mdi mdi-share-variant-outline" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="mag-driving-body">
|
||||
<button type="button" class="mag-driving-start" id="mag-driving-start" onclick="magDrivingStart()" hidden>
|
||||
<span class="mdi mdi-play-circle-outline" aria-hidden="true"></span> Tap to play
|
||||
</button>
|
||||
<h2 id="mag-driving-title">Article</h2>
|
||||
<p id="mag-driving-by" class="muted"></p>
|
||||
<div id="mag-driving-queue" class="mag-driving-queue" hidden>
|
||||
|
||||
@ -5685,3 +5685,19 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
|
||||
.mag-legend-count { min-width: 1.6em; padding: 0 6px; border-radius: 999px; background: var(--panel); font-size: 12px; text-align: center; }
|
||||
.mag-pages-shown { font-weight: 600; color: var(--accent); }
|
||||
.mag-legend-clear { margin-left: 6px; }
|
||||
|
||||
/* Share: the overlay's share button mirrors the exit button; the Play prompt is the big first tap a deep link needs. */
|
||||
.mag-driving-share { position: absolute; top: 20px; left: 20px; width: 52px; height: 52px;
|
||||
border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--text);
|
||||
font-size: 22px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.mag-driving-share:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.mag-driving-start { display: flex; align-items: center; justify-content: center; gap: 12px; width: 100%; min-height: 72px;
|
||||
margin: 0 0 16px; padding: 12px 20px; border: 0; border-radius: 16px; background: var(--accent); color: var(--on-accent, #fff);
|
||||
font-size: clamp(22px, 6vw, 32px); font-weight: 700; cursor: pointer; }
|
||||
.mag-driving-start[hidden] { display: none; }
|
||||
.mag-driving-start:focus-visible { outline: 3px solid var(--text); outline-offset: 3px; }
|
||||
.mag-share-sheet { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 600; box-sizing: border-box;
|
||||
width: min(520px, calc(100vw - 32px)); display: flex; flex-direction: column; gap: 8px; padding: 16px;
|
||||
background: var(--surface); color: var(--text); border: 2px solid var(--accent); border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
|
||||
.mag-share-sheet[hidden] { display: none; }
|
||||
.mag-share-sheet input { width: 100%; box-sizing: border-box; }
|
||||
|
||||
@ -1747,6 +1747,96 @@ test.describe('Hands-free listening', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Share link and deep link into driving mode', () => {
|
||||
const LINK = `/#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`;
|
||||
|
||||
async function openLink(page, url = LINK) {
|
||||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
}
|
||||
|
||||
test('opening a shared link goes straight to driving mode for that article and plays', async ({ page }) => {
|
||||
await openLink(page);
|
||||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||||
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
|
||||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line').first()).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||||
await expect(page.locator('#mag-driving-start')).toBeHidden();
|
||||
});
|
||||
|
||||
test('a blocked autoplay shows a big Play prompt that starts the audio', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const real = HTMLMediaElement.prototype.play;
|
||||
window.__blockPlay = true;
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
return window.__blockPlay ? Promise.reject(new DOMException('blocked', 'NotAllowedError')) : real.call(this);
|
||||
};
|
||||
});
|
||||
await openLink(page);
|
||||
const prompt = page.locator('#mag-driving-start');
|
||||
await expect(prompt).toBeVisible();
|
||||
const box = await prompt.boundingBox();
|
||||
expect(box.height).toBeGreaterThanOrEqual(64);
|
||||
await page.evaluate(() => { window.__blockPlay = false; });
|
||||
await prompt.click();
|
||||
await expect(prompt).toBeHidden();
|
||||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||||
});
|
||||
|
||||
test('a malformed or hostile link is ignored: no driving mode, nothing injected', async ({ page }) => {
|
||||
const bad = `/#s-magazine?doc=${DOC_ID}&article=%22%3E%3Cimg%20src%3Dx%20onerror%3Dwindow.__xss%3D1%3E&mode=drive`;
|
||||
await openLink(page, bad);
|
||||
await navTo(page, 's-magazine');
|
||||
await expect(page.locator('#mag-driving')).toBeHidden();
|
||||
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||||
const { parse } = await page.evaluate(() => ({ parse: [
|
||||
window.magParseDeepLink('#s-magazine?doc=../etc&mode=drive'),
|
||||
window.magParseDeepLink('#s-magazine?doc=abc&article=a/b&mode=drive'),
|
||||
window.magParseDeepLink('#s-magazine?doc=abc'),
|
||||
window.magParseDeepLink('#s-magazine?doc=abc&article=article-001&mode=drive'),
|
||||
] }));
|
||||
expect(parse).toEqual([null, null, null, { doc: 'abc', article: 'article-001' }]);
|
||||
});
|
||||
|
||||
test('Share uses the system share sheet with a deep link, at 375 px wide', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 700 });
|
||||
await page.addInitScript(() => { navigator.share = (data) => { window.__shared = data; return Promise.resolve(); }; });
|
||||
await openLink(page, '/');
|
||||
await navTo(page, 's-magazine');
|
||||
await page.locator('#mag-library-list .mag-card').first().click();
|
||||
await page.getByRole('button', { name: 'Playlist' }).click();
|
||||
await page.locator('.mag-playlist-track-play').click();
|
||||
await page.evaluate(() => window.magOpenDriving());
|
||||
const share = page.locator('#mag-driving .mag-driving-share');
|
||||
await expect(share).toHaveAttribute('aria-label', /Share/);
|
||||
const box = await share.boundingBox();
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(375);
|
||||
await share.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const shared = await page.evaluate(() => window.__shared);
|
||||
const url = new URL(shared.url);
|
||||
expect(url.hash).toBe(`#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`);
|
||||
expect(url.search).toBe(''); // no tokens or keys anywhere in the link
|
||||
});
|
||||
|
||||
test('Share without the share sheet copies the link and says so (aria-live toast)', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__copied = t; return Promise.resolve(); } }, configurable: true });
|
||||
});
|
||||
await openLink(page, '/');
|
||||
await navTo(page, 's-magazine');
|
||||
await page.locator('#mag-library-list .mag-card').first().click();
|
||||
await page.locator('#mag-share-book').click();
|
||||
await expect(page.locator('#toast')).toContainText('Link copied');
|
||||
await expect(page.locator('#toast')).toHaveAttribute('aria-live', 'polite');
|
||||
expect(await page.evaluate(() => window.__copied)).toBe(`${new URL(page.url()).origin}/#s-magazine?doc=${DOC_ID}&mode=drive`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Page cards say what to do', () => {
|
||||
const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user