diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 58cf93fe..cf17c2fd 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -3,7 +3,7 @@ name: Auto Release # ── Single-draft release flow (there must be exactly ONE draft per tag) ────── # When a push to main changes the version in pyproject.toml: # 1. this workflow tags vX.Y.Z and creates/updates the draft release, with -# notes pulled from CHANGELOG.md; +# notes pulled from CHANGELOG.md (scripts/release_notes.py); # 2. the tag push triggers desktop-release.yml, which builds every platform, # signs the update enclosures, refreshes the appcasts, and attaches the # built assets to the SAME draft (softprops matches the release by tag). @@ -75,26 +75,21 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Extract the section for this version from CHANGELOG.md - NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md) - if [ -z "$NOTES" ]; then - NOTES="Release v${VERSION}" - fi + # The Highlights block (or, without one, the whole section) for this + # version; the rules live in scripts/release_notes.py. + python3 scripts/release_notes.py "${VERSION}" > /tmp/release-notes.md # Append upgrade instructions - cat > /tmp/release-notes.md << ENDNOTES - ${NOTES} + cat >> /tmp/release-notes.md << 'ENDNOTES' ### Upgrade - \`\`\`bash + ```bash docker pull epheterson/zimi:latest docker compose down && docker compose up -d - \`\`\` + ``` - Or \`pip install --upgrade zimi\` for Python installs. + Or `pip install --upgrade zimi` for Python installs. ENDNOTES - # Trim leading whitespace from heredoc - sed -i 's/^ //' /tmp/release-notes.md # One draft per tag: update an existing release (curated draft or a # re-run) instead of creating a duplicate. `gh release view` finds # drafts by tag too. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf0a38ef..ef80f600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,9 @@ jobs: # no upper bound while requirements.txt correctly pinned <2.0: anyone # running `pip install zimi[mcp]` got mcp 2.x, which dropped the vendored # FastMCP, and their MCP server refused to start. + # Pillow as requirements.txt has it (favicons; the Red contrast test reads pixels). - name: Install package - run: pip install -e ".[pdf,mcp]" pytest + run: pip install -e ".[pdf,mcp]" pytest "Pillow>=12.3.0" # A real browser, so the paint gate actually runs somewhere. # diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 42a39d6f..ba73040e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,6 +15,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + id-token: write # OIDC: the provenance attestation + attestations: write # store the provenance attestation steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -22,6 +24,11 @@ jobs: python-version: "3.11" - run: pip install build twine - run: python -m build + # Verify a download with: gh attestation verify --repo epheterson/Zimi + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/* - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: password: ${{ secrets.PYPI_API_TOKEN }} @@ -38,7 +45,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - id-token: write + id-token: write # trusted publishing, and the provenance attestation + attestations: write # store the provenance attestation steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -46,6 +54,11 @@ jobs: python-version: "3.11" - run: pip install build - run: python packaging/zimi-mcp/build.py --outdir dist-mcp + # Verify a download with: gh attestation verify --repo epheterson/Zimi + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist-mcp/* - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a182816..dccc0dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [1.14.1] - 2026-10-22 + +### Highlights + +- Read aloud marks the sentence being read and follows it. +- Settings follow your account across browsers. + +### Added + +- Read aloud: the sentence being read is marked and followed; tap a sentence or select text to start there. +- Read aloud: back button (once: this sentence again; twice: the one before). +- Read aloud: speed slider, 0.5× to 3×. +- Settings, Voices: speed test for each voice. +- Settings follow the signed-in account to every browser; the admin has an account. +- PDFs: two pages side by side when they fit. +- Books: the chapter in the footer opens the contents. +- Discover's word of the day is voiced in advance. +- Build provenance for PyPI wheels and sdists. + +### Changed + +- Numbers, dates, sizes, sorting and week start follow Zimi's language. +- Read aloud skips citation marks, infoboxes, tables, captions and references. +- Read aloud loads on first use. +- Dependency floors raised for known vulnerabilities. + +### Fixed + +- Red and Dark: text no longer matches its background ([#119](https://github.com/epheterson/Zimi/issues/119)). +- Private mode: sign-in could refuse the right password. +- Signing in shows your own library without a reload. +- Change password could keep the old password in the field. +- Read aloud: first tap on iPhone, taps on every page, menu closes, no false "slow" message. +- Read aloud: Automatic avoids voices measured too slow. +- Arabic and Hebrew: spacing and arrow direction. +- Offline PDF library list matches the app's. +- espeak's test voices are hidden. + ## [1.14.0] - 2026-10-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7996aa4..b282857c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,9 +33,13 @@ Before tagging, verify these on the feature branch: - [ ] `node -c zimi/static/app.js` passes (no syntax errors) - [ ] `python3 -m pytest tests/ -q` passes - [ ] `./scripts/release-gate.sh` passes — boots real servers against real ZIM files and drives every headline feature over HTTP, which is the only way to catch a feature that is correct in every module and still dead end to end (cross-ZIM link resolution once shipped serving an empty domain map, and no unit test noticed) -- [ ] CHANGELOG updated, README current, screenshots current +- [ ] CHANGELOG updated (with a `### Highlights` block, see Release notes below), README current, screenshots current - [ ] No debug print statements or console.logs that shouldn't ship +### Release notes + +Auto Release builds the draft's body from the version's `## [X.Y.Z]` section in `CHANGELOG.md` (`scripts/release_notes.py`). The section may open with a `### Highlights` block of one to three short one-line bullets, before `### Added` and the rest. When it does, the release body is those bullets plus a link to the full entry in the CHANGELOG at the version's tag. Without the block the whole section is the body. Highlights are one-line factual bullets, no headings or stories; the CHANGELOG itself keeps them as its first subsection. A draft that already exists is never overwritten, so a curated body wins. + ### Phase 1: Tag and build 1. Squash merge feature branch to `main` diff --git a/README.md b/README.md index 9512e52a..f92526cb 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Zimi [![CI](https://github.com/epheterson/Zimi/actions/workflows/ci.yml/badge.svg)](https://github.com/epheterson/Zimi/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-4343-brightgreen)](#) [![Lighthouse Accessibility](https://img.shields.io/badge/Lighthouse%20a11y-100%2F100-success?logo=lighthouse&logoColor=white)](docs/features/reading.md) [![WCAG 2.1 AA](https://img.shields.io/badge/WCAG%202.1-AA-blue)](docs/features/reading.md) [![i18n](https://img.shields.io/badge/i18n-10%20languages-blueviolet)](#languages) [![Docker Pulls](https://img.shields.io/docker/pulls/epheterson/zimi)](https://hub.docker.com/r/epheterson/zimi) [![PyPI](https://img.shields.io/pypi/v/zimi)](https://pypi.org/project/zimi/) [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE) -[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/epheterson/Zimi/badge)](https://scorecard.dev/viewer/?uri=github.com/epheterson/Zimi) A modern experience for your ZIM files. [Kiwix](https://kiwix.org) packages the world's knowledge into ZIM files. Zimi makes them feel like the real internet with a rich web UI, fast JSON API, and an MCP server for AI agents. Everything works offline, in your language. +**If you ❤️ Zimi, leave a ⭐️ to help others find it.** + Use as much as you need: - **Search for agents.** `pip install zimi-mcp` searches and reads every ZIM over MCP. 280 KB. diff --git a/desktop/requirements-desktop.txt b/desktop/requirements-desktop.txt index d5875018..9af3dd15 100644 --- a/desktop/requirements-desktop.txt +++ b/desktop/requirements-desktop.txt @@ -1,10 +1,11 @@ libzim>=3.1.0 PyMuPDF>=1.23.0,<2 -certifi>=2024.1.1 +certifi>=2026.10.19 pywebview>=4.0.0 -Pillow>=10.0.0 +Pillow>=13.0.0; python_version >= "3.11" +Pillow>=12.3.0; python_version < "3.11" pyinstaller>=6.0.0 -zeroconf>=0.130.0 +zeroconf>=0.149.16 # Windows desktop backend. pywebview 5.x/6.x drives the native Edge WebView2 # runtime through .NET, which it reaches via pythonnet (+ clr_loader). pywebview # declares `pythonnet` transitively, but we pin both explicitly with an env diff --git a/packaging/zimi-mcp/pyproject.toml b/packaging/zimi-mcp/pyproject.toml index 3edb0413..1c23a2d3 100644 --- a/packaging/zimi-mcp/pyproject.toml +++ b/packaging/zimi-mcp/pyproject.toml @@ -24,8 +24,8 @@ classifiers = [ # Pinned as the main package pins them (mcp<2.0: see its pyproject). dependencies = [ "libzim>=3.1.0", - "mcp>=1.0,<2.0", - "certifi>=2024.1.1", + "mcp>=1.30.0,<2.0", + "certifi>=2026.10.19", ] [project.optional-dependencies] diff --git a/pyproject.toml b/pyproject.toml index c5595607..8a425a19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "zimi" -version = "1.14.0" +version = "1.14.1" description = "Offline knowledge server for ZIM files — search and read Wikipedia, Stack Overflow and 50+ sources with no internet" readme = "README.md" license = {text = "GPL-3.0-or-later"} @@ -36,10 +36,10 @@ classifiers = [ ] dependencies = [ "libzim>=3.1.0", - "certifi>=2024.1.1", + "certifi>=2026.10.19", # LAN peer discovery (mDNS/Zeroconf). Pure-Python; the peer ZIM-sharing # transport runs over plain HTTP so it needs no extra binary. - "zeroconf>=0.130.0", + "zeroconf>=0.149.16", # BitTorrent accelerator: attempted at install, optional at runtime. # # libtorrent publishes NO sdist. So on any platform where a wheel is @@ -75,7 +75,7 @@ desktop = ["pywebview>=4.0.0"] # distribution. Without the ceiling, `pip install zimi[mcp]` resolves to 2.x and # the MCP server refuses to start -- issue #52, which was fixed in # requirements.txt and missed HERE, where PyPI users actually get their pins. -mcp = ["mcp>=1.0,<2.0"] +mcp = ["mcp>=1.30.0,<2.0"] # The rendered capture engine (zimi/renderer.py): a headless Chromium run as a # CHILD PROCESS of the server, for pages whose content is built in JavaScript. # Deliberately not in `all`: this package is only half the install — the browser @@ -88,7 +88,7 @@ browser = ["playwright>=1.40"] # Docker image's pins). Without them Zimi sets them up itself, in a venv under # its data directory, the first time someone downloads a voice. voices = ["piper-tts>=1.8,<2", "kokoro-onnx>=0.6,<0.7", "misaki[zh]>=0.9.4,<0.10", "num2words>=0.5.14,<0.6"] -all = ["PyMuPDF>=1.23.0,<2", "mcp>=1.0,<2.0", "libtorrent==2.0.*"] +all = ["PyMuPDF>=1.23.0,<2", "mcp>=1.30.0,<2.0", "libtorrent==2.0.*"] [project.scripts] zimi = "zimi.server:main" diff --git a/requirements.txt b/requirements.txt index 55602f48..524b66f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,14 +4,15 @@ PyMuPDF>=1.23.0,<2 # (moved it to a separate `fastmcp` distribution), so a bare `mcp` resolves to # 2.0 and the MCP server crashes on import (issue #52, hit via OpenWebUI). Pin to # the 1.x line that still ships FastMCP until the server is ported. -mcp>=1.0,<2.0 -certifi>=2024.1.1 -zeroconf>=0.130.0 +mcp>=1.30.0,<2.0 +certifi>=2026.10.19 +zeroconf>=0.149.16 # Rescales a captured site's icon into the 48x48 ZIM illustration. Without it # has_image_support() is False and every created ZIM ships iconless — which is # exactly what happened on the Docker image, where Pillow was absent (Eric: CNN # capture "no favicon" on prod, present locally where Pillow is installed). -Pillow>=10.0.0 +Pillow>=13.0.0; python_version >= "3.11" +Pillow>=12.3.0; python_version < "3.11" # BitTorrent accelerator. Marker-gated below 3.14 (no wheel there yet, and no # sdist) so a plain `pip install -r requirements.txt` never breaks on new # interpreters; missing = HTTP-only. Wheels cover manylinux + musllinux, macOS diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 00000000..e909880c --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""The GitHub release body for a version, from CHANGELOG.md. + +Auto Release calls this: ``release_notes.py 1.14.1 > notes.md``. + +A version's section may open with a ``### Highlights`` block of one to three +short sentences. The release body is then that block plus one line linking the +full entry in the CHANGELOG at the version's tag. A section without the block +is used whole, as it always was, and a version with no section at all gets a +bare "Release vX.Y.Z". +""" + +import re +import sys + +REPO_URL = "https://github.com/epheterson/zimi" +HIGHLIGHTS_HEADING = "### Highlights" + + +def _section(changelog, version): + """The lines under ``## [version]``, up to the next ``## [`` heading, and + the heading itself; ``(None, [])`` when the version has no section.""" + heading, body = None, [] + for line in changelog.splitlines(): + if heading is None: + if line.startswith("## [%s]" % version): + heading = line + elif line.startswith("## ["): + break + else: + body.append(line) + return heading, body + + +def _highlights(body): + """The text of a leading ``### Highlights`` block, or ``""``.""" + lines = iter(body) + for line in lines: + if line.strip(): + if line.strip() != HIGHLIGHTS_HEADING: + return "" + break + text = [] + for line in lines: + if line.startswith("### "): + break + text.append(line) + return "\n".join(text).strip() + + +def heading_anchor(heading): + """The fragment GitHub generates for a heading: lowercase, anything but + letters, digits, spaces, hyphens and underscores dropped, spaces to + hyphens. ``## [1.14.1] - 2026-10-20`` becomes ``1141---2026-10-20``.""" + text = heading.lstrip("#").strip().lower() + return re.sub(r"[^\w\- ]", "", text).replace(" ", "-") + + +def release_body(changelog, version, repo_url=REPO_URL): + heading, body = _section(changelog, version) + if heading is None: + return "Release v%s" % version + highlights = _highlights(body) + if not highlights: + return "\n".join(body).strip() or "Release v%s" % version + link = "%s/blob/v%s/CHANGELOG.md#%s" % (repo_url, version, heading_anchor(heading)) + return "%s\n\n[Full changelog for v%s](%s)" % (highlights, version, link) + + +def main(argv): + if len(argv) not in (2, 3): + sys.stderr.write("usage: release_notes.py VERSION [CHANGELOG.md]\n") + return 2 + with open(argv[2] if len(argv) == 3 else "CHANGELOG.md", encoding="utf-8") as f: + print(release_body(f.read(), argv[1])) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/app_source.cjs b/tests/app_source.cjs index e03d30ec..c399015f 100644 --- a/tests/app_source.cjs +++ b/tests/app_source.cjs @@ -18,3 +18,16 @@ module.exports = function appSource() { const table = JSON.parse(fs.readFileSync(path.join(root, 'assets', 'lang-codes.json'), 'utf8')); return src.replace(MARK, JSON.stringify(table)); }; + +// localeHelpers(src): app.js's number, date, sort and week helpers (fmtNum, +// fmtDate, cmpText, cmpTitle, _weekFirstDay), to run in a vm context before +// any part that formats or sorts. Zimi's language is the context's +// _currentLang, English when it has none. +module.exports.localeHelpers = function localeHelpers(src) { + const a = src.indexOf('function _uiLocale()'), b = src.indexOf('// Localized pluralization: picks'); + if (a < 0 || b < a) throw new Error('app.js locale helpers moved'); + return "if (typeof _currentLang === 'undefined') var _currentLang = 'en';\n" + + "var _uiLangPrimary = typeof _uiLangPrimary === 'function' ? _uiLangPrimary : function() { return String(_currentLang || 'en').split('-')[0]; };\n" + + "var _langPrimary = typeof _langPrimary === 'function' ? _langPrimary : function(t) { return String(t || '').split('-')[0]; };\n" + + src.slice(a, b); +}; diff --git a/tests/conftest.py b/tests/conftest.py index af4faaed..81435ee0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,24 @@ os.environ.setdefault("ZIMI_BEST_ANSWER", "1") +@pytest.fixture(autouse=True, scope="session") +def _cheap_password_hashing(): + """Every password set or checked costs 600,000 PBKDF2 rounds, a fraction + of a second by design, and the access tests do it hundreds of times. In + this process a hash is made and checked with the same count, so the + tests lose nothing at 1,000. Servers a test starts as `zimi serve` keep + the real count; none of them reads a password this process wrote.""" + try: + from zimi import manage + except Exception: + yield + return + real = manage._PW_ITERATIONS + manage._PW_ITERATIONS = 1_000 + yield + manage._PW_ITERATIONS = real + + @pytest.fixture(autouse=True) def _fresh_archive_pools(): try: diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 6b03f4d7..23b5194e 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ // This config lives in tests/; specs sit beside it, artifacts stay at repo root. testDir: '.', - testMatch: ['visual_validation.spec.mjs', 'test_password_flow.mjs', 'test_interlang.mjs', 'screenshots.mjs', 'test_tabs.mjs', 'test_almanac_hero_clock.spec.mjs', 'test_login_navigation.spec.mjs', 'test_private_mode_login.spec.mjs', 'test_admin_private_library.spec.mjs', 'test_backup_hub.spec.mjs', 'test_bookmarks_folders.spec.mjs', 'test_reader_bm_panel.spec.mjs', 'test_reader_captions.spec.mjs', 'test_manage_boot_race.spec.mjs', 'test_offline_state.spec.mjs', 'test_export_download.spec.mjs', 'test_create_flow.spec.mjs', 'test_create_folder.spec.mjs', 'test_activity_view.spec.mjs', 'test_zim_about.spec.mjs', 'test_manage_ux.spec.mjs', 'test_almanac_earth_settings.spec.mjs', 'test_almanac_ux.spec.mjs', 'test_almanac_secbar.spec.mjs', 'test_almanac_clocks.spec.mjs'], + testMatch: ['visual_validation.spec.mjs', 'test_password_flow.mjs', 'test_interlang.mjs', 'screenshots.mjs', 'test_tabs.mjs', 'test_almanac_hero_clock.spec.mjs', 'test_login_navigation.spec.mjs', 'test_private_mode_login.spec.mjs', 'test_admin_private_library.spec.mjs', 'test_backup_hub.spec.mjs', 'test_bookmarks_folders.spec.mjs', 'test_reader_bm_panel.spec.mjs', 'test_reader_captions.spec.mjs', 'test_manage_boot_race.spec.mjs', 'test_offline_state.spec.mjs', 'test_export_download.spec.mjs', 'test_create_flow.spec.mjs', 'test_create_folder.spec.mjs', 'test_activity_view.spec.mjs', 'test_zim_about.spec.mjs', 'test_manage_ux.spec.mjs', 'test_almanac_earth_settings.spec.mjs', 'test_almanac_ux.spec.mjs', 'test_almanac_secbar.spec.mjs', 'test_almanac_clocks.spec.mjs', 'test_settings_sync.spec.mjs', 'test_almanac_overlays.spec.mjs'], timeout: 60000, expect: { timeout: 10000 }, fullyParallel: false, // Run sequentially — some tests depend on server state diff --git a/tests/test_almanac_overlays.spec.mjs b/tests/test_almanac_overlays.spec.mjs new file mode 100644 index 00000000..6c5bda42 --- /dev/null +++ b/tests/test_almanac_overlays.spec.mjs @@ -0,0 +1,228 @@ +// The orrery's overlays at a phone's width and a desktop's: its tip (with +// "See the Sun up close" / "Fly there"), a probe's card and the Earth view's +// settings. Each puts itself away when a press lands outside it, on Escape, +// and on a phone by a 44px x; and the tip's button is always where a finger +// can reach it (the whole tip is on the screen, nothing over it). +// +// Start a server first, on its own fresh folders (no ZIMs needed): +// ZIMI_TORRENT=0 ZIMI_OFFLINE=1 ZIM_DIR=/tmp/zimi-empty ZIMI_DATA_DIR=/tmp/zimi-empty-data \ +// python3 -m zimi serve --port 8877 +// Run (SHOTS_DIR keeps the screenshots somewhere to read them): +// BASE_URL=http://localhost:8877 SHOTS_DIR=/tmp/overlay-shots npx playwright test \ +// --config=tests/playwright.config.mjs --reporter=list tests/test_almanac_overlays.spec.mjs + +import { test, expect } from '@playwright/test'; + +const BASE = process.env.BASE_URL || 'http://localhost:8877'; +// A fresh data dir greets with "Welcome to Zimi" until a password is set; +// loopback may set it, as the other specs do. +test.beforeAll(async ({ request }) => { + await request.post(`${BASE}/manage/set-password`, { headers: { 'Content-Type': 'application/json' }, data: { password: 'overlay-spec-pw' } }); +}); +const SHOTS = process.env.SHOTS_DIR || 'test-results/overlays'; +test.use({ serviceWorkers: 'block' }); +const PHONE = { width: 390, height: 664 }; // a phone's page under Safari's bars +const DESKTOP = { width: 1440, height: 900 }; +const SAN_FRANCISCO = { lat: 37.77, lon: -122.42, name: 'San Francisco' }; +const X_TARGET_PX = 44; + +async function phone(browser) { + return browser.newContext({ viewport: PHONE, hasTouch: true, isMobile: true, deviceScaleFactor: 2, serviceWorkers: 'block' }); +} + +async function openAlmanac(page) { + await page.addInitScript((loc) => { + try { + localStorage.setItem('zimi_app_theme', 'dark'); + sessionStorage.setItem('zimi_almanac_location', JSON.stringify(loc)); + } catch (e) { /* private window: the defaults do */ } + }, SAN_FRANCISCO); + await page.goto(`${BASE}/#almanac`); + await page.waitForFunction(() => typeof window.openAlmanacEarth === 'function' && document.getElementById('almanac-orrery')); + await page.evaluate(() => _orrerySnapToNow()); +} + +// Scroll the page so the orrery's top edge stands `top` px from the window's. +async function orreryAt(page, top) { + for (let i = 0; i < 3; i++) { + await page.evaluate((t) => { + const c = document.getElementById('almanac-orrery'); + let s = c.parentElement; + while (s && !(s.scrollHeight > s.clientHeight + 5 && /auto|scroll/.test(getComputedStyle(s).overflowY))) s = s.parentElement; + (s || document.scrollingElement).scrollTop += c.getBoundingClientRect().top - t; + }, top); + await page.waitForTimeout(500); + } +} + +// A body's place on the page (CSS px). +function bodyAt(page, name) { + return page.evaluate((n) => { + const r = document.getElementById('almanac-orrery').getBoundingClientRect(); + const q = n === 'Sun' ? _orrerySunPos : _orreryPlanetPositions.find((p) => p.name === n); + const s = _orreryWorldToScreen(q.x, q.y); + return { x: r.left + s.x, y: r.top + s.y }; + }, name); +} +const shown = (page, id) => page.evaluate((i) => { const e = document.getElementById(i); return !!e && e.getClientRects().length > 0; }, id); +// Somewhere on the page that is none of the orrery's: its section's title. +async function outsidePoint(page) { + return page.evaluate(() => { + const h = document.getElementById('almanac-orrery').closest('.almanac-section').firstElementChild; + const r = h.getBoundingClientRect(); + return { x: r.left + 20, y: r.top + r.height / 2 }; + }); +} +async function tipOnSun(page, tap) { + const sun = await bodyAt(page, 'Sun'); + if (tap) await page.touchscreen.tap(sun.x, sun.y); + else { await page.mouse.move(sun.x + 40, sun.y + 40); await page.mouse.move(sun.x, sun.y, { steps: 6 }); } + await expect(page.locator('#orrery-tooltip')).toBeVisible(); +} + +test.describe('on a phone', () => { + test('the Sun\'s button is on the screen, under no one\'s finger but its own, and opens the view', async ({ browser }) => { + const ctx = await phone(browser); + const page = await ctx.newPage(); + await openAlmanac(page); + // The orrery half below the fold: the Sun low on the screen. + await orreryAt(page, 350); + await tipOnSun(page, true); + const btn = page.locator('#orrery-tooltip [data-orr-earth="sun"]'); + await expect(btn).toBeVisible(); + await page.screenshot({ path: `${SHOTS}/sun-tip-low.png` }); + const where = await page.evaluate(() => { + const b = document.querySelector('#orrery-tooltip [data-orr-earth="sun"]').getBoundingClientRect(); + const bar = document.querySelector('.topbar').getBoundingClientRect(); + const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); + return { top: b.top, bottom: b.bottom, barBottom: bar.bottom, vh: innerHeight, onTop: !!hit && hit.matches('[data-orr-earth]') }; + }); + expect(where.top).toBeGreaterThanOrEqual(where.barBottom); + expect(where.bottom).toBeLessThanOrEqual(where.vh); + expect(where.onTop).toBe(true); + await btn.tap(); + await expect(page.locator('#ae-view')).toHaveClass(/open/); + expect(await page.evaluate(() => _ae.target)).toBe('sun'); + await ctx.close(); + }); + + test('the tip closes on a press outside it, and by its x, a 44px target round a small glyph', async ({ browser }) => { + const ctx = await phone(browser); + const page = await ctx.newPage(); + await openAlmanac(page); + await orreryAt(page, 200); + await tipOnSun(page, true); + const x = page.locator('#orrery-tooltip .alm-dismiss-x'); + await expect(x).toBeVisible(); + const box = await x.boundingBox(); + expect(box.width).toBeGreaterThanOrEqual(X_TARGET_PX); + expect(box.height).toBeGreaterThanOrEqual(X_TARGET_PX); + expect(parseFloat(await x.evaluate((e) => getComputedStyle(e).fontSize))).toBeLessThanOrEqual(20); + await page.screenshot({ path: `${SHOTS}/sun-tip-x.png` }); + + await x.tap(); + expect(await shown(page, 'orrery-tooltip')).toBe(false); + + await tipOnSun(page, true); + const out = await outsidePoint(page); + await page.touchscreen.tap(out.x, out.y); + expect(await shown(page, 'orrery-tooltip')).toBe(false); + + // Closed, the body is a fresh first tap: the tip again, not the view. + await tipOnSun(page, true); + expect(await page.evaluate(() => document.getElementById('ae-view') && document.getElementById('ae-view').classList.contains('open'))).toBeFalsy(); + await ctx.close(); + }); + + test('a planet\'s tip, Fly there, closes the same way', async ({ browser }) => { + const ctx = await phone(browser); + const page = await ctx.newPage(); + await openAlmanac(page); + await orreryAt(page, 200); + const mars = await bodyAt(page, 'Mars'); + await page.touchscreen.tap(mars.x, mars.y); + await expect(page.locator('#orrery-tooltip [data-orr-fly]')).toBeVisible(); + await page.screenshot({ path: `${SHOTS}/mars-tip.png` }); + const out = await outsidePoint(page); + await page.touchscreen.tap(out.x, out.y); + expect(await shown(page, 'orrery-tooltip')).toBe(false); + await ctx.close(); + }); + + test('a probe\'s card closes by a press outside and by its x', async ({ browser }) => { + const ctx = await phone(browser); + const page = await ctx.newPage(); + await openAlmanac(page); + await orreryAt(page, 200); + await page.evaluate(() => _showVoyagerCard(0)); + await expect(page.locator('#voyager-card')).toBeVisible(); + const box = await page.locator('#voyager-card .alm-dismiss-x').boundingBox(); + expect(box.width).toBeGreaterThanOrEqual(X_TARGET_PX); + expect(box.height).toBeGreaterThanOrEqual(X_TARGET_PX); + await page.screenshot({ path: `${SHOTS}/voyager-card.png` }); + await page.locator('#voyager-card .alm-dismiss-x').tap(); + expect(await shown(page, 'voyager-card')).toBe(false); + + await page.evaluate(() => _showVoyagerCard(0)); + const out = await outsidePoint(page); + await page.touchscreen.tap(out.x, out.y); + expect(await shown(page, 'voyager-card')).toBe(false); + await ctx.close(); + }); + + test('the Earth view\'s settings close by a press outside and by their x', async ({ browser }) => { + const ctx = await phone(browser); + const page = await ctx.newPage(); + await openAlmanac(page); + await page.evaluate(() => window.openAlmanacEarth()); + await expect(page.locator('#ae-view')).toHaveClass(/open/); + await page.locator('#ae-gear').tap(); + await expect(page.locator('#ae-set')).toBeVisible(); + const box = await page.locator('#ae-set .alm-dismiss-x').boundingBox(); + expect(box.width).toBeGreaterThanOrEqual(X_TARGET_PX); + await page.screenshot({ path: `${SHOTS}/earth-settings.png` }); + await page.locator('#ae-set .alm-dismiss-x').tap(); + await expect(page.locator('#ae-set')).toBeHidden(); + + await page.locator('#ae-gear').tap(); + await expect(page.locator('#ae-set')).toBeVisible(); + await page.touchscreen.tap(60, 400); + await expect(page.locator('#ae-set')).toBeHidden(); + await ctx.close(); + }); +}); + +test.describe('on a desktop', () => { + test('Escape and a click outside put the tip and the probe\'s card away, and no x shows', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openAlmanac(page); + await orreryAt(page, 300); + await tipOnSun(page, false); + expect(await page.locator('#orrery-tooltip .alm-dismiss-x').isVisible()).toBe(false); + await page.keyboard.press('Escape'); + expect(await shown(page, 'orrery-tooltip')).toBe(false); + + await tipOnSun(page, false); + const out = await outsidePoint(page); + await page.mouse.click(out.x, out.y); + expect(await shown(page, 'orrery-tooltip')).toBe(false); + + await page.evaluate(() => _showVoyagerCard(0)); + await expect(page.locator('#voyager-card')).toBeVisible(); + await page.keyboard.press('Escape'); + expect(await shown(page, 'voyager-card')).toBe(false); + }); + + test('the Sun\'s tip button opens the Sun view with the mouse', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openAlmanac(page); + await orreryAt(page, 300); + await tipOnSun(page, false); + const btn = await page.locator('#orrery-tooltip [data-orr-earth="sun"]').boundingBox(); + await page.mouse.move(btn.x + btn.width / 2, btn.y + btn.height / 2, { steps: 20 }); + await page.mouse.down(); + await page.mouse.up(); + await expect(page.locator('#ae-view')).toHaveClass(/open/); + expect(await page.evaluate(() => _ae.target)).toBe('sun'); + }); +}); diff --git a/tests/test_almanac_tables_browser.py b/tests/test_almanac_tables_browser.py index 17fb0afd..6b3cee05 100644 --- a/tests/test_almanac_tables_browser.py +++ b/tests/test_almanac_tables_browser.py @@ -918,8 +918,9 @@ def test_one_tile_prints_and_copies_as_its_own_book(page): assert not page.errors, page.errors +# The reader open on a PDF: a closed reader keeps the last one in its frame. PDF_SHOWN = ( - "() => { const f = document.getElementById('reader-frame'); try {" + "() => { const f = document.getElementById('reader-frame'); if (!readerOpen) return false; try {" " const a = f.contentWindow.PDFViewerApplication; return !!(a && a.pagesCount > 0); } catch (e) { return false; } }" ) @@ -1110,6 +1111,14 @@ def words(s): assert not page.errors, page.errors +def _reader_closed(pg): + """Back to the Almanac, whatever a test left open, so one failure stays + its own and does not fail the tests after it.""" + if pg.evaluate("readerOpen"): + pg.evaluate("goBack()") + pg.wait_for_function("() => !readerOpen", timeout=10000) + + def test_a_busy_server_is_asked_once_more_and_a_slow_pdf_says_so(page): """The server is drawing someone else's PDF (a 503 naming its wait): the icons keep spinning, the page asks once more after the wait, and the @@ -1121,9 +1130,11 @@ def test_a_busy_server_is_asked_once_more_and_a_slow_pdf_says_so(page): def busy_once(route): seen.append(route.request.method) if len(seen) == 1: + # A wait well past the checks below: at 1 s the retry came 0.14 s + # after them, and a slower page (after the whole book) saw it. route.fulfill( status=503, - headers={"Retry-After": "1", "Content-Type": "application/json"}, + headers={"Retry-After": "3", "Content-Type": "application/json"}, body='{"error": "busy"}', ) else: @@ -1143,11 +1154,10 @@ def busy_once(route): assert len(seen) == 2 assert not page.evaluate(toast) assert page.evaluate("document.querySelectorAll('.alm-busy').length") == 0 - page.evaluate("goBack()") - page.wait_for_function("() => !readerOpen", timeout=10000) finally: page.unroute("**/almanac/pdf") page.evaluate("() => { TB_PDF_SLOW_MS = window.__slow; }") + _reader_closed(page) assert not page.errors, page.errors diff --git a/tests/test_apps_counts.cjs b/tests/test_apps_counts.cjs index 2c0fe044..46d6dca9 100644 --- a/tests/test_apps_counts.cjs +++ b/tests/test_apps_counts.cjs @@ -35,6 +35,7 @@ const ctx = { }, }; vm.createContext(ctx); +vm.runInContext(require("./app_source.cjs").localeHelpers(src), ctx); vm.runInContext([ extract(/function _mapSourceLabel\(z\) \{[\s\S]*?\n\}/, '_mapSourceLabel'), extract(/function _appItemsCount\(app, z\) \{[\s\S]*?\n\}/, '_appItemsCount'), diff --git a/tests/test_apps_dedupe.cjs b/tests/test_apps_dedupe.cjs index 6c4e5923..fa2365ba 100644 --- a/tests/test_apps_dedupe.cjs +++ b/tests/test_apps_dedupe.cjs @@ -35,6 +35,7 @@ const ctx = { esc: escf, t: k => k, _getLibraryView: () => 'list', _spaNav: () => false, }; vm.createContext(ctx); +vm.runInContext(require("./app_source.cjs").localeHelpers(src), ctx); vm.runInContext([ extract(/function _newestPer\(list, key\) \{[\s\S]*?\n\}/, '_newestPer'), extract(/function _installedOfKind\(kind, key\) \{[\s\S]*?\n\}/, '_installedOfKind'), diff --git a/tests/test_apps_order.cjs b/tests/test_apps_order.cjs index 8ef42a04..bdd29b04 100644 --- a/tests/test_apps_order.cjs +++ b/tests/test_apps_order.cjs @@ -53,6 +53,7 @@ const ctx = { _zimInfo: name => ctx.zimsCache.find(z => z.name === name) || null, }; vm.createContext(ctx); +vm.runInContext(require("./app_source.cjs").localeHelpers(src), ctx); vm.runInContext([ 'var _MAPS_SVG = "", _TUBE_PLAY_SVG = "", _EXCHANGE_SVG = "", _REDDOT_SVG = "", _WIKI_SVG = "", _BOOKS_SVG = "";', extract(/function _newestPer\(list, key\) \{[\s\S]*?\n\}/, '_newestPer'), diff --git a/tests/test_book_reader.py b/tests/test_book_reader.py index 8f33eaf2..73c0a343 100644 --- a/tests/test_book_reader.py +++ b/tests/test_book_reader.py @@ -217,6 +217,15 @@ def test_a_book_reads_like_an_e_reader_on_a_phone(served): # A tap in the middle brings the bars, again sends them away. _tap(pg, 195) assert not st()["away"] + # The chapter named in the footer opens the contents too, at that chapter. + frame.locator(".zb-foot .zb-info").click() + pg.wait_for_timeout(300) + on = frame.locator('.zb-toc-sheet [aria-current="true"] button') + assert on.count() == 1 and on.get_attribute("data-k") == "2" + frame.locator(".zb-toc-sheet .zb-x").click() + pg.wait_for_timeout(300) + if st()["away"]: + _tap(pg, 195) _tap(pg, 195) assert st()["away"] # Turning: the right edge on, a swipe left on, a swipe right back, the left edge back. diff --git a/tests/test_bookmark_export_compose.cjs b/tests/test_bookmark_export_compose.cjs index 52f91f39..85b3d386 100644 --- a/tests/test_bookmark_export_compose.cjs +++ b/tests/test_bookmark_export_compose.cjs @@ -66,6 +66,7 @@ function mkComposeSandbox() { t: (k) => ({ saved_liked: 'Liked' }[k] || k), }; vm.createContext(sandbox); + vm.runInContext(require("./app_source.cjs").localeHelpers(src), sandbox); vm.runInContext( extract(/function _savedListName\(l\)\s*\{[^\n]*\}/, '_savedListName') + '\n' + extract(/function _bmSanitizeZimName\(s\)\s*\{[\s\S]*?\n\}/, '_bmSanitizeZimName') + @@ -126,6 +127,7 @@ function mkComposeSandbox() { _currentLang: 'en', }; vm.createContext(sandbox); + vm.runInContext(require("./app_source.cjs").localeHelpers(src), sandbox); vm.runInContext( extract(/function t\(key, vars\)\s*\{[\s\S]*?\n\}/, 't') + extract(/function tPlural\(base, n, vars\)\s*\{[\s\S]*?\n\}/, 'tPlural'), diff --git a/tests/test_ci_contract.py b/tests/test_ci_contract.py index 9d2af1e4..a6f22db4 100644 --- a/tests/test_ci_contract.py +++ b/tests/test_ci_contract.py @@ -427,3 +427,11 @@ def test_the_changelog_has_a_section_for_this_version(): assert any(line.startswith(head) for line in lines), ( "CHANGELOG.md has no %s section; rename [Unreleased] when bumping the version" % head ) + + +def test_a_failed_attestation_stops_the_pypi_upload(): + """The release promises build provenance, so a file is never published + without it: neither attestation step may carry ``continue-on-error``.""" + body = _commands("publish-release.yml") + assert body.count("actions/attest-build-provenance") == 2 + assert "continue-on-error" not in body diff --git a/tests/test_cr_fixes_static.cjs b/tests/test_cr_fixes_static.cjs new file mode 100644 index 00000000..cc7e948f --- /dev/null +++ b/tests/test_cr_fixes_static.cjs @@ -0,0 +1,37 @@ +// Review fixes pinned against the shipped files (Escape focus, RTL speed slider). +// Run: node tests/test_cr_fixes_static.cjs (exit 0 = pass) +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const STATIC = path.join(__dirname, '..', 'zimi', 'static'); +const read = (f) => fs.readFileSync(path.join(STATIC, f), 'utf8'); +let failures = 0; +const check = (ok, label) => { if (!ok) { console.error('FAIL: ' + label); failures++; } }; + +// The speed slider sits inside the always-LTR bar: no RTL overrides on its fill or marks. +const css = read('app.css'); +check(/\.tts-bar \{ direction: ltr/.test(css), 'the bar is direction: ltr'); +check(!/\[dir="rtl"\][^{]*tts-speed/.test(css), 'no [dir=rtl] override on the speed slider or its marks'); + +// Escape from inside Earth's settings returns focus to the gear. +const src = read('almanac.js'); +const body = src.slice(src.indexOf('var _almDismissables'), src.indexOf('function _almDismissX')); +const listeners = {}; +let focused = null; +const gear = { focus() { focused = 'gear'; } }; +const radio = {}; +const panel = { getClientRects: () => [1], contains: (n) => n === radio }; +const doc = { + activeElement: radio, + addEventListener: (t, f) => { listeners[t] = f; }, + getElementById: (id) => (id === 'ae-set' ? panel : id === 'ae-gear' ? gear : null), +}; +let hidden = 0; +const ctx = { document: doc, console, __hide: () => { hidden++; } }; +vm.createContext(ctx); +vm.runInContext(body, ctx); +vm.runInContext("_almDismissable('ae-set', function () { __hide(); }, null, 'ae-gear');", ctx); +listeners.keydown({ key: 'Escape', preventDefault() {}, stopPropagation() {} }); +check(hidden === 1, 'Escape hides the settings panel'); +check(focused === 'gear', 'Escape from a settings control focuses #ae-gear'); +process.exit(failures ? 1 : 0); diff --git a/tests/test_create_numbers_and_rows.cjs b/tests/test_create_numbers_and_rows.cjs index 5ce44089..e58ddf7e 100644 --- a/tests/test_create_numbers_and_rows.cjs +++ b/tests/test_create_numbers_and_rows.cjs @@ -45,6 +45,7 @@ const sandbox = { localStorage: { getItem: () => null, setItem() {}, removeItem( return s; } }; vm.createContext(sandbox); +vm.runInContext(require("./app_source.cjs").localeHelpers(APP), sandbox); vm.runInContext(STOP_BLOCK[0] + '\n', sandbox); vm.runInContext(SRC.slice(0, cut), sandbox); const { _createDoneBytes, _createMetricLive, _createRowGone, _createChipTarget, _createEngineFor } = sandbox; diff --git a/tests/test_creator_made_chips.cjs b/tests/test_creator_made_chips.cjs index bdbc698a..3e068d96 100644 --- a/tests/test_creator_made_chips.cjs +++ b/tests/test_creator_made_chips.cjs @@ -30,6 +30,7 @@ function context(lang) { return s; }; vm.createContext(ctx); + vm.runInContext(require("./app_source.cjs").localeHelpers(src), ctx); vm.runInContext(extract(/function tPlural\(base, n, vars\) \{[\s\S]*?\n\}/, 'tPlural'), ctx); vm.runInContext(extract(/function tPluralH\(base, n, vars\) \{[\s\S]*?\n\}/, 'tPluralH'), ctx); vm.runInContext(extract(/var _CREATOR_TYPE_KEYS = \{[\s\S]*?\n\};/, 'the types'), ctx); diff --git a/tests/test_dictionary_live.py b/tests/test_dictionary_live.py index 18116057..aa0a7f93 100644 --- a/tests/test_dictionary_live.py +++ b/tests/test_dictionary_live.py @@ -910,7 +910,7 @@ def test_the_page_asks_the_server_to_load_its_voices(piper_here, monkeypatch): tv.install("fr") monkeypatch.setattr(voices, "_espeak_voices", lambda: {"en": {"US": "en-us"}}) asked = [] - monkeypatch.setattr(voices, "warm", lambda langs: asked.append(list(langs)) or []) + monkeypatch.setattr(voices, "warm", lambda langs, engine=None: asked.append(list(langs)) or []) f = open_eau() f.wait_for_timeout(500) langs = [lang for call in asked for lang in call] diff --git a/tests/test_discover_season_hemisphere.cjs b/tests/test_discover_season_hemisphere.cjs index 5bc6e5e0..8d67f4cf 100644 --- a/tests/test_discover_season_hemisphere.cjs +++ b/tests/test_discover_season_hemisphere.cjs @@ -11,7 +11,7 @@ function ctx(tz, place) { const S = { Math, Date, JSON, Object, console, String, Number, Intl: { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: tz }) }) }, - _almChosenPlace: () => place, t: (k) => k, + _almChosenPlace: () => place, t: (k) => k, fmtPct: (n) => Math.round(n) + '%', document: { head: { appendChild: () => {} }, createElement: () => ({}) }, _renderDiscover() {}, }; diff --git a/tests/test_fieldguide_sky.cjs b/tests/test_fieldguide_sky.cjs index 128e5934..80f35a4c 100644 --- a/tests/test_fieldguide_sky.cjs +++ b/tests/test_fieldguide_sky.cjs @@ -32,6 +32,7 @@ run('function _skyClock(ms) { return "CLK:" + Math.round(ms); }'); run('function _skyBearing(az) { return "AZ"; }'); run('function _skyDeg(x) { return Math.round(x) + "d"; }'); run('function _orrNum(n) { return String(Math.round(n)); }'); +run('function fmtPct(n) { return Math.round(n) + "%"; }'); run(pull(/var FG_SKY_ROUND_MS = [^;]+;/, 'round')); run(fn('_fgRound')); run(fn('_fgSkyRows')); diff --git a/tests/test_home_discover_live.py b/tests/test_home_discover_live.py index d4886944..32edc129 100644 --- a/tests/test_home_discover_live.py +++ b/tests/test_home_discover_live.py @@ -192,6 +192,12 @@ def speak(route): say = pg.locator(".dc-quote-card .dc-say") assert say.count() == 1 say.scroll_into_view_if_needed() + # A finger's target: a tap just outside the 32px circle still lands on it. + assert pg.evaluate( + """() => { const b = document.querySelector('.dc-quote-card .dc-say').getBoundingClientRect(); + const at = document.elementFromPoint(b.left - 6, b.top + b.height / 2); + return !!(at && at.closest('.dc-say')); }""" + ), "the speaker's target reaches past its circle" if os.environ.get("ZIMI_SHOTS"): pg.locator(".dc-quote-card").screenshot( path=os.path.join( diff --git a/tests/test_keep_pdf_live.py b/tests/test_keep_pdf_live.py index d8975ae6..4b5725f7 100644 --- a/tests/test_keep_pdf_live.py +++ b/tests/test_keep_pdf_live.py @@ -64,6 +64,8 @@ def test_a_kept_document_library_reads_offline(pw, world): "async () => { for (const u of _KEEP_OFFLINE_FILES) if (!(await caches.match(u, {ignoreSearch: true}))) return false; return true; }", timeout_s=30, ) + # And the library's list as the server draws it, though it was never opened. + poll(pg, "async (z) => !!(await caches.match('/catalog?zim=' + encodeURIComponent(z)))", timeout_s=30, arg=NAME) world.stop() pg.goto(world.base + "/", wait_until="load") pg.wait_for_function("() => typeof enterSource === 'function'") @@ -71,6 +73,10 @@ def test_a_kept_document_library_reads_offline(pw, world): pg.wait_for_selector("a.zg-doc", timeout=15000) titles = pg.eval_on_selector_all("a.zg-doc .title", "els => els.map(e => e.innerText)") assert titles[0] == "Distillation For Home Water Treatment" and len(titles) == 7, titles + # The same list the app shows online, with the sizes the server knows + # (the one read off the phone's copy has none). + metas = pg.eval_on_selector_all("a.zg-doc", "els => els.map(e => e.innerText)") + assert any(" KB" in m or " MB" in m for m in metas), metas pg.click("a.zg-doc[data-path='%s']" % DOC) pg.wait_for_function( """() => { const f = document.getElementById('reader-frame'); diff --git a/tests/test_library_sort.cjs b/tests/test_library_sort.cjs index 396c364b..0dbef43f 100644 --- a/tests/test_library_sort.cjs +++ b/tests/test_library_sort.cjs @@ -35,12 +35,14 @@ const sandbox = { setItem: (k, v) => { store[k] = String(v); }, }, SK: { LIBRARY_SORT: 'zimi_library_sort' }, + _prefWrite: (k, v) => { if (v == null) delete store[k]; else store[k] = String(v); }, // Changing the order moves the cards in place; when it cannot, the caller // rebuilds. Both are recorded so the fallback is a fact rather than a hope. renderHome: () => { sandboxCalls.rebuilt++; }, _reorderLibraryInPlace: () => { sandboxCalls.reordered++; return sandboxCalls.canReorder; }, }; vm.createContext(sandbox); +vm.runInContext(require("./app_source.cjs").localeHelpers(src), sandbox); for (const fn of ['_zimChangedAt', '_byFirstSeenDesc', '_byUpdatedDesc']) vm.runInContext(grab(fn), sandbox); vm.runInContext(grab('LIBRARY_SORTS', 'var'), sandbox); for (const fn of ['_librarySort', '_setLibrarySort', '_sortLibrary']) { @@ -184,6 +186,7 @@ check(sandboxCalls.reordered === 0 && sandboxCalls.rebuilt === 0, const ctx = { esc: (s) => s, escAttr: (s) => s, t: (k) => ({ sort_alpha: 'Alphabetical', sort_added: 'Recently added', library_sort: 'Order' })[k] || k, LIBRARY_SORTS: ['alpha', 'added'], _librarySort: () => 'added' }; vm.createContext(ctx); + vm.runInContext(require("./app_source.cjs").localeHelpers(src), ctx); vm.runInContext(grab('_LIBRARY_SORT_LABELS', 'var') + '\n' + src.match(/var _LIB_SORT_SVG = '[^\n]*';/)[0] + '\n' + grab('_libSortHtml'), ctx); const html = ctx._libSortHtml(); check(/^