File size: 21,902 Bytes
e46ffb6
 
 
 
 
 
 
 
 
 
 
 
ee79633
e46ffb6
 
 
 
 
ee79633
 
e46ffb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee79633
e46ffb6
ee79633
e46ffb6
 
 
 
 
 
 
 
 
ee79633
 
 
 
e46ffb6
ee79633
 
e46ffb6
b43da19
 
e46ffb6
 
b43da19
 
e46ffb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee79633
 
 
e46ffb6
 
 
 
 
 
ee79633
e46ffb6
 
 
ee79633
 
e46ffb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee79633
e46ffb6
b43da19
 
b0fb94f
e46ffb6
 
 
b43da19
 
e46ffb6
 
ee79633
 
 
 
 
 
 
 
 
 
b0fb94f
 
 
 
 
 
e46ffb6
 
 
 
b0fb94f
b43da19
e46ffb6
 
 
 
ee79633
 
 
 
 
e46ffb6
 
 
 
 
 
 
ee79633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e46ffb6
 
 
 
 
 
 
 
 
 
 
b0fb94f
b43da19
e46ffb6
 
 
 
 
 
 
 
b43da19
ee79633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0fb94f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e46ffb6
 
 
 
 
ee79633
 
 
 
b0fb94f
b43da19
b0fb94f
 
e46ffb6
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
"""Explicit offline Chromium reader integration using mocked jobs and real MP3.

No inference, corpus or production calls. Supply an already generated real MP3.
Muting avoids this host's stalled physical audio sink; it is not audible/phone QA.
"""
from __future__ import annotations

import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import sys
from urllib.parse import urlparse

from playwright.sync_api import sync_playwright

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from tts.text import playback_segments, utf16_offset
BASE = "http://127.0.0.1:19876"


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--sample", type=Path, required=True)
    parser.add_argument("--output", type=Path, default=Path("/tmp/opencode/hitokoto-reader-browser.json"))
    args = parser.parse_args()
    audio = args.sample.read_bytes()
    results = {"at": datetime.now(timezone.utc).isoformat(), "scope": __doc__, "cases": []}
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
        try:
            for mobile in (False, True):
                context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900})
                jobs, requests, tokens, audio_requests, deleted = {}, [], {}, [], []
                lost = [False]
                hold_text, held = [None], []
                page_errors = []

                def reply(route, data, status=200):
                    route.fulfill(status=status, content_type="application/json", body=json.dumps(data), headers={"Cache-Control": "no-store"})

                def describe(job):
                    state = "expired" if job.get("expired") else "cancelled" if job.get("cancelled") else "ready" if job["polls"] >= 5 else "running"
                    job["state"] = state
                    text = job["body"]["text"]
                    spans = [(utf16_offset(text, start), utf16_offset(text, end)) for start, end, _ in playback_segments(text)]
                    count = len(spans) if job["polls"] >= 3 else 1 if job["polls"] else 0
                    value = {"id": job["id"], "state": state, "cache_hit": False, "expires_in": 900,
                             "source_segments": [{"start": start, "end": end} for start, end in spans]}
                    if count and state not in {"cancelled", "expired"}:
                        value["progress"] = {"complete": state == "ready", "total_parts": len(spans), "parts": [
                            {"index": index, "total_parts": len(spans), "start": spans[index][0], "end": spans[index][1],
                             "audio_start": index * 10.6, "audio_end": (index + 1) * 10.6, "audio_seconds": 10.6,
                              "bytes": len(audio), "first_audio_seconds": .2,
                              "pitch_semitones": job["body"].get("pitch_semitones", 0),
                             "audio_url": f"/tts/audio/{job['id']}/parts/{index}.mp3"} for index in range(count)]}
                    if state == "ready":
                        value["result"] = {"audio_seconds": 21.2, "synthesis_seconds": 2.0, "audio_url": f"/tts/audio/{job['id']}.mp3",
                                           "pitch_semitones": job["body"].get("pitch_semitones", 0)}
                    return value

                def handler(route):
                    request = route.request
                    path = urlparse(request.url).path
                    names = {"/tts/": ("tts.html", "text/html"), "/tts/app.js": ("tts.js", "text/javascript"),
                             "/tts/reader.js": ("reader.js", "text/javascript"), "/tts/style.css": ("tts.css", "text/css")}
                    if path in names:
                        name, mime = names[path]
                        route.fulfill(content_type=mime, body=(ROOT / "static" / name).read_bytes())
                    elif path == "/tts/health":
                        reply(route, {"ready": True, "engine": {"threads": 2}, "queued": 0})
                    elif path == "/tts/voices":
                        reply(route, {"voices": [{"id": name, "name": name} for name in ("zf_001", "zf_017", "zm_010", "zm_029")]})
                    elif path == "/tts/jobs" and request.method == "POST":
                        body = request.post_data_json
                        requests.append(body)
                        token = body["request_id"]
                        if token not in tokens:
                            id = f"{len(jobs) + 1:032d}"
                            tokens[token] = id
                            jobs[id] = {"id": id, "body": body, "polls": 0, "state": "running"}
                        # Simulate an accepted POST whose response is lost. The
                        # retry must reuse the token, not allocate another job.
                        if mobile and not lost[0]:
                            lost[0] = True
                            route.abort("connectionfailed")
                        elif body["text"] == hold_text[0]:
                            hold_text[0] = None
                            held.append((route, describe(jobs[tokens[token]])))
                        else:
                            reply(route, describe(jobs[tokens[token]]))
                    elif re.fullmatch(r"/tts/jobs/[0-9]{32}", path):
                        job = jobs[path.rsplit("/", 1)[-1]]
                        if request.method == "DELETE":
                            job["cancelled"] = True
                            deleted.append(job["id"])
                        else:
                            job["polls"] += 1
                        reply(route, describe(job))
                    elif re.fullmatch(r"/tts/audio/[0-9]{32}/parts/[0-9]+\.mp3", path):
                        audio_requests.append(path)
                        data, status, headers = audio, 200, {"Cache-Control": "private, max-age=300"}
                        match = re.fullmatch(r"bytes=(\d+)-(\d*)", request.headers.get("range", ""))
                        if match:
                            start = int(match[1])
                            end = min(len(audio) - 1, int(match[2]) if match[2] else len(audio) - 1)
                            data, status = audio[start:end + 1], 206
                            headers["Content-Range"] = f"bytes {start}-{end}/{len(audio)}"
                        route.fulfill(status=status, content_type="audio/mpeg", body=data, headers=headers)
                    else:
                        reply(route, {"error": {"code": "not_mocked", "message": path}}, 404)

                context.route(BASE + "/**", handler)
                page = context.new_page()
                page.set_default_timeout(60000)
                page.on("pageerror", lambda error: page_errors.append(str(error)))
                page.add_init_script('''document.addEventListener("DOMContentLoaded", () => {
                    const audio=document.querySelector("#audio");audio.muted=true;
                });''')
                try:
                    page.goto(BASE + "/tts/", wait_until="domcontentloaded")
                    page.wait_for_function('!document.querySelector("#generate").disabled')
                    assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 1')
                    assert page.evaluate('localStorage.getItem(ReaderPlan.bookmarkKey)') is None
                    text = "这是一篇长文,校长文章写得很好,我得亲自处理。😀\n" * 42 + "<b>原文不是HTML</b>"
                    page.locator("#speech-text").fill(text)
                    page.locator("#pitch").evaluate("node=>{node.value=-2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
                    assert page.locator("#pitch-value").text_content().startswith("-2")
                    page.locator("#playback-rate").evaluate("node=>{node.value=2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
                    page.locator("#generate").click()
                    page.wait_for_function('document.querySelector("#audio").currentTime > .2 && !document.querySelector("#audio").paused')
                    assert page.evaluate('session.jobs.get(session.batch).state') == "running"
                    assert page.locator("#pitch").is_disabled()
                    assert all(body["pitch_semitones"] == -2 for body in requests)
                    page.locator("#play").click()
                    assert page.locator("#audio").evaluate("node => node.paused")
                    assert page.locator("#reader-source").text_content() == text
                    assert page.locator("#reader-source b").count() == 0
                    current = page.evaluate("({start:session.plan[session.current.batch].start+session.current.part.start,end:session.plan[session.current.batch].start+session.current.part.end})")
                    assert page.locator("#current-source").text_content() == text.encode('utf-16-le')[current['start']*2:current['end']*2].decode('utf-16-le')
                    assert page.locator('.source-part[aria-current="location"]').count() == 1
                    page.wait_for_function("session && !session.ticking")
                    post_count, clip_count = len(requests), len(audio_requests)
                    page.wait_for_timeout(1400)
                    assert len(requests) == post_count and len(audio_requests) == clip_count
                    assert page.evaluate("!preloader.hasAttribute('src') && !session.wantsPlay")
                    count = len(requests)
                    page.locator("#playback-rate").evaluate("node=>{node.value=1.75;node.dispatchEvent(new Event('input',{bubbles:true}));}")
                    assert page.locator("#audio").evaluate("node=>node.playbackRate") == 1.75
                    assert len(requests) == count
                    assert all(body['speed'] == 1 for body in requests)
                    page.locator("#playback-rate").evaluate("node=>{node.value=2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
                    page.locator("#remember").check()
                    page.evaluate("checkpoint(true)")
                    saved = page.evaluate("JSON.parse(localStorage.getItem(ReaderPlan.bookmarkKey))")
                    assert saved["text"] == text and saved["seconds"] > 0
                    assert saved["playbackRate"] == 2
                    assert saved["pitchSemitones"] == -2
                    assert "audio_url" not in saved and "id" not in saved
                    if not mobile:
                        expired_id = page.evaluate("session.jobs.get(session.batch).id")
                        jobs[expired_id]["expired"] = True
                        page.wait_for_function("session.jobs.get(session.batch).id === ''")
                        page.wait_for_function("session && !session.ticking")
                        count = len(requests)
                        page.wait_for_timeout(1400)
                        assert len(requests) == count, "Expired paused jobs must not automatically resubmit"
                        assert page.locator("#audio").evaluate("node => node.paused"), "Recovery must respect a user pause"
                    page.locator("#play").click()
                    page.wait_for_function("session && session.batch >= 1 && session.current && !document.querySelector('#audio').paused")
                    assert page.evaluate("session.jobs.size") <= 2
                    assert len({body["request_id"] for body in requests}) >= 2
                    if mobile:
                        assert requests[0]["request_id"] == requests[1]["request_id"]
                    # Cancel an incomplete speculative batch on native pause;
                    # the current batch is retained and no third batch is queued.
                    page.wait_for_function("session.jobs.get(session.batch+1)?.id && session.jobs.get(session.batch+1)?.state === 'running'")
                    prefetched = page.evaluate("session.jobs.get(session.batch+1).id")
                    page.locator("#audio").evaluate("node=>node.pause()")
                    page.wait_for_function("!session.wantsPlay")
                    page.wait_for_function("!session.jobs.has(session.batch+1)")
                    page.wait_for_function("id=>!session.ticking", arg=prefetched)
                    page.wait_for_timeout(200)
                    assert prefetched in deleted
                    assert page.evaluate("session.jobs.size") <= 1
                    # Previous/next and direct cross-batch source clicks preserve
                    # a user pause and do not create tasks or fetch another MP3.
                    navigation = page.evaluate("session.navigationIndex")
                    page.locator("#next").click()
                    assert page.evaluate("session.navigationIndex") == navigation + 1
                    page.locator("#previous").click()
                    assert page.evaluate("session.navigationIndex") == navigation
                    target = page.evaluate("session.locations.findIndex(part=>part.batch===session.batch+1)")
                    assert target >= 0
                    page.locator(f'.source-part[data-location="{target}"]').click()
                    page.wait_for_function("session && !session.ticking")
                    count, clips = len(requests), len(audio_requests)
                    page.wait_for_timeout(1400)
                    assert len(requests) == count and len(audio_requests) == clips
                    assert page.evaluate("session.navigationIndex") == target
                    assert page.evaluate("!session.current && !session.wantsPlay")
                    selected = page.evaluate("session.locations[session.navigationIndex].start")
                    page.locator("#locate").click()
                    page.locator("#play").click()
                    page.wait_for_function("session && session.current && !document.querySelector('#audio').paused")
                    assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == selected
                    # Rapid in-session seeks must not allow stale job responses
                    # to change the selected source or continue obsolete work.
                    page.locator("#next").click()
                    page.locator("#previous").click()
                    page.wait_for_function("session && session.current && !document.querySelector('#audio').paused")
                    assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == selected
                    page.evaluate('Object.defineProperty(document,"hidden",{configurable:true,get:()=>true});document.dispatchEvent(new Event("visibilitychange"))')
                    assert page.evaluate("session !== null")
                    assert not page.locator("#audio").evaluate("node => node.paused")
                    page.locator("#play").click()
                    page.evaluate("checkpoint(true)")
                    saved = page.evaluate("JSON.parse(localStorage.getItem(ReaderPlan.bookmarkKey))")
                    post_count = len(requests)
                    page.reload(wait_until="domcontentloaded")
                    page.wait_for_function('!document.querySelector("#resume").disabled')
                    assert len(requests) == post_count, "Restoring text must not automatically submit it"
                    assert page.locator("#speech-text").input_value() == text
                    assert page.locator("#playback-rate").input_value() == "2"
                    assert page.locator("#pitch").input_value() == "-2"
                    page.locator("#resume").click()
                    page.wait_for_function("session && session.current && document.querySelector('#audio').readyState >= 2")
                    assert page.evaluate("session.plan[session.current.batch].start + session.current.part.start") == saved["offset"]
                    page.wait_for_function("document.querySelector('#audio').currentTime > .1")
                    page.locator("#forget").click()
                    assert page.evaluate("localStorage.getItem(ReaderPlan.bookmarkKey)") is None
                    page.locator("#stop").click()
                    assert not page.locator("#audio").evaluate("node => node.hasAttribute('src')")
                    assert not page.locator("#pitch").is_disabled()
                    # Hold a speculative POST response after server acceptance,
                    # then pause before the capability comes back to the reader.
                    # Its late response must be released, not adopted/replayed.
                    hold_text[0] = page.evaluate("ReaderPlan.split(document.querySelector('#speech-text').value)[1].text")
                    page.locator("#generate").click()
                    page.wait_for_function("session && session.current && session.jobs.has(session.batch+1) && session.ticking && !session.jobs.get(session.batch+1).id")
                    assert len(held) == 1
                    page.locator("#play").click()
                    route, late = held.pop()
                    reply(route, late)
                    page.wait_for_function("session && !session.ticking")
                    page.wait_for_timeout(200)
                    assert late["id"] in deleted
                    assert page.evaluate("!session.wantsPlay && session.jobs.size===1 && !preloader.hasAttribute('src')")
                    page.locator("#stop").click()
                    # Sleep mode stops at the exact current clip anchor, saves
                    # the next original source position and releases owned work.
                    page.locator("#generate").click()
                    page.wait_for_function("session && session.current && document.querySelector('#audio').currentTime>.1")
                    assert page.locator("#audio").evaluate("node=>node.playbackRate") == 2
                    page.locator("#remember").check()
                    page.locator("#sleep-mode").select_option("clip")
                    sleep_end = page.evaluate("session.plan[session.current.batch].start+session.current.part.end")
                    page.locator("#audio").evaluate("node=>{node.currentTime=node.duration-.05;}")
                    page.wait_for_function("session===null && document.querySelector('#status').textContent.includes('睡眠定时')")
                    assert page.evaluate("bookmark.offset") == sleep_end
                    assert page.evaluate("!preloader.hasAttribute('src') && sleep.mode==='off'")
                    page.locator("#resume").click()
                    page.wait_for_function("session && session.current && document.querySelector('#audio').currentTime>.1")
                    assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == sleep_end
                    page.locator("#play").click()
                    page.locator("#sleep-mode").select_option("5")
                    assert page.evaluate("sleep.remaining()") <= 300
                    assert page.locator("#audio").evaluate("node=>node.paused")
                    # Inject only the pure timer's clock; no actual five-minute
                    # wait or fake inference is added to the default suite.
                    page.evaluate("sleep.clock=()=>sleep.deadline;document.dispatchEvent(new Event('visibilitychange'))")
                    page.wait_for_function("session===null")
                    assert page.locator("#status").text_content().startswith("睡眠定时")
                    assert page.evaluate("!document.querySelector('#audio').hasAttribute('src') && sleep.mode==='off'")
                    assert page.evaluate("!('mediaSession' in navigator) || navigator.mediaSession.playbackState==='none'")
                    page.locator("#forget").click()
                    assert not page_errors, page_errors
                    results["cases"].append({"mobile_viewport": mobile, "early_play_before_completion": True,
                                              "continuous_cross_batch": True, "bounded_prefetch": True,
                                              "bookmark_opt_in_reload_resume_clear": True,
                                              "lost_post_idempotent_retry": mobile, "expired_audio_recovery_while_paused": not mobile,
                                              "source_highlight_next_previous_click_seek": True,
                                              "pause_no_new_posts_or_preloads": True, "unfinished_prefetch_cancelled": True,
                                              "rapid_seek_stale_response_guard": True,
                                              "pause_during_post_late_capability_released": True,
                                              "instant_playback_rate_no_resynthesis_and_bookmark": True,
                                              "pitch_request_snapshot_and_opt_in_bookmark": True,
                                              "sleep_current_clip_stops_before_next_and_resumes": True,
                                              "sleep_deadline_while_paused_releases_work": True,
                                              "visibility_change_does_not_stop": True, "page_errors": page_errors})
                    print("Reader mobile" if mobile else "Reader desktop", "PASS", flush=True)
                finally:
                    context.close()
        finally:
            browser.close()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(results, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()