Change foundational structure
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m12s
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m12s
This commit is contained in:
227
tests/e2e/integration.spec.js
Normal file
227
tests/e2e/integration.spec.js
Normal file
@@ -0,0 +1,227 @@
|
||||
// @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, '..', '..', 'web', '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;
|
||||
}
|
||||
|
||||
// SHA1 of the original unmodified KoboRoot.tgz inside firmware 4.45.23646.
|
||||
const ORIGINAL_TGZ_SHA1 = 'b5c3307e8e7ec036f4601135f0b741c37b899db4';
|
||||
|
||||
// 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');
|
||||
await expect(page.locator('#build-status')).toContainText('Kobo Libra Colour');
|
||||
|
||||
// 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');
|
||||
await expect(page.locator('#download-device-name')).toHaveText('Kobo Libra Colour');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
test('restore original firmware 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, then continue with zero patches selected.
|
||||
await expect(page.locator('#step-patches')).not.toBeHidden();
|
||||
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
|
||||
await expect(page.locator('#patch-count-hint')).toContainText('restore the original');
|
||||
await page.click('#btn-patches-next');
|
||||
|
||||
// Step 5: Verify build step shows restore text.
|
||||
await expect(page.locator('#step-firmware')).not.toBeHidden();
|
||||
await expect(page.locator('#firmware-description')).toContainText('without modifications');
|
||||
await expect(page.locator('#btn-build')).toContainText('Restore Original Software');
|
||||
|
||||
// Step 6: 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(`Restore failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
await expect(page.locator('#build-status')).toContainText('Software extracted');
|
||||
|
||||
// Step 7: Download KoboRoot.tgz and verify it matches the original.
|
||||
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 actualHash = crypto.createHash('sha1').update(tgzData).digest('hex');
|
||||
expect(actualHash, 'restored KoboRoot.tgz SHA1 mismatch').toBe(ORIGINAL_TGZ_SHA1);
|
||||
});
|
||||
76
tests/e2e/package-lock.json
generated
Normal file
76
tests/e2e/package-lock.json
generated
Normal 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
tests/e2e/package.json
Normal file
10
tests/e2e/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "kobopatch-webui-e2e",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "npx playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
}
|
||||
}
|
||||
23
tests/e2e/playwright.config.js
Normal file
23
tests/e2e/playwright.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: '*.spec.js',
|
||||
timeout: 300_000,
|
||||
retries: 0,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:8889',
|
||||
actionTimeout: 10_000,
|
||||
launchOptions: {
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
command: 'python3 -m http.server -d ../../web/public 8889',
|
||||
port: 8889,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
38
tests/e2e/run-e2e-local.sh
Executable file
38
tests/e2e/run-e2e-local.sh
Executable 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 "../../web/public/wasm/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
tests/e2e/run-e2e.sh
Executable file
49
tests/e2e/run-e2e.sh
Executable 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 "../../web/public/wasm/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
|
||||
Reference in New Issue
Block a user