summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_downloads.py
blob: 32d3e11d42673cd4082e4636ba1e59907326f7a4 (plain) (blame)
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
"""
Where downloads are written.

The module is mostly browser plumbing — a directory handle from a picker, kept
in IndexedDB — but two pieces decide behaviour and can be checked here: that
automatic is the default, and that writing into the same folder repeatedly does
not quietly replace what is already there. The second one is the whole risk of
the automatic mode: a Save As dialog warns you about a collision, and a folder
you never look at does not.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
DOWNLOADS = STATIC / "downloads.js"

pytestmark = pytest.mark.skipif(
    shutil.which("node") is None or not DOWNLOADS.exists(),
    reason="node or the SPA sources are not available")


def _run(body, tmp_path):
    module = tmp_path / "downloads.mjs"
    module.write_text(DOWNLOADS.read_text())
    script = tmp_path / "case.mjs"
    script.write_text(
        # A localStorage good enough for a preference, so the module can be
        # imported outside a browser at all.
        "const store = new Map();\n"
        "globalThis.localStorage = {\n"
        "  getItem: k => (store.has(k) ? store.get(k) : null),\n"
        "  setItem: (k, v) => store.set(k, String(v)),\n"
        "};\n"
        f"const M = await import('{module.as_posix()}');\n"
        "const out = [];\n"
        "const say = (...a) => out.push(...a);\n"
        f"{body}\n"
        "console.log(JSON.stringify(out));\n")
    proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
    assert proc.returncode == 0, proc.stderr
    return json.loads(proc.stdout)


def test_saving_automatically_is_the_default(tmp_path):
    """
    Grouped downloads are the reason this setting exists: twelve files must not
    mean twelve dialogs unless someone asked for that.
    """
    assert _run("say(M.getMode());", tmp_path) == ["auto"]


def test_the_choice_is_remembered_and_nothing_else_is_accepted(tmp_path):
    result = _run("""
M.setMode('ask');   say(M.getMode());
M.setMode('auto');  say(M.getMode());
M.setMode('nonsense'); say(M.getMode());
""", tmp_path)
    assert result == ["ask", "auto", "auto"]


def test_a_second_copy_does_not_replace_the_first(tmp_path):
    result = _run("""
const taken = new Set(['clip.mp4', 'clip (2).mp4', 'notes']);
const exists = async n => taken.has(n);
say(await M.freeName('clip.mp4', exists));
say(await M.freeName('other.mp4', exists));
say(await M.freeName('notes', exists));
say(await M.freeName('archive.tar.gz', exists));
""", tmp_path)
    assert result == [
        "clip (3).mp4",      # (2) was taken as well
        "other.mp4",         # free: left alone
        "notes (2)",         # no extension to keep
        "archive.tar.gz",    # free, and the double extension is not mangled
    ]


def test_the_suffix_goes_before_the_extension(tmp_path):
    """
    "clip.mp4 (2)" would stop being a video as far as the operating system is
    concerned, which is how a download folder ends up full of files nothing opens.
    """
    result = _run("""
say(await M.freeName('clip.mp4', async () => false));
say(await M.freeName('clip.mp4', async n => n === 'clip.mp4'));
""", tmp_path)
    assert result == ["clip.mp4", "clip (2).mp4"]


def test_a_browser_without_the_api_reports_it(tmp_path):
    """`SUPPORTED` decides whether Settings offers a choice or an explanation."""
    assert _run("say(M.SUPPORTED);", tmp_path) == [False]


def test_the_open_action_reads_the_file_back(tmp_path):
    """
    "Open" is the browser being handed the bytes, not a desktop application
    being started — no web page can do the second, and none can show a file
    manager either. It is only offered for a file written into a granted folder,
    since that is the one a page can read back.
    """
    src = DOWNLOADS.read_text()
    target = src[src.index("export async function openTarget"):]
    assert "getFile()" in target and "window.open(" in target
    assert "revokeObjectURL" in target, "the blob URL must not be leaked"


# ── Streaming to disk without the File System Access API ────────────────────

SW = STATIC / "sw.js"


def test_the_worker_only_answers_its_own_urls():
    """
    It is registered at the root scope, so it sees every request the page makes.
    Anything that is not a download of ours has to fall through untouched — a
    service worker that answers more than it should is a cache bug waiting to
    happen.
    """
    src = SW.read_text()
    assert "startsWith(PREFIX)" in src
    assert "self.location.origin" in src, "cross-origin requests must fall through"
    # The API, not the word: the file explains in prose that it caches nothing.
    for api in ("caches.open", "caches.match", "cache.put"):
        assert api not in src, f"this worker must not cache anything ({api})"


def test_the_download_is_announced_as_an_attachment():
    src = SW.read_text()
    assert "Content-Disposition" in src and "attachment" in src
    assert "filename*=UTF-8''" in src, "a name with accents would be mangled"
    assert "Content-Length" in src


def test_a_length_is_only_promised_when_it_is_known(tmp_path):
    """
    An archive is assembled as it goes and is larger than the files in it.
    Announcing the sum of their sizes would truncate the download at that mark.
    """
    src = SW.read_text()
    assert "if (entry.size > 0)" in src

    # The zip-directory download started in files-app.js (group-page refactor)
    # and was lifted into file-utils.js's downloadDirectory (docs/photos.md
    # §3) so photos-app.js's own "zip this album" button calls the same
    # implementation rather than a second one.
    app = (STATIC / "file-utils.js").read_text()
    zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):]
    zip_call = zip_call[:zip_call.index(");") + 2]
    assert zip_call.rstrip().endswith(", 0);"), (
        "the zip download announces a Content-Length it will not match")


def test_backpressure_is_real(tmp_path):
    """
    The point of the service worker path is not holding the file. A stream that
    is transferred gives `writer.write()` something to wait on; posting chunks
    to a port would queue them in memory and look identical from here.
    """
    src = DOWNLOADS.read_text()
    fn = src[src.index("export async function openStreamedDownload"):]
    assert "new TransformStream()" in fn
    # The transfer list may carry more than the stream — a reply port rides
    # along now — so this asserts that `readable` is transferred, not the exact
    # shape of the list.
    transfer = fn[fn.index("worker.postMessage("):]
    transfer = transfer[transfer.index("["):transfer.index("]") + 1]
    assert "readable" in transfer, "the readable half must be transferred, not copied"
    assert "writer.write(bytes)" in fn
    assert "return null" in fn, "a browser that cannot transfer streams must say so"


def test_the_streamed_path_gives_up_rather_than_blocking_for_ever():
    """Reported 2026-08-16: a download frozen at exactly one chunk.

    The writable half applies real backpressure, which is the whole point — and
    the trap. If nothing ever reads the readable half, `writer.write()` waits
    for room that never comes, and the transfer stops dead after the stream's
    internal queue fills. Two ways that happens on a phone: the page is not yet
    *controlled* by the worker, so the iframe's request is never handed to its
    fetch handler; or the browser refuses a download started from a hidden
    iframe. Both are silent.

    So the worker confirms that it actually answered, and this path reports
    failure instead of returning a sink nobody drains.
    """
    src = DOWNLOADS.read_text()
    fn = src[src.index("export async function openStreamedDownload"):]
    assert "mbdl-serving" in fn, "the worker has to confirm it served the request"
    assert "Promise.race" in fn, "the confirmation needs a deadline"
    assert "writable.abort" in fn, "give up cleanly so the caller can fall back"

    sw = (DOWNLOADS.parent / "sw.js").read_text()
    assert "mbdl-serving" in sw, "and the worker has to send that confirmation"


def test_an_uncontrolled_page_is_not_treated_as_ready():
    """`registration.active` says a worker exists, not that it will see our fetch."""
    src = DOWNLOADS.read_text()
    fn = src[src.index("async function serviceWorker()"):]
    fn = fn[:fn.index("\n}")]
    assert "navigator.serviceWorker.controller" in fn
    assert "controllerchange" in fn, (
        "control can arrive a tick after registration; waiting beats refusing")