Add KOReader option
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
All checks were successful
Build and test project / build-and-test (push) Successful in 1m32s
This commit is contained in:
21
.github/workflows/build.yml
vendored
21
.github/workflows/build.yml
vendored
@@ -56,22 +56,31 @@ jobs:
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/)'; then
|
||||
elif git diff --name-only HEAD~1 HEAD | grep -qE '^(kobopatch-wasm/|web/|tests/|nickelmenu/|koreader/)'; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Full integration test (WASM)
|
||||
- name: Download test firmware
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: |
|
||||
cd kobopatch-wasm
|
||||
./test-integration.sh
|
||||
mkdir -p tests/cached_assets
|
||||
echo "Downloading firmware..."
|
||||
curl -fL --progress-bar -o tests/cached_assets/kobo-update-4.45.23646.zip \
|
||||
https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip
|
||||
|
||||
- name: Full integration test (WASM)
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: kobopatch-wasm/test-integration.sh
|
||||
|
||||
- name: Set up NickelMenu assets
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: |
|
||||
nickelmenu/setup.sh
|
||||
run: nickelmenu/setup.sh
|
||||
|
||||
- name: Set up KOReader assets
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
run: koreader/setup.sh
|
||||
|
||||
- name: Full integration test (Playwright)
|
||||
if: steps.check-e2e.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,7 +8,6 @@ kobopatch/
|
||||
# Kobopatch WASM build artifacts
|
||||
kobopatch-wasm/go/
|
||||
kobopatch-wasm/kobopatch-src/
|
||||
kobopatch-wasm/testdata/
|
||||
kobopatch-wasm/kobopatch.wasm
|
||||
kobopatch-wasm/wasm_exec.js
|
||||
|
||||
@@ -16,6 +15,7 @@ kobopatch-wasm/wasm_exec.js
|
||||
web/src/js/wasm_exec.js
|
||||
web/src/nickelmenu/NickelMenu.zip
|
||||
web/src/nickelmenu/kobo-config.zip
|
||||
web/src/koreader/
|
||||
|
||||
# Build output
|
||||
web/dist/
|
||||
@@ -23,6 +23,9 @@ web/dist/
|
||||
# Node
|
||||
web/node_modules/
|
||||
|
||||
# Cached test assets (firmware, KOReader zips)
|
||||
tests/cached_assets/
|
||||
|
||||
# E2E tests
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
|
||||
48
README.md
48
README.md
@@ -5,8 +5,9 @@
|
||||
|
||||
A web application for customising Kobo e-readers. It supports two modes:
|
||||
|
||||
- **NickelMenu** — installs [NickelMenu](https://pgaskin.net/NickelMenu/) [fork](https://github.com/nicoverbruggen/NickelMenu) with an optional [curated configuration](https://github.com/nicoverbruggen/kobo-config) (custom menus, fonts, screensavers, UI tweaks). Works with most Kobo devices regardless of software version. Can also remove NickelMenu from a connected device.
|
||||
- **NickelMenu** — installs [NickelMenu](https://pgaskin.net/NickelMenu/) [fork](https://github.com/nicoverbruggen/NickelMenu) with an optional [curated configuration](https://github.com/nicoverbruggen/kobo-config) (custom menus, fonts, screensavers, UI tweaks). Works with most Kobo devices regardless of software version. Can also remove NickelMenu from a connected device.
|
||||
- <u>The safest patch to install</u>. These modifications tend to persist with system updates as long as NickelMenu remains functional.
|
||||
- You can optionally install KOReader using this method, too.
|
||||
- Will automatically uninstall itself if Kobo releases an incompatible update in the future, which may happen with software v5.x at some point.
|
||||
|
||||
- **Custom patches** — applies community [kobopatch](https://github.com/pgaskin/kobopatch) patches to your Kobo's system software. Requires a supported software version and device model, which is currently limited to Kobo Libra Color, Kobo Clara Color and Kobo Clara BW models.
|
||||
@@ -27,7 +28,7 @@ If you choose to apply custom patches, **patching happens fully client-side**
|
||||
|
||||
1. **Connect or download** — auto-detect your Kobo via File System Access API on Chromium, or choose manual download mode (any browser)
|
||||
2. **Choose mode** — NickelMenu (install/configure/remove) or custom patches
|
||||
3. **Configure** — for NickelMenu: select install options (fonts, screensaver, tab/homescreen tweaks) or removal; for patches: enable/disable patches (or select none to restore original software)
|
||||
3. **Configure** — for NickelMenu: select install options (fonts, screensaver, tab/homescreen tweaks, KOReader) or removal; for patches: enable/disable patches (or select none to restore original software)
|
||||
4. **Review** — confirm your selections before proceeding
|
||||
5. **Install** — write directly to the device (Chromium auto mode) or download a ZIP/tgz for manual installation
|
||||
|
||||
@@ -56,17 +57,24 @@ web/
|
||||
nickelmenu/ # NickelMenu assets (generated by nickelmenu/setup.sh, gitignored)
|
||||
NickelMenu.zip
|
||||
kobo-config.zip
|
||||
koreader/ # KOReader assets (generated by koreader/setup.sh, gitignored)
|
||||
koreader-kobo.zip
|
||||
release.json
|
||||
favicon/
|
||||
dist/ # Build output (gitignored, fully regenerable)
|
||||
bundle.js # esbuild output (minified, content-hashed)
|
||||
index.html # Generated with cache-busted references
|
||||
css/ favicon/ patches/ nickelmenu/ wasm/ js/wasm_exec.js
|
||||
css/ favicon/ patches/ nickelmenu/ koreader/ wasm/ js/wasm_exec.js
|
||||
build.mjs # esbuild build script + asset copy
|
||||
package.json # esbuild, jszip
|
||||
|
||||
nickelmenu/
|
||||
setup.sh # Downloads NickelMenu.zip and bundles kobo-config.zip
|
||||
|
||||
koreader/
|
||||
setup.sh # Downloads latest KOReader release for Kobo
|
||||
update.sh # Updates KOReader in web/dist/ (for production containers)
|
||||
|
||||
kobopatch-wasm/
|
||||
main.go # Go entry point
|
||||
go.mod go.sum
|
||||
@@ -76,8 +84,14 @@ kobopatch-wasm/
|
||||
test-integration.sh
|
||||
|
||||
tests/
|
||||
cached_assets/ # Downloaded test assets (gitignored)
|
||||
e2e/
|
||||
integration.spec.js # Playwright E2E tests
|
||||
helpers/ # Shared test utilities
|
||||
assets.js # Asset availability checks, firmware symlink helpers
|
||||
mock-device.js # Mock File System Access API (simulated Kobo device)
|
||||
paths.js # Test asset paths, expected checksums
|
||||
tar.js # Tar archive parser for output verification
|
||||
integration.spec.js # Playwright E2E tests
|
||||
playwright.config.js
|
||||
run-e2e.sh
|
||||
|
||||
@@ -94,11 +108,11 @@ serve-locally.sh # Serves app at localhost:8888
|
||||
|
||||
## Building the WASM binary
|
||||
|
||||
Requires Go 1.21+.
|
||||
Requires Go 1.21+ (if Go is not installed, `setup.sh` will download it locally to `kobopatch-wasm/go/`).
|
||||
|
||||
```bash
|
||||
cd kobopatch-wasm
|
||||
./setup.sh # first time only
|
||||
./setup.sh # first time only — clones kobopatch source, sets up Go if needed
|
||||
./build.sh # compiles WASM, copies to web/dist/wasm/
|
||||
```
|
||||
|
||||
@@ -110,6 +124,22 @@ nickelmenu/setup.sh
|
||||
|
||||
This downloads `NickelMenu.zip` and clones/updates the [kobo-config](https://github.com/nicoverbruggen/kobo-config) repo to bundle `kobo-config.zip` into `web/src/nickelmenu/`.
|
||||
|
||||
## Setting up KOReader assets
|
||||
|
||||
```bash
|
||||
koreader/setup.sh
|
||||
```
|
||||
|
||||
This downloads the latest [KOReader](https://koreader.rocks) release for Kobo into `web/src/koreader/`. The KOReader zip is served from the app's own domain (to avoid CORS issues with GitHub release downloads). The version is displayed in the UI next to the KOReader checkbox. If the assets are missing, the KOReader option is hidden.
|
||||
|
||||
To update KOReader on a running production container without a full rebuild:
|
||||
|
||||
```bash
|
||||
koreader/update.sh
|
||||
```
|
||||
|
||||
This downloads the latest release directly into `web/dist/koreader/`, skipping the build step. It's a no-op if the current version is already up to date.
|
||||
|
||||
## Building the frontend
|
||||
|
||||
The JS source lives in `web/src/js/` as ES modules. esbuild bundles them into a single `web/dist/bundle.js`.
|
||||
@@ -143,13 +173,13 @@ Run all tests (WASM integration + E2E):
|
||||
./test.sh
|
||||
```
|
||||
|
||||
This builds the web app, compiles the WASM binary, runs the WASM integration tests, and then runs the full E2E suite.
|
||||
This builds the web app, compiles the WASM binary, runs the WASM integration tests, and then runs the full E2E suite. On first run it will prompt to download test assets (~190 MB total) to `tests/cached_assets/`. Tests that require missing assets are skipped.
|
||||
|
||||
### E2E tests (Playwright)
|
||||
|
||||
The E2E tests cover all major user flows:
|
||||
|
||||
- **NickelMenu** — install with config (manual download), install NickelMenu only, remove option disabled without device
|
||||
- **NickelMenu** — install with config (manual download), install NickelMenu only, KOReader installation, remove option disabled without device
|
||||
- **Custom patches** — full patching pipeline, restore original firmware, build failure with "Go Back" recovery
|
||||
- **Device detection** — firmware version validation (4.x supported, 5.x incompatible), unknown model warning
|
||||
- **Back navigation** — verifies every back button returns to the correct previous screen in both auto and manual mode
|
||||
@@ -157,7 +187,7 @@ The E2E tests cover all major user flows:
|
||||
|
||||
The simulated device tests mock the File System Access API with an in-memory filesystem that mimics a Kobo Libra Color (serial prefix N428, firmware 4.45.23646).
|
||||
|
||||
Custom patches tests download firmware 4.45.23646 (~150MB, cached in `kobopatch-wasm/testdata/`), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. This specific combination is used because the author has tested it on an actual device.
|
||||
Custom patches tests use firmware 4.45.23646 (~150 MB, cached in `tests/cached_assets/`), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. This specific combination is used because the author has tested it on an actual device. KOReader tests use a real KOReader zip (~39 MB, also cached) to verify the full installation flow.
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Integration test: downloads firmware and runs the full patching pipeline
|
||||
# with SHA1 checksum validation.
|
||||
#
|
||||
# Usage: ./test-integration.sh
|
||||
#
|
||||
# The firmware zip (~150MB) is cached in testdata/ to avoid re-downloading.
|
||||
|
||||
FIRMWARE_VERSION="4.45.23646"
|
||||
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
|
||||
FIRMWARE_DIR="testdata"
|
||||
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
|
||||
# Integration test: runs the full WASM patching pipeline with SHA1 checksum
|
||||
# validation against a real firmware zip.
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -22,21 +13,11 @@ if [ -x "$LOCAL_GO_DIR/bin/go" ]; then
|
||||
export PATH="$LOCAL_GO_DIR/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Download firmware if not cached.
|
||||
FIRMWARE_FILE="${FIRMWARE_ZIP:-$(cd .. && pwd)/tests/cached_assets/kobo-update-4.45.23646.zip}"
|
||||
if [ ! -f "$FIRMWARE_FILE" ]; then
|
||||
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..."
|
||||
mkdir -p "$FIRMWARE_DIR"
|
||||
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
|
||||
# Validate it's actually a zip file.
|
||||
if ! file "$FIRMWARE_FILE.tmp" | grep -q "Zip archive"; then
|
||||
echo "ERROR: downloaded file is not a valid zip"
|
||||
rm -f "$FIRMWARE_FILE.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
|
||||
echo "Downloaded to $FIRMWARE_FILE"
|
||||
else
|
||||
echo "Using cached firmware: $FIRMWARE_FILE"
|
||||
echo "ERROR: Firmware zip not found at $FIRMWARE_FILE"
|
||||
echo "Run ./test.sh from the project root to download test assets."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the WASM test executor.
|
||||
|
||||
22
koreader/setup.sh
Executable file
22
koreader/setup.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PUBLIC_DIR="$SCRIPT_DIR/../web/src/koreader"
|
||||
|
||||
mkdir -p "$PUBLIC_DIR"
|
||||
|
||||
echo "Fetching latest KOReader release info..."
|
||||
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/koreader/koreader/releases/latest)
|
||||
VERSION=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print(next(a['browser_download_url'] for a in assets if 'koreader-kobo-' in a['name'] and a['name'].endswith('.zip')))")
|
||||
|
||||
echo "Downloading KOReader $VERSION..."
|
||||
curl -fL --progress-bar -o "$PUBLIC_DIR/koreader-kobo.zip" "$DOWNLOAD_URL"
|
||||
echo " -> $(du -h "$PUBLIC_DIR/koreader-kobo.zip" | cut -f1)"
|
||||
|
||||
# Write release metadata so the app knows the version.
|
||||
echo "{\"version\":\"$VERSION\"}" > "$PUBLIC_DIR/release.json"
|
||||
|
||||
echo ""
|
||||
echo "Done. Assets written to: $PUBLIC_DIR"
|
||||
37
koreader/update.sh
Executable file
37
koreader/update.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Updates KOReader assets in the served dist directory.
|
||||
# Run this on the production container to update KOReader
|
||||
# without a full rebuild.
|
||||
#
|
||||
# Usage: ./koreader/update.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/../web/dist/koreader"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "Fetching latest KOReader release info..."
|
||||
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/koreader/koreader/releases/latest)
|
||||
VERSION=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print(next(a['browser_download_url'] for a in assets if 'koreader-kobo-' in a['name'] and a['name'].endswith('.zip')))")
|
||||
|
||||
# Check if we already have this version.
|
||||
if [ -f "$DIST_DIR/release.json" ]; then
|
||||
CURRENT=$(python3 -c "import sys,json; print(json.load(open('$DIST_DIR/release.json'))['version'])")
|
||||
if [ "$CURRENT" = "$VERSION" ]; then
|
||||
echo "Already up to date ($VERSION)."
|
||||
exit 0
|
||||
fi
|
||||
echo "Updating from $CURRENT to $VERSION..."
|
||||
else
|
||||
echo "Installing KOReader $VERSION..."
|
||||
fi
|
||||
|
||||
curl -fL --progress-bar -o "$DIST_DIR/koreader-kobo.zip.tmp" "$DOWNLOAD_URL"
|
||||
mv "$DIST_DIR/koreader-kobo.zip.tmp" "$DIST_DIR/koreader-kobo.zip"
|
||||
echo "{\"version\":\"$VERSION\"}" > "$DIST_DIR/release.json"
|
||||
|
||||
echo " -> $(du -h "$DIST_DIR/koreader-kobo.zip" | cut -f1)"
|
||||
echo "Done. KOReader $VERSION is now being served."
|
||||
@@ -12,6 +12,11 @@ if [ ! -f "$SRC_DIR/nickelmenu/NickelMenu.zip" ]; then
|
||||
"$SCRIPT_DIR/nickelmenu/setup.sh"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
|
||||
echo "KOReader assets not found, downloading..."
|
||||
"$SCRIPT_DIR/koreader/setup.sh"
|
||||
fi
|
||||
|
||||
echo "Building JS bundle..."
|
||||
cd "$WEB_DIR"
|
||||
npm install --silent
|
||||
|
||||
30
test.sh
30
test.sh
@@ -2,6 +2,29 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CACHED_ASSETS="$SCRIPT_DIR/tests/cached_assets"
|
||||
|
||||
FIRMWARE_FILE="$CACHED_ASSETS/kobo-update-4.45.23646.zip"
|
||||
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-4.45.23646.zip"
|
||||
|
||||
# Check if firmware needs to be downloaded.
|
||||
if [ ! -f "$FIRMWARE_FILE" ]; then
|
||||
echo "Firmware test asset is not cached locally (~150 MB)."
|
||||
echo ""
|
||||
read -rp "Download it now? Tests that need the firmware will be skipped otherwise. [y/N] " answer
|
||||
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$CACHED_ASSETS"
|
||||
echo "Downloading firmware..."
|
||||
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
|
||||
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up KOReader assets if not present (served by the app, not a test-only asset).
|
||||
if [ ! -f "$SCRIPT_DIR/web/src/koreader/koreader-kobo.zip" ]; then
|
||||
"$SCRIPT_DIR/koreader/setup.sh"
|
||||
fi
|
||||
|
||||
echo "=== Installing web dependencies ==="
|
||||
cd "$SCRIPT_DIR/web" && npm install
|
||||
@@ -16,7 +39,11 @@ echo "=== Building WASM ==="
|
||||
|
||||
echo ""
|
||||
echo "=== Running WASM integration test ==="
|
||||
"$SCRIPT_DIR/kobopatch-wasm/test-integration.sh"
|
||||
if [ -f "$FIRMWARE_FILE" ]; then
|
||||
"$SCRIPT_DIR/kobopatch-wasm/test-integration.sh"
|
||||
else
|
||||
echo "Skipped (firmware not downloaded)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Running E2E tests (Playwright) ==="
|
||||
@@ -25,4 +52,5 @@ if [ ! -d "node_modules" ]; then
|
||||
npm install
|
||||
npx playwright install --with-deps
|
||||
fi
|
||||
|
||||
npm test
|
||||
|
||||
34
tests/e2e/helpers/assets.js
Normal file
34
tests/e2e/helpers/assets.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { WEBROOT, WEBROOT_FIRMWARE, FIRMWARE_PATH } = require('./paths');
|
||||
|
||||
function hasNickelMenuAssets() {
|
||||
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
|
||||
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
|
||||
}
|
||||
|
||||
function hasKoreaderAssets() {
|
||||
return fs.existsSync(path.join(WEBROOT, 'koreader', 'koreader-kobo.zip'))
|
||||
&& fs.existsSync(path.join(WEBROOT, 'koreader', 'release.json'));
|
||||
}
|
||||
|
||||
function hasFirmwareZip() {
|
||||
return fs.existsSync(FIRMWARE_PATH);
|
||||
}
|
||||
|
||||
function setupFirmwareSymlink() {
|
||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
||||
}
|
||||
|
||||
function cleanupFirmwareSymlink() {
|
||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasNickelMenuAssets,
|
||||
hasKoreaderAssets,
|
||||
hasFirmwareZip,
|
||||
setupFirmwareSymlink,
|
||||
cleanupFirmwareSymlink,
|
||||
};
|
||||
187
tests/e2e/helpers/mock-device.js
Normal file
187
tests/e2e/helpers/mock-device.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Inject a mock File System Access API into the page, simulating a Kobo Libra Color.
|
||||
* The mock provides:
|
||||
* - .kobo/version file with serial N4280A0000000 and firmware 4.45.23646
|
||||
* - Optionally a .adds/nm/ directory (to simulate NickelMenu being installed)
|
||||
* - In-memory filesystem that tracks all writes for verification
|
||||
*/
|
||||
async function injectMockDevice(page, opts = {}) {
|
||||
const firmware = opts.firmware || '4.45.23646';
|
||||
const serial = opts.serial || 'N4280A0000000';
|
||||
await page.evaluate(({ hasNickelMenu, firmware, serial }) => {
|
||||
const filesystem = {
|
||||
'.kobo': {
|
||||
_type: 'dir',
|
||||
'version': {
|
||||
_type: 'file',
|
||||
content: serial + ',4.9.77,' + firmware + ',4.9.77,4.9.77,00000000-0000-0000-0000-000000000390',
|
||||
},
|
||||
'Kobo': {
|
||||
_type: 'dir',
|
||||
'Kobo eReader.conf': {
|
||||
_type: 'file',
|
||||
content: '[General]\nsome=setting\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (hasNickelMenu) {
|
||||
filesystem['.adds'] = {
|
||||
_type: 'dir',
|
||||
'nm': {
|
||||
_type: 'dir',
|
||||
'items': { _type: 'file', content: 'menu_item:main:test:skip:' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.__mockFS = filesystem;
|
||||
window.__mockWrittenFiles = {};
|
||||
|
||||
function makeFileHandle(dirNode, fileName, pathPrefix) {
|
||||
return {
|
||||
getFile: async () => {
|
||||
const fileNode = dirNode[fileName];
|
||||
const content = fileNode ? (fileNode.content || '') : '';
|
||||
return {
|
||||
text: async () => content,
|
||||
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
|
||||
};
|
||||
},
|
||||
createWritable: async () => {
|
||||
const chunks = [];
|
||||
return {
|
||||
write: async (chunk) => { chunks.push(chunk); },
|
||||
close: async () => {
|
||||
const first = chunks[0];
|
||||
const bytes = first instanceof Uint8Array ? first : new TextEncoder().encode(String(first));
|
||||
if (!dirNode[fileName]) dirNode[fileName] = { _type: 'file' };
|
||||
dirNode[fileName].content = new TextDecoder().decode(bytes);
|
||||
const fullPath = pathPrefix ? pathPrefix + '/' + fileName : fileName;
|
||||
window.__mockWrittenFiles[fullPath] = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDirHandle(node, name, pathPrefix) {
|
||||
const currentPath = pathPrefix ? pathPrefix + '/' + name : name;
|
||||
return {
|
||||
name: name,
|
||||
kind: 'directory',
|
||||
getDirectoryHandle: async (childName, opts2) => {
|
||||
if (node[childName] && node[childName]._type === 'dir') {
|
||||
return makeDirHandle(node[childName], childName, currentPath);
|
||||
}
|
||||
if (opts2 && opts2.create) {
|
||||
node[childName] = { _type: 'dir' };
|
||||
return makeDirHandle(node[childName], childName, currentPath);
|
||||
}
|
||||
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||
},
|
||||
getFileHandle: async (childName, opts2) => {
|
||||
if (node[childName] && node[childName]._type === 'file') {
|
||||
return makeFileHandle(node, childName, currentPath);
|
||||
}
|
||||
if (opts2 && opts2.create) {
|
||||
node[childName] = { _type: 'file', content: '' };
|
||||
return makeFileHandle(node, childName, currentPath);
|
||||
}
|
||||
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', '');
|
||||
window.showDirectoryPicker = async () => rootHandle;
|
||||
}, { hasNickelMenu: opts.hasNickelMenu || false, firmware, serial });
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject mock device, optionally override firmware URLs, and connect.
|
||||
*/
|
||||
async function connectMockDevice(page, opts = {}) {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||
await injectMockDevice(page, opts);
|
||||
if (opts.overrideFirmware) {
|
||||
await overrideFirmwareURLs(page);
|
||||
}
|
||||
await page.click('#btn-connect');
|
||||
await expect(page.locator('#step-device')).not.toBeHidden();
|
||||
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
|
||||
await expect(page.locator('#device-firmware')).toHaveText('4.45.23646');
|
||||
await expect(page.locator('#device-status')).toContainText('recognized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Override firmware download URLs to point at the local test server.
|
||||
*/
|
||||
async function overrideFirmwareURLs(page) {
|
||||
await page.evaluate(() => {
|
||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
||||
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to manual mode.
|
||||
*/
|
||||
async function goToManualMode(page) {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||
await page.click('#btn-manual');
|
||||
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's content from the mock filesystem.
|
||||
*/
|
||||
async function readMockFile(page, ...pathParts) {
|
||||
return page.evaluate((parts) => {
|
||||
let node = window.__mockFS;
|
||||
for (const part of parts) {
|
||||
if (!node || !node[part]) return null;
|
||||
node = node[part];
|
||||
}
|
||||
return node && node._type === 'file' ? (node.content || '') : null;
|
||||
}, pathParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists in the mock filesystem.
|
||||
*/
|
||||
async function mockPathExists(page, ...pathParts) {
|
||||
return page.evaluate((parts) => {
|
||||
let node = window.__mockFS;
|
||||
for (const part of parts) {
|
||||
if (!node || !node[part]) return false;
|
||||
node = node[part];
|
||||
}
|
||||
return true;
|
||||
}, pathParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of written file paths from the mock device.
|
||||
*/
|
||||
async function getWrittenFiles(page) {
|
||||
return page.evaluate(() => Object.keys(window.__mockWrittenFiles));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
injectMockDevice,
|
||||
connectMockDevice,
|
||||
overrideFirmwareURLs,
|
||||
goToManualMode,
|
||||
readMockFile,
|
||||
mockPathExists,
|
||||
getWrittenFiles,
|
||||
};
|
||||
28
tests/e2e/helpers/paths.js
Normal file
28
tests/e2e/helpers/paths.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const path = require('path');
|
||||
|
||||
const CACHED_ASSETS = path.resolve(__dirname, '..', '..', 'cached_assets');
|
||||
|
||||
const FIRMWARE_PATH = path.join(CACHED_ASSETS, 'kobo-update-4.45.23646.zip');
|
||||
|
||||
const WEBROOT = path.resolve(__dirname, '..', '..', '..', 'web', 'dist');
|
||||
const WEBROOT_FIRMWARE = path.join(WEBROOT, '_test_firmware.zip');
|
||||
|
||||
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
||||
// with only "Remove footer (row3) on new home screen" enabled.
|
||||
const EXPECTED_SHA1 = {
|
||||
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d',
|
||||
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
|
||||
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
|
||||
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
|
||||
};
|
||||
|
||||
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
||||
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
||||
|
||||
module.exports = {
|
||||
FIRMWARE_PATH,
|
||||
WEBROOT,
|
||||
WEBROOT_FIRMWARE,
|
||||
EXPECTED_SHA1,
|
||||
ORIGINAL_TGZ_SHA1,
|
||||
};
|
||||
33
tests/e2e/helpers/tar.js
Normal file
33
tests/e2e/helpers/tar.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
|
||||
*/
|
||||
function parseTar(buffer) {
|
||||
const entries = {};
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const header = buffer.subarray(offset, offset + 512);
|
||||
if (header.every(b => b === 0)) break;
|
||||
|
||||
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
||||
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
|
||||
if (prefix) name = prefix + '/' + name;
|
||||
name = name.replace(/^\.\//, '');
|
||||
|
||||
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
|
||||
const size = parseInt(sizeStr, 8) || 0;
|
||||
const typeFlag = header[156];
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (typeFlag === 48 || typeFlag === 0) {
|
||||
entries[name] = buffer.subarray(offset, offset + size);
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
module.exports = { parseTar };
|
||||
@@ -1,276 +1,19 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const JSZip = require('jszip');
|
||||
|
||||
// Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646,
|
||||
// with only "Remove footer (row3) on new home screen" enabled.
|
||||
const EXPECTED_SHA1 = {
|
||||
'usr/local/Kobo/libnickel.so.1.0.0': 'ef64782895a47ac85f0829f06fffa4816d23512d',
|
||||
'usr/local/Kobo/nickel': '80a607bac515457a6864be8be831df631a01005c',
|
||||
'usr/local/Kobo/libadobe.so': '02dc99c71c4fef75401cd49ddc2e63f928a126e1',
|
||||
'usr/local/Kobo/librmsdk.so.1.0.0': 'e3819260c9fc539a53db47e9d3fe600ec11633d5',
|
||||
};
|
||||
const { FIRMWARE_PATH, EXPECTED_SHA1, ORIGINAL_TGZ_SHA1 } = require('./helpers/paths');
|
||||
const { hasNickelMenuAssets, hasKoreaderAssets, hasFirmwareZip, setupFirmwareSymlink, cleanupFirmwareSymlink } = require('./helpers/assets');
|
||||
const { injectMockDevice, connectMockDevice, overrideFirmwareURLs, goToManualMode, readMockFile, mockPathExists, getWrittenFiles } = require('./helpers/mock-device');
|
||||
const { parseTar } = require('./helpers/tar');
|
||||
|
||||
const FIRMWARE_PATH = process.env.FIRMWARE_ZIP
|
||||
|| path.resolve(__dirname, '..', '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
|
||||
|
||||
const WEBROOT = path.resolve(__dirname, '..', '..', 'web', 'dist');
|
||||
const WEBROOT_FIRMWARE = path.join(WEBROOT, '_test_firmware.zip');
|
||||
|
||||
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
||||
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
||||
|
||||
/**
|
||||
* Parse a tar archive (uncompressed) and return a map of entry name -> Buffer.
|
||||
*/
|
||||
function parseTar(buffer) {
|
||||
const entries = {};
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const header = buffer.subarray(offset, offset + 512);
|
||||
if (header.every(b => b === 0)) break;
|
||||
|
||||
let name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
||||
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
|
||||
if (prefix) name = prefix + '/' + name;
|
||||
name = name.replace(/^\.\//, '');
|
||||
|
||||
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
|
||||
const size = parseInt(sizeStr, 8) || 0;
|
||||
const typeFlag = header[156];
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (typeFlag === 48 || typeFlag === 0) {
|
||||
entries[name] = buffer.subarray(offset, offset + size);
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Clean up the symlink after each test.
|
||||
test.afterEach(() => {
|
||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||
cleanupFirmwareSymlink();
|
||||
});
|
||||
|
||||
/**
|
||||
* Check that NickelMenu assets exist in webroot.
|
||||
*/
|
||||
function hasNickelMenuAssets() {
|
||||
return fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'NickelMenu.zip'))
|
||||
&& fs.existsSync(path.join(WEBROOT, 'nickelmenu', 'kobo-config.zip'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to manual mode: click "Download files manually" on the connect step.
|
||||
*/
|
||||
async function goToManualMode(page) {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||
await page.click('#btn-manual');
|
||||
await expect(page.locator('#step-mode')).not.toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override firmware download URLs to point at the local test server.
|
||||
*/
|
||||
async function overrideFirmwareURLs(page) {
|
||||
await page.evaluate(() => {
|
||||
for (const version of Object.keys(FIRMWARE_DOWNLOADS)) {
|
||||
for (const prefix of Object.keys(FIRMWARE_DOWNLOADS[version])) {
|
||||
FIRMWARE_DOWNLOADS[version][prefix] = '/_test_firmware.zip';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up firmware symlink for tests that need it.
|
||||
*/
|
||||
function setupFirmwareSymlink() {
|
||||
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
|
||||
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock File System Access API into the page, simulating a Kobo Libra Color.
|
||||
* The mock provides:
|
||||
* - .kobo/version file with serial N4280A0000000 and firmware 4.45.23646
|
||||
* - Optionally a .adds/nm/ directory (to simulate NickelMenu being installed)
|
||||
* - In-memory filesystem that tracks all writes for verification
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.hasNickelMenu=false] - Whether .adds/nm/ exists on device
|
||||
* @param {string} [opts.firmware='4.45.23646'] - Firmware version to report
|
||||
* @param {string} [opts.serial='N4280A0000000'] - Serial number to report
|
||||
*/
|
||||
async function injectMockDevice(page, opts = {}) {
|
||||
const firmware = opts.firmware || '4.45.23646';
|
||||
const serial = opts.serial || 'N4280A0000000';
|
||||
await page.evaluate(({ hasNickelMenu, firmware, serial }) => {
|
||||
// In-memory filesystem for the mock device
|
||||
const filesystem = {
|
||||
'.kobo': {
|
||||
_type: 'dir',
|
||||
'version': {
|
||||
_type: 'file',
|
||||
content: serial + ',4.9.77,' + firmware + ',4.9.77,4.9.77,00000000-0000-0000-0000-000000000390',
|
||||
},
|
||||
'Kobo': {
|
||||
_type: 'dir',
|
||||
'Kobo eReader.conf': {
|
||||
_type: 'file',
|
||||
content: '[General]\nsome=setting\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (hasNickelMenu) {
|
||||
filesystem['.adds'] = {
|
||||
_type: 'dir',
|
||||
'nm': {
|
||||
_type: 'dir',
|
||||
'items': { _type: 'file', content: 'menu_item:main:test:skip:' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Expose filesystem for verification from tests
|
||||
window.__mockFS = filesystem;
|
||||
// Track written file paths (relative path string -> true)
|
||||
window.__mockWrittenFiles = {};
|
||||
|
||||
function makeFileHandle(dirNode, fileName, pathPrefix) {
|
||||
return {
|
||||
getFile: async () => {
|
||||
const fileNode = dirNode[fileName];
|
||||
const content = fileNode ? (fileNode.content || '') : '';
|
||||
return {
|
||||
text: async () => content,
|
||||
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
|
||||
};
|
||||
},
|
||||
createWritable: async () => {
|
||||
const chunks = [];
|
||||
return {
|
||||
write: async (chunk) => { chunks.push(chunk); },
|
||||
close: async () => {
|
||||
const first = chunks[0];
|
||||
const bytes = first instanceof Uint8Array ? first : new TextEncoder().encode(String(first));
|
||||
if (!dirNode[fileName]) dirNode[fileName] = { _type: 'file' };
|
||||
dirNode[fileName].content = new TextDecoder().decode(bytes);
|
||||
const fullPath = pathPrefix ? pathPrefix + '/' + fileName : fileName;
|
||||
window.__mockWrittenFiles[fullPath] = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDirHandle(node, name, pathPrefix) {
|
||||
const currentPath = pathPrefix ? pathPrefix + '/' + name : name;
|
||||
return {
|
||||
name: name,
|
||||
kind: 'directory',
|
||||
getDirectoryHandle: async (childName, opts2) => {
|
||||
if (node[childName] && node[childName]._type === 'dir') {
|
||||
return makeDirHandle(node[childName], childName, currentPath);
|
||||
}
|
||||
if (opts2 && opts2.create) {
|
||||
node[childName] = { _type: 'dir' };
|
||||
return makeDirHandle(node[childName], childName, currentPath);
|
||||
}
|
||||
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||
},
|
||||
getFileHandle: async (childName, opts2) => {
|
||||
if (node[childName] && node[childName]._type === 'file') {
|
||||
return makeFileHandle(node, childName, currentPath);
|
||||
}
|
||||
if (opts2 && opts2.create) {
|
||||
node[childName] = { _type: 'file', content: '' };
|
||||
return makeFileHandle(node, childName, currentPath);
|
||||
}
|
||||
throw new DOMException('Not found: ' + childName, 'NotFoundError');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rootHandle = makeDirHandle(filesystem, 'KOBOeReader', '');
|
||||
|
||||
// Override showDirectoryPicker
|
||||
window.showDirectoryPicker = async () => rootHandle;
|
||||
}, { hasNickelMenu: opts.hasNickelMenu || false, firmware: firmware, serial: serial });
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject mock device, optionally override firmware URLs, and connect.
|
||||
* Firmware URLs must be overridden BEFORE connecting, because the app captures
|
||||
* the firmware URL during device detection (configureFirmwareStep).
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.hasNickelMenu=false]
|
||||
* @param {boolean} [opts.overrideFirmware=false] - Override firmware URLs before connecting
|
||||
*/
|
||||
async function connectMockDevice(page, opts = {}) {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('KoboPatch');
|
||||
await injectMockDevice(page, opts);
|
||||
if (opts.overrideFirmware) {
|
||||
await overrideFirmwareURLs(page);
|
||||
}
|
||||
await page.click('#btn-connect');
|
||||
await expect(page.locator('#step-device')).not.toBeHidden();
|
||||
await expect(page.locator('#device-model')).toHaveText('Kobo Libra Colour');
|
||||
await expect(page.locator('#device-firmware')).toHaveText('4.45.23646');
|
||||
await expect(page.locator('#device-status')).toContainText('recognized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's content from the mock filesystem.
|
||||
*/
|
||||
async function readMockFile(page, ...pathParts) {
|
||||
return page.evaluate((parts) => {
|
||||
let node = window.__mockFS;
|
||||
for (const part of parts) {
|
||||
if (!node || !node[part]) return null;
|
||||
node = node[part];
|
||||
}
|
||||
return node && node._type === 'file' ? (node.content || '') : null;
|
||||
}, pathParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists in the mock filesystem.
|
||||
*/
|
||||
async function mockPathExists(page, ...pathParts) {
|
||||
return page.evaluate((parts) => {
|
||||
let node = window.__mockFS;
|
||||
for (const part of parts) {
|
||||
if (!node || !node[part]) return false;
|
||||
node = node[part];
|
||||
}
|
||||
return true;
|
||||
}, pathParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of written file paths from the mock device.
|
||||
*/
|
||||
async function getWrittenFiles(page) {
|
||||
return page.evaluate(() => Object.keys(window.__mockWrittenFiles));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NickelMenu
|
||||
// ============================================================
|
||||
@@ -300,6 +43,7 @@ test.describe('NickelMenu', () => {
|
||||
await expect(page.locator('input[name="nm-cfg-screensaver"]')).not.toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-simplify-tabs"]')).not.toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-simplify-home"]')).not.toBeChecked();
|
||||
await expect(page.locator('input[name="nm-cfg-koreader"]')).not.toBeChecked();
|
||||
|
||||
// Enable simplifyHome for testing
|
||||
await page.check('input[name="nm-cfg-simplify-home"]');
|
||||
@@ -350,6 +94,86 @@ test.describe('NickelMenu', () => {
|
||||
expect(itemsContent).toContain('experimental:hide_home_row3_enabled:1');
|
||||
});
|
||||
|
||||
test('no device — install with KOReader via manual download', async ({ page }) => {
|
||||
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||
test.skip(!hasKoreaderAssets(), 'KOReader assets not found (run koreader/setup.sh)');
|
||||
|
||||
await goToManualMode(page);
|
||||
|
||||
// Mode selection
|
||||
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
|
||||
await page.click('#btn-mode-next');
|
||||
|
||||
// NickelMenu configure step — select "Install NickelMenu with preset"
|
||||
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
|
||||
await page.click('input[name="nm-option"][value="sample"]');
|
||||
await expect(page.locator('#nm-config-options')).not.toBeHidden();
|
||||
|
||||
// KOReader checkbox should be visible and unchecked by default
|
||||
await expect(page.locator('input[name="nm-cfg-koreader"]')).not.toBeChecked();
|
||||
|
||||
// Enable KOReader
|
||||
await page.check('input[name="nm-cfg-koreader"]');
|
||||
|
||||
await page.click('#btn-nm-next');
|
||||
|
||||
// Review step — should list KOReader
|
||||
await expect(page.locator('#step-nm-review')).not.toBeHidden();
|
||||
await expect(page.locator('#nm-review-list')).toContainText('KOReader');
|
||||
|
||||
// Download
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.click('#btn-nm-download'),
|
||||
]);
|
||||
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Verify ZIP contents include KOReader files
|
||||
expect(download.suggestedFilename()).toBe('NickelMenu-install.zip');
|
||||
const zipData = fs.readFileSync(await download.path());
|
||||
const zip = await JSZip.loadAsync(zipData);
|
||||
const zipFiles = Object.keys(zip.files);
|
||||
|
||||
expect(zipFiles).toContainEqual('.kobo/KoboRoot.tgz');
|
||||
expect(zipFiles).toContainEqual('.adds/nm/items');
|
||||
// KOReader files should be present under .adds/koreader/
|
||||
expect(zipFiles.some(f => f.startsWith('.adds/koreader/'))).toBe(true);
|
||||
});
|
||||
|
||||
test('with device — install with KOReader writes files to device', async ({ page }) => {
|
||||
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||
test.skip(!hasKoreaderAssets(), 'KOReader assets not found (run koreader/setup.sh)');
|
||||
|
||||
await connectMockDevice(page, { hasNickelMenu: false });
|
||||
|
||||
await page.click('#btn-device-next');
|
||||
await page.click('#btn-mode-next');
|
||||
|
||||
// Select "Install NickelMenu with preset"
|
||||
await page.click('input[name="nm-option"][value="sample"]');
|
||||
|
||||
// Enable KOReader
|
||||
await page.check('input[name="nm-cfg-koreader"]');
|
||||
|
||||
await page.click('#btn-nm-next');
|
||||
|
||||
// Review step
|
||||
await expect(page.locator('#nm-review-list')).toContainText('KOReader');
|
||||
|
||||
// Write to device
|
||||
await page.click('#btn-nm-write');
|
||||
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator('#nm-done-status')).toContainText('installed');
|
||||
|
||||
// Verify KOReader files were written to mock device
|
||||
const writtenFiles = await getWrittenFiles(page);
|
||||
expect(writtenFiles.some(f => f.includes('koreader'))).toBe(true);
|
||||
|
||||
// Verify the .adds/koreader directory was created in mock FS
|
||||
const koreaderDirExists = await mockPathExists(page, '.adds', 'koreader');
|
||||
expect(koreaderDirExists, '.adds/koreader/ should exist').toBe(true);
|
||||
});
|
||||
|
||||
test('no device — install NickelMenu only via manual download', async ({ page }) => {
|
||||
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
|
||||
|
||||
@@ -549,7 +373,7 @@ test.describe('NickelMenu', () => {
|
||||
|
||||
test.describe('Custom patches', () => {
|
||||
test('no device — full manual mode patching pipeline', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
await goToManualMode(page);
|
||||
@@ -633,7 +457,7 @@ test.describe('Custom patches', () => {
|
||||
});
|
||||
|
||||
test('no device — restore original firmware', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
await goToManualMode(page);
|
||||
@@ -755,7 +579,7 @@ test.describe('Custom patches', () => {
|
||||
});
|
||||
|
||||
test('with device — apply patches and verify checksums', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
// Override firmware URLs BEFORE connecting so the app captures the local URL
|
||||
@@ -830,7 +654,7 @@ test.describe('Custom patches', () => {
|
||||
});
|
||||
|
||||
test('with device — restore original firmware', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
// Override firmware URLs BEFORE connecting so the app captures the local URL
|
||||
@@ -872,7 +696,7 @@ test.describe('Custom patches', () => {
|
||||
});
|
||||
|
||||
test('with device — build failure shows Go Back and returns to patches', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });
|
||||
@@ -909,7 +733,7 @@ test.describe('Custom patches', () => {
|
||||
});
|
||||
|
||||
test('with device — real patch failure with Go Back (Allow rotation)', async ({ page }) => {
|
||||
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`);
|
||||
|
||||
setupFirmwareSymlink();
|
||||
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
#
|
||||
# Prerequisites:
|
||||
# - kobopatch.wasm built (run kobopatch-wasm/build.sh first)
|
||||
# - Firmware zip cached at kobopatch-wasm/testdata/ (downloaded automatically)
|
||||
# - Test assets cached in tests/cached_assets/ (run ./test.sh to download)
|
||||
# - NickelMenu assets in web/src/nickelmenu/ (set up automatically)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
@@ -45,11 +45,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
FIRMWARE_VERSION="4.45.23646"
|
||||
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/kobopatch-wasm/testdata"
|
||||
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
|
||||
|
||||
# Check WASM is built.
|
||||
if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then
|
||||
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
|
||||
@@ -63,20 +58,10 @@ if [ ! -f "$NM_DIR/NickelMenu.zip" ] || [ ! -f "$NM_DIR/kobo-config.zip" ]; then
|
||||
"$PROJECT_ROOT/nickelmenu/setup.sh"
|
||||
fi
|
||||
|
||||
# Download firmware if not cached.
|
||||
if [ ! -f "$FIRMWARE_FILE" ]; then
|
||||
echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..."
|
||||
mkdir -p "$FIRMWARE_DIR"
|
||||
curl -fL --progress-bar -o "$FIRMWARE_FILE.tmp" "$FIRMWARE_URL"
|
||||
if ! file "$FIRMWARE_FILE.tmp" | grep -q "Zip archive"; then
|
||||
echo "ERROR: downloaded file is not a valid zip"
|
||||
rm -f "$FIRMWARE_FILE.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "$FIRMWARE_FILE.tmp" "$FIRMWARE_FILE"
|
||||
echo "Downloaded to $FIRMWARE_FILE"
|
||||
else
|
||||
echo "Using cached firmware: $FIRMWARE_FILE"
|
||||
# Set up KOReader assets if not present.
|
||||
if [ ! -f "$SRC_DIR/koreader/koreader-kobo.zip" ]; then
|
||||
echo "Setting up KOReader assets..."
|
||||
"$PROJECT_ROOT/koreader/setup.sh"
|
||||
fi
|
||||
|
||||
# Install dependencies and browser.
|
||||
@@ -85,5 +70,4 @@ npx playwright install chromium
|
||||
|
||||
# Run the tests.
|
||||
echo "Running E2E integration tests..."
|
||||
FIRMWARE_ZIP="$FIRMWARE_FILE" \
|
||||
npx playwright test "${PLAYWRIGHT_ARGS[@]}"
|
||||
npx playwright test "${PLAYWRIGHT_ARGS[@]}"
|
||||
|
||||
@@ -898,6 +898,10 @@ select + .fallback-hint {
|
||||
/* Install instructions */
|
||||
.install-instructions {
|
||||
margin-top: 1rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.install-instructions .warning {
|
||||
@@ -909,14 +913,25 @@ select + .fallback-hint {
|
||||
}
|
||||
|
||||
.install-steps {
|
||||
margin: 0.5rem 0 0 1.25rem;
|
||||
margin: 0.25rem 0 0 1.25rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.install-steps li {
|
||||
padding: 0.1rem 0;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.install-steps code {
|
||||
display: inline-block;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.step .info-banner {
|
||||
|
||||
@@ -191,6 +191,13 @@
|
||||
<span class="nm-config-desc">Adds the Readerly font family. These fonts are optically similar to Bookerly. When you are reading a book, you will be able to select this font from the dropdown as "KF Readerly".</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item" id="nm-cfg-koreader-label">
|
||||
<input type="checkbox" name="nm-cfg-koreader">
|
||||
<div class="nm-config-text">
|
||||
<span>Install KOReader <span id="koreader-version"></span> (optional)</span>
|
||||
<span class="nm-config-desc">Installs <a href="https://koreader.rocks" target="_blank">KOReader</a>, an alternative e-book reader with advanced features like PDF reflow, customizable fonts, and more.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="nm-config-item">
|
||||
<input type="checkbox" name="nm-cfg-simplify-tabs">
|
||||
<div class="nm-config-text">
|
||||
@@ -269,12 +276,12 @@
|
||||
<div id="nm-download-instructions" class="install-instructions" hidden>
|
||||
<ol class="install-steps">
|
||||
<li>Connect your Kobo via USB so it appears as a removable drive.</li>
|
||||
<li>Extract the downloaded ZIP to the <strong>root</strong> of the Kobo drive, preserving the folder structure.</li>
|
||||
<li id="nm-download-conf-step" hidden>
|
||||
Open <strong>.kobo/Kobo/Kobo eReader.conf</strong> in a text editor.<br>
|
||||
<li id="nm-download-conf-step" hidden>Open <strong>.kobo/Kobo/Kobo eReader.conf</strong> in a text editor.<br>
|
||||
Find the <code>[FeatureSettings]</code> section (or add it at the end) and add the following line:<br>
|
||||
<code>ExcludeSyncFolders=(calibre|\.(?!kobo|adobe|calibre).+|([^.][^/]*/)+\..+)</code><br>
|
||||
This prevents the Kobo from discovering books in these folders during a sync.</li>
|
||||
This prevents the Kobo from incorrectly identifying certain files as books in your library.</li>
|
||||
<li id="nm-download-reboot-step" hidden><strong>Safely eject</strong> the Kobo, then power it off by holding the power button until it says "Powered off". Press the power button again to boot it back up. The config change takes effect after reboot. Reconnect the Kobo via USB afterwards.</li>
|
||||
<li>Extract the downloaded ZIP to the <strong>root</strong> of the Kobo drive, preserving the folder structure. Make sure hidden folders like <code>.kobo</code> and <code>.adds</code> are also copied.</li>
|
||||
<li><strong>Safely eject</strong> the Kobo — do not just unplug the cable.</li>
|
||||
<li>The device will reboot and install NickelMenu automatically.</li>
|
||||
</ol>
|
||||
|
||||
@@ -23,16 +23,27 @@ import JSZip from 'jszip';
|
||||
let selectedMode = null; // 'nickelmenu' | 'patches'
|
||||
let nickelMenuOption = null; // 'sample' | 'nickelmenu-only' | 'remove'
|
||||
|
||||
// Fetch data eagerly so it's ready when needed.
|
||||
const softwareUrlsReady = loadSoftwareUrls();
|
||||
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const $q = (sel, ctx = document) => ctx.querySelector(sel);
|
||||
const $qa = (sel, ctx = document) => ctx.querySelectorAll(sel);
|
||||
|
||||
// Fetch data eagerly so it's ready when needed.
|
||||
const softwareUrlsReady = loadSoftwareUrls();
|
||||
const availablePatchesReady = scanAvailablePatches().then(p => { availablePatches = p; });
|
||||
|
||||
// Show KOReader version in the UI (best-effort, non-blocking).
|
||||
fetch('/koreader/release.json').then(r => r.ok ? r.json() : null).then(meta => {
|
||||
if (meta && meta.version) {
|
||||
$('koreader-version').textContent = meta.version;
|
||||
} else {
|
||||
$('nm-cfg-koreader-label').style.display = 'none';
|
||||
}
|
||||
}).catch(() => {
|
||||
$('nm-cfg-koreader-label').style.display = 'none';
|
||||
});
|
||||
|
||||
function formatMB(bytes) {
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
@@ -502,6 +513,7 @@ import JSZip from 'jszip';
|
||||
screensaver: $q('input[name="nm-cfg-screensaver"]').checked,
|
||||
simplifyTabs: $q('input[name="nm-cfg-simplify-tabs"]').checked,
|
||||
simplifyHome: $q('input[name="nm-cfg-simplify-home"]').checked,
|
||||
koreader: $q('input[name="nm-cfg-koreader"]').checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -558,6 +570,7 @@ import JSZip from 'jszip';
|
||||
if (cfg.screensaver) items.push(TL.NICKEL_MENU_ITEMS.SCREENSAVER);
|
||||
if (cfg.simplifyTabs) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_TABS);
|
||||
if (cfg.simplifyHome) items.push(TL.NICKEL_MENU_ITEMS.SIMPLIFY_HOME);
|
||||
if (cfg.koreader) items.push(TL.NICKEL_MENU_ITEMS.KOREADER);
|
||||
for (const text of items) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = text;
|
||||
@@ -638,8 +651,10 @@ import JSZip from 'jszip';
|
||||
nmDoneStatus.textContent = TL.STATUS.NM_DOWNLOAD_READY;
|
||||
triggerDownload(resultNmZip, 'NickelMenu-install.zip', 'application/zip');
|
||||
$('nm-download-instructions').hidden = false;
|
||||
// Show eReader.conf step only when sample config is included
|
||||
$('nm-download-conf-step').hidden = nickelMenuOption !== 'sample';
|
||||
// Show eReader.conf + reboot steps only when sample config is included
|
||||
const showConfStep = nickelMenuOption === 'sample';
|
||||
$('nm-download-conf-step').hidden = !showConfStep;
|
||||
$('nm-download-reboot-step').hidden = !showConfStep;
|
||||
}
|
||||
|
||||
setNavStep(5);
|
||||
|
||||
@@ -16,11 +16,13 @@ import JSZip from 'jszip';
|
||||
* screensaver: bool — include custom screensaver
|
||||
* simplifyTabs: bool — comment out experimental tab items in config
|
||||
* simplifyHome: bool — append homescreen simplification lines
|
||||
* koreader: bool — download and install latest KOReader from GitHub
|
||||
*/
|
||||
class NickelMenuInstaller {
|
||||
constructor() {
|
||||
this.nickelMenuZip = null; // JSZip instance
|
||||
this.koboConfigZip = null; // JSZip instance
|
||||
this.koreaderZip = null; // JSZip instance
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,25 @@ class NickelMenuInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache KOReader for Kobo (served from the app's own domain
|
||||
* to avoid CORS issues with GitHub release downloads).
|
||||
* @param {function} progressFn
|
||||
*/
|
||||
async loadKoreader(progressFn) {
|
||||
if (this.koreaderZip) return;
|
||||
|
||||
progressFn('Fetching KOReader release info...');
|
||||
const metaResp = await fetch('/koreader/release.json');
|
||||
if (!metaResp.ok) throw new Error('KOReader assets not available (run koreader/setup.sh)');
|
||||
const meta = await metaResp.json();
|
||||
|
||||
progressFn('Downloading KOReader ' + meta.version + '...');
|
||||
const zipResp = await fetch('/koreader/koreader-kobo.zip');
|
||||
if (!zipResp.ok) throw new Error('Failed to download KOReader: HTTP ' + zipResp.status);
|
||||
this.koreaderZip = await JSZip.loadAsync(await zipResp.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KoboRoot.tgz from the NickelMenu zip.
|
||||
*/
|
||||
@@ -53,6 +74,37 @@ class NickelMenuInstaller {
|
||||
return new Uint8Array(await file.async('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KOReader files from the downloaded zip, remapped to .adds/koreader/.
|
||||
* The zip contains a top-level koreader/ directory that needs to be placed
|
||||
* under .adds/ on the device. Also includes a NickelMenu launcher config.
|
||||
* Returns { path: string[], data: Uint8Array } entries.
|
||||
*/
|
||||
async getKoreaderFiles() {
|
||||
const files = [];
|
||||
for (const [relativePath, zipEntry] of Object.entries(this.koreaderZip.files)) {
|
||||
if (zipEntry.dir) continue;
|
||||
// Remap koreader/... to .adds/koreader/...
|
||||
const devicePath = relativePath.startsWith('koreader/')
|
||||
? '.adds/' + relativePath
|
||||
: '.adds/koreader/' + relativePath;
|
||||
const data = new Uint8Array(await zipEntry.async('arraybuffer'));
|
||||
files.push({
|
||||
path: devicePath.split('/'),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// Add NickelMenu launcher config
|
||||
const launcherConfig = 'menu_item:main:KOReader:cmd_spawn:quiet:exec /mnt/onboard/.adds/koreader/koreader.sh\n';
|
||||
files.push({
|
||||
path: ['.adds', 'nm', 'koreader'],
|
||||
data: new TextEncoder().encode(launcherConfig),
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config files from kobo-config.zip filtered by cfg flags.
|
||||
* Returns { path: string[], data: Uint8Array } entries.
|
||||
@@ -135,6 +187,16 @@ class NickelMenuInstaller {
|
||||
await device.writeFile(path, data);
|
||||
}
|
||||
|
||||
// Install KOReader if selected
|
||||
if (cfg.koreader) {
|
||||
await this.loadKoreader(progressFn);
|
||||
progressFn('Writing KOReader files...');
|
||||
const koreaderFiles = await this.getKoreaderFiles();
|
||||
for (const { path, data } of koreaderFiles) {
|
||||
await device.writeFile(path, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Modify Kobo eReader.conf
|
||||
progressFn('Updating Kobo eReader.conf...');
|
||||
await this.updateEReaderConf(device);
|
||||
@@ -193,6 +255,16 @@ class NickelMenuInstaller {
|
||||
for (const { path, data } of configFiles) {
|
||||
zip.file(path.join('/'), data);
|
||||
}
|
||||
|
||||
// Include KOReader if selected
|
||||
if (cfg.koreader) {
|
||||
await this.loadKoreader(progressFn);
|
||||
progressFn('Adding KOReader to package...');
|
||||
const koreaderFiles = await this.getKoreaderFiles();
|
||||
for (const { path, data } of koreaderFiles) {
|
||||
zip.file(path.join('/'), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressFn('Compressing...');
|
||||
|
||||
@@ -55,5 +55,6 @@ export const TL = {
|
||||
SCREENSAVER: 'Custom screensaver',
|
||||
SIMPLIFY_TABS: 'Simplified tab menu',
|
||||
SIMPLIFY_HOME: 'Simplified homescreen',
|
||||
KOREADER: 'KOReader',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user