1
0

Add end-to-end playwright test
All checks were successful
Build & Test WASM / build-and-test (push) Successful in 1m40s

This commit is contained in:
2026-03-16 12:13:16 +01:00
parent f080bdce00
commit d5347a7093
9 changed files with 384 additions and 1 deletions

View File

@@ -64,8 +64,14 @@ jobs:
echo "run=false" >> "$GITHUB_OUTPUT" echo "run=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Full integration test - name: Full integration test (WASM)
if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true' if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: | run: |
cd kobopatch-wasm cd kobopatch-wasm
./test-integration.sh ./test-integration.sh
- name: Full integration test (Playwright)
if: steps.check-wasm.outputs.run == 'true' && env.GITEA_ACTIONS != 'true'
run: |
cd e2e
./run-e2e.sh

8
.gitignore vendored
View File

@@ -11,9 +11,17 @@ kobopatch-wasm/testdata/
kobopatch-wasm/kobopatch.wasm kobopatch-wasm/kobopatch.wasm
kobopatch-wasm/wasm_exec.js kobopatch-wasm/wasm_exec.js
# Test artifacts in webroot
src/public/_test_firmware.zip
# WASM artifacts copied to webroot for serving # WASM artifacts copied to webroot for serving
src/public/kobopatch.wasm src/public/kobopatch.wasm
src/public/wasm_exec.js src/public/wasm_exec.js
# E2E tests
e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
# Claude # Claude
.claude .claude

View File

@@ -70,6 +70,35 @@ cd kobopatch-wasm
python3 -m http.server -d src/public/ 8888 python3 -m http.server -d src/public/ 8888
``` ```
## Testing
To further validate the patched `KoboRoot.tgz` packages are identical to what a local version of `kobopatch` would generate, two integration tests have been added.
Both integration tests run the full patching pipeline with firmware 4.45.23646 (Kobo Libra Color), enable a single patch, and verify SHA1 checksums of all 4 patched binaries. The firmware zip (~150MB) is downloaded once and cached in `kobopatch-wasm/testdata/`.
The reason this particular combination is used is simple: the author has actually used that specific firmware on an actual device before and it's a known, working, patched version of the firmware. So comparing hashes against it seems like a good idea.
**WASM integration test** — calls `patchFirmware()` directly in Go/WASM via Node.js:
```bash
cd kobopatch-wasm
./test-integration.sh
```
**Playwright E2E test** — drives the full browser UI (manual mode, headless):
```bash
cd e2e
./run-e2e.sh
```
To run the Playwright test with a visible browser window:
```bash
cd e2e
./run-e2e-local.sh
```
## Output validation ## Output validation
The WASM patcher performs several checks on each patched binary before including it in the output `KoboRoot.tgz`: The WASM patcher performs several checks on each patched binary before including it in the output `KoboRoot.tgz`:

148
e2e/integration.spec.js Normal file
View File

@@ -0,0 +1,148 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
// 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 = process.env.FIRMWARE_ZIP
|| path.resolve(__dirname, '..', 'kobopatch-wasm', 'testdata', 'kobo-update-4.45.23646.zip');
const WEBROOT_FIRMWARE = path.resolve(__dirname, '..', 'src', 'public', '_test_firmware.zip');
/**
* 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 the test.
test.afterEach(() => {
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
});
test('full manual mode patching pipeline', async ({ page }) => {
if (!fs.existsSync(FIRMWARE_PATH)) {
test.skip(true, `Firmware not found at ${FIRMWARE_PATH}`);
}
// Symlink the cached firmware into the webroot so the app can fetch it locally.
try { fs.unlinkSync(WEBROOT_FIRMWARE); } catch {}
fs.symlinkSync(path.resolve(FIRMWARE_PATH), WEBROOT_FIRMWARE);
await page.goto('/');
await expect(page.locator('h1')).toContainText('KoboPatch');
// Override the firmware download URLs to point at the local server.
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';
}
}
});
// Step 1: Switch to manual mode.
await page.click('#btn-manual-from-auto');
await expect(page.locator('#step-manual')).not.toBeHidden();
// Step 2: Select firmware version.
await page.selectOption('#manual-version', '4.45.23646');
await expect(page.locator('#manual-model')).not.toBeHidden();
// Step 3: Select Kobo Libra Colour (N428).
await page.selectOption('#manual-model', 'N428');
await expect(page.locator('#btn-manual-confirm')).toBeEnabled();
await page.click('#btn-manual-confirm');
// Step 4: Wait for patches to load.
await expect(page.locator('#step-patches')).not.toBeHidden();
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
// Step 5: Enable "Remove footer (row3) on new home screen".
const patchName = page.locator('.patch-name', { hasText: 'Remove footer (row3) on new home screen' }).first();
const patchSection = patchName.locator('xpath=ancestor::details');
await patchSection.locator('summary').click();
await expect(patchName).toBeVisible();
await patchName.locator('xpath=ancestor::label').locator('input').check();
// Verify patch count updated.
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
await expect(page.locator('#btn-patches-next')).toBeEnabled();
// Step 6: Continue to build step.
await page.click('#btn-patches-next');
await expect(page.locator('#step-firmware')).not.toBeHidden();
await expect(page.locator('#firmware-version-label')).toHaveText('4.45.23646');
await expect(page.locator('#firmware-device-label')).toHaveText('Kobo Libra Colour');
// Step 7: Build and wait for completion.
await page.click('#btn-build');
const doneOrError = await Promise.race([
page.locator('#step-done').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'done'),
page.locator('#step-error').waitFor({ state: 'visible', timeout: 240_000 }).then(() => 'error'),
]);
if (doneOrError === 'error') {
const errorMsg = await page.locator('#error-message').textContent();
throw new Error(`Build failed: ${errorMsg}`);
}
await expect(page.locator('#build-status')).toContainText('Patching complete');
// Step 8: Download KoboRoot.tgz and verify checksums.
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#btn-download'),
]);
expect(download.suggestedFilename()).toBe('KoboRoot.tgz');
const downloadPath = await download.path();
const tgzData = fs.readFileSync(downloadPath);
const tarData = zlib.gunzipSync(tgzData);
const entries = parseTar(tarData);
for (const [name, expectedHash] of Object.entries(EXPECTED_SHA1)) {
const data = entries[name];
expect(data, `missing binary in output: ${name}`).toBeDefined();
const actualHash = crypto.createHash('sha1').update(data).digest('hex');
expect(actualHash, `SHA1 mismatch for ${name}`).toBe(expectedHash);
}
});

76
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "kobopatch-webui-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kobopatch-webui-e2e",
"devDependencies": {
"@playwright/test": "^1.50.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

10
e2e/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "kobopatch-webui-e2e",
"private": true,
"scripts": {
"test": "npx playwright test"
},
"devDependencies": {
"@playwright/test": "^1.50.0"
}
}

19
e2e/playwright.config.js Normal file
View File

@@ -0,0 +1,19 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: '.',
testMatch: '*.spec.js',
timeout: 300_000,
retries: 0,
use: {
baseURL: 'http://localhost:8889',
launchOptions: {
args: ['--disable-dev-shm-usage'],
},
},
webServer: {
command: 'python3 -m http.server -d ../src/public 8889',
port: 8889,
reuseExistingServer: true,
},
});

38
e2e/run-e2e-local.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# Run the E2E integration test with a visible browser window.
# Usage: ./run-e2e-local.sh
cd "$(dirname "$0")"
FIRMWARE_VERSION="4.45.23646"
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
FIRMWARE_DIR="../kobopatch-wasm/testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
if [ ! -f "../src/public/kobopatch.wasm" ]; then
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
exit 1
fi
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"
else
echo "Using cached firmware: $FIRMWARE_FILE"
fi
npm install --silent 2>/dev/null
npx playwright install chromium 2>/dev/null
echo "Running E2E integration test (headed)..."
FIRMWARE_ZIP="$(cd .. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
npx playwright test --reporter=list --headed

49
e2e/run-e2e.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
# E2E integration test: runs the full manual-mode patching flow in a browser
# and verifies SHA1 checksums of the patched binaries.
#
# Usage: ./run-e2e.sh
#
# Prerequisites:
# - kobopatch.wasm built (run kobopatch-wasm/build.sh first)
# - Firmware zip cached at kobopatch-wasm/testdata/ (downloaded automatically)
cd "$(dirname "$0")"
FIRMWARE_VERSION="4.45.23646"
FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip"
FIRMWARE_DIR="../kobopatch-wasm/testdata"
FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip"
# Check WASM is built.
if [ ! -f "../src/public/kobopatch.wasm" ]; then
echo "ERROR: kobopatch.wasm not found. Run kobopatch-wasm/build.sh first."
exit 1
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"
fi
# Install dependencies and browser.
npm install --silent
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
# Run the test.
echo "Running E2E integration test..."
FIRMWARE_ZIP="$(cd .. && pwd)/kobopatch-wasm/testdata/kobo-update-${FIRMWARE_VERSION}.zip" \
npx playwright test --reporter=list