|
| 1 | +"""A Microsoft Store copy says when the Store has a newer version. |
| 2 | +
|
| 3 | +The Store updates a Store copy, on its own schedule; the app never downloads |
| 4 | +or installs anything there. What it does is ask the Store's public catalog |
| 5 | +which version is offered and compare that with the one running, so somebody |
| 6 | +whose Store has not updated yet is told, with a button to the Store page. |
| 7 | +
|
| 8 | +The two decisions in that (which version the catalog's answer names, and |
| 9 | +whether one version is newer than another) are in ui/src/main/storeVersion.ts, |
| 10 | +which has no Electron in it and is run here under Node. The rest is checked as |
| 11 | +text: the ids match the ones the package and the README use, and a Store copy |
| 12 | +still never reaches for the installer's updater. |
| 13 | +""" |
| 14 | + |
| 15 | +import json |
| 16 | +import re |
| 17 | +import shutil |
| 18 | +import subprocess |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +import pytest |
| 22 | + |
| 23 | +ROOT = Path(__file__).resolve().parent.parent |
| 24 | +MAIN = ROOT / "ui" / "src" / "main" |
| 25 | + |
| 26 | + |
| 27 | +def _node() -> str: |
| 28 | + node = shutil.which("node") |
| 29 | + if node is None: |
| 30 | + pytest.skip("Node isn't available") |
| 31 | + return node |
| 32 | + |
| 33 | + |
| 34 | +def _run(tmp_path, cases: list, body: str) -> list: |
| 35 | + """Run `body` (JavaScript: `m` is the module, `c` one case) over the cases.""" |
| 36 | + (tmp_path / "storeVersion.ts").write_text((MAIN / "storeVersion.ts").read_text(encoding="utf-8"), encoding="utf-8") |
| 37 | + (tmp_path / "cases.json").write_text(json.dumps(cases), encoding="utf-8") |
| 38 | + script = ( |
| 39 | + f"const m = await import({json.dumps((tmp_path / 'storeVersion.ts').as_uri())});" |
| 40 | + "const fs = await import('node:fs');" |
| 41 | + f"const cases = JSON.parse(fs.readFileSync({json.dumps(str(tmp_path / 'cases.json'))}, 'utf8'));" |
| 42 | + f"console.log(JSON.stringify(cases.map((c) => {{ {body} }})));" |
| 43 | + ) |
| 44 | + r = subprocess.run([_node(), "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", script], |
| 45 | + capture_output=True, text=True, encoding="utf-8", timeout=120) |
| 46 | + if r.returncode != 0 and "strip-types" in r.stderr: |
| 47 | + pytest.skip("this Node can't run TypeScript directly") |
| 48 | + assert r.returncode == 0, r.stderr |
| 49 | + return json.loads(r.stdout) |
| 50 | + |
| 51 | + |
| 52 | +def _catalog(*full_names): |
| 53 | + """The catalog's answer, cut down to the part that names packages.""" |
| 54 | + return {"Product": {"ProductId": "9NB6XT7DSQZZ", "DisplaySkuAvailabilities": [ |
| 55 | + {"Sku": {"Properties": {"Packages": [{"PackageFullName": n, "MaxDownloadSizeInBytes": 1}]}}} |
| 56 | + for n in full_names]}} |
| 57 | + |
| 58 | + |
| 59 | +def test_the_version_the_store_offers_is_read_from_its_packages(tmp_path): |
| 60 | + ours = "ClipsStudio.ClipsStudio_{}_x64__315g1r74a6w58" |
| 61 | + cases = [ |
| 62 | + _catalog(ours.format("2.0.0.0")), |
| 63 | + _catalog(ours.format("2.0.0.0"), ours.format("2.0.0.0")), # one per SKU, as the Store answers today |
| 64 | + _catalog(ours.format("1.2.0.0"), ours.format("2.1.0.0"), ours.format("2.0.0.0")), # the highest |
| 65 | + _catalog(ours.format("2.9.0.0"), ours.format("2.10.0.0")), # by number, not by letter |
| 66 | + _catalog("SomeoneElse.Other_9.0.0.0_x64__abcdefghjkmnp", ours.format("2.0.0.0")), # only this app's |
| 67 | + _catalog("SomeoneElse.Other_9.0.0.0_x64__abcdefghjkmnp"), |
| 68 | + _catalog(ours.format("2.0.0")), # not a package version |
| 69 | + _catalog(), |
| 70 | + {"Product": None}, |
| 71 | + {"code": "NotFound"}, |
| 72 | + None, |
| 73 | + "not json at all", |
| 74 | + [], |
| 75 | + ] |
| 76 | + got = _run(tmp_path, cases, "return m.storeVersionOf(c)") |
| 77 | + assert got == ["2.0.0", "2.0.0", "2.1.0", "2.10.0", "2.0.0", None, None, None, None, None, None, None, None] |
| 78 | + |
| 79 | + |
| 80 | +def test_newer_means_a_later_version_and_nothing_else(tmp_path): |
| 81 | + cases = [ |
| 82 | + ("2.0.0", "1.1.4", True), ("2.0.0", "1.2.0", True), ("2.0.1", "2.0.0", True), ("2.10.0", "2.9.0", True), |
| 83 | + ("2.0.0", "2.0.0", False), ("2.0", "2.0.0", False), ("2.0.0", "2.0", False), |
| 84 | + ("1.2.0", "2.0.0", False), # the Store behind this copy (a test build): no "update" |
| 85 | + ("2.1.0-beta.1", "2.0.0", False), ("2.1.0", "2.1.0-beta.1", False), ("", "2.0.0", False), |
| 86 | + ("latest", "2.0.0", False), |
| 87 | + ] |
| 88 | + got = _run(tmp_path, [list(c[:2]) for c in cases], "return m.isNewer(c[0], c[1])") |
| 89 | + assert got == [c[2] for c in cases] |
| 90 | + |
| 91 | + |
| 92 | +def test_the_ids_are_the_ones_the_package_and_the_readme_use(): |
| 93 | + source = (MAIN / "storeVersion.ts").read_text(encoding="utf-8") |
| 94 | + product = re.search(r"STORE_PRODUCT_ID = '([A-Z0-9]+)'", source).group(1) |
| 95 | + identity = re.search(r"STORE_IDENTITY = '([\w.]+)'", source).group(1) |
| 96 | + assert f"apps.microsoft.com/detail/{product}" in (ROOT / "README.md").read_text(encoding="utf-8") |
| 97 | + builder = (ROOT / "ui" / "electron-builder.yml").read_text(encoding="utf-8") |
| 98 | + assert re.search(rf"^\s*identityName: {re.escape(identity)}\s*$", builder, re.M) |
| 99 | + # The Store's own page and catalog, by that id, and nowhere else. |
| 100 | + assert "ms-windows-store://pdp/?ProductId=${STORE_PRODUCT_ID}" in source |
| 101 | + assert "https://displaycatalog.mp.microsoft.com/v7.0/products/${STORE_PRODUCT_ID}?" in source |
| 102 | + |
| 103 | + |
| 104 | +def test_a_store_copy_asks_the_store_and_never_runs_the_installers_updater(): |
| 105 | + """Everything a Store copy does about updates is before the `return` that |
| 106 | + ends its branch, and none of it touches electron-updater.""" |
| 107 | + source = (MAIN / "updater.ts").read_text(encoding="utf-8") |
| 108 | + start = source.index("if (isMicrosoftStore()) {") |
| 109 | + branch = source[start:source.index("\n return\n }\n", start)] |
| 110 | + assert "autoUpdater" not in branch |
| 111 | + assert "net.fetch(STORE_CATALOG" in branch and "storeVersionOf(" in branch and "isNewer(" in branch |
| 112 | + for handler in ("update:check", "update:download", "update:install", "update:skip", "update:prefs"): |
| 113 | + assert f"ipcMain.handle('{handler}'" in branch, handler |
| 114 | + # Download and install stay refused there: the Store does both. |
| 115 | + assert "ipcMain.handle('update:download', async () => ({ ok: false }))" in branch |
| 116 | + assert "ipcMain.handle('update:install', () => ({ ok: false }))" in branch |
| 117 | + # The button opens the Store's page, a fixed address the page can't choose. |
| 118 | + assert "shell.openExternal(STORE_PAGE)" in source |
| 119 | + |
| 120 | + |
| 121 | +def test_the_screens_say_which_version_and_offer_the_store(): |
| 122 | + renderer = ROOT / "ui" / "src" / "renderer" / "src" |
| 123 | + settings = (renderer / "pages" / "Settings.tsx").read_text(encoding="utf-8") |
| 124 | + banner = (renderer / "components" / "UpdateBanner.tsx").read_text(encoding="utf-8") |
| 125 | + assert "Version ${state.version} is in the Microsoft Store. This copy is ${state.current}." in settings |
| 126 | + assert "You're on the latest version (${state.current})." in settings |
| 127 | + assert "Couldn't check the Microsoft Store just now." in settings |
| 128 | + assert "window.studio.update.openStore()" in settings and "window.studio.update.openStore()" in banner |
| 129 | + # The bar shows only when the Store is ahead and that version wasn't skipped. |
| 130 | + assert "if (!s.behind || s.skipped) return null" in banner |
| 131 | + assert "openStore: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('update:openStore')" in ( |
| 132 | + ROOT / "ui" / "src" / "preload" / "index.ts").read_text(encoding="utf-8") |
0 commit comments