1
0
Files
kobopatch-webui/tests/e2e/integration.spec.js

745 lines
29 KiB
JavaScript

// @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 = path.resolve(__dirname, '..', '..', 'web', 'public');
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 {}
});
/**
* 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
*/
async function injectMockDevice(page, opts = {}) {
await page.evaluate(({ hasNickelMenu }) => {
// In-memory filesystem for the mock device
const filesystem = {
'.kobo': {
_type: 'dir',
'version': {
_type: 'file',
content: 'N4280A0000000,4.9.77,4.45.23646,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 });
}
/**
* 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
// ============================================================
test.describe('NickelMenu', () => {
test('no device — install with config via manual download', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
await goToManualMode(page);
// Mode selection: NickelMenu should be pre-selected (checked in HTML)
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
await page.click('#btn-mode-next');
// NickelMenu configure step
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
// No option pre-selected — Continue should be disabled
await expect(page.locator('#btn-nm-next')).toBeDisabled();
// Select "Install NickelMenu and configure"
await page.click('input[name="nm-option"][value="sample"]');
await expect(page.locator('#nm-config-options')).not.toBeHidden();
// Verify default checkbox states
await expect(page.locator('input[name="nm-cfg-fonts"]')).toBeChecked();
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('#btn-nm-next')).toBeEnabled();
await page.click('#btn-nm-next');
// Review step
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu');
await expect(page.locator('#nm-review-list')).toContainText('Readerly fonts');
// Write button should be hidden in manual mode
await expect(page.locator('#btn-nm-write')).toBeHidden();
// Download button visible
await expect(page.locator('#btn-nm-download')).toBeVisible();
// Click download and wait for done step
await page.click('#btn-nm-download');
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('#nm-done-status')).toContainText('ready to download');
// Download instructions should be visible, and include eReader.conf step for sample config
await expect(page.locator('#nm-download-instructions')).not.toBeHidden();
await expect(page.locator('#nm-download-conf-step')).not.toBeHidden();
});
test('no device — install NickelMenu only via manual download', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
await goToManualMode(page);
await page.click('#btn-mode-next');
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
// Select "Install NickelMenu only"
await page.click('input[name="nm-option"][value="nickelmenu-only"]');
await expect(page.locator('#nm-config-options')).toBeHidden();
await page.click('#btn-nm-next');
// Review step
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu (KoboRoot.tgz)');
// Download
await page.click('#btn-nm-download');
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('#nm-done-status')).toContainText('ready to download');
// eReader.conf step should be hidden for nickelmenu-only
await expect(page.locator('#nm-download-conf-step')).toBeHidden();
});
test('no device — remove option is disabled in manual mode', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
await goToManualMode(page);
await page.click('#btn-mode-next');
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
// Remove option should be disabled (no device connected)
await expect(page.locator('#nm-option-remove')).toHaveClass(/nm-option-disabled/);
await expect(page.locator('input[name="nm-option"][value="remove"]')).toBeDisabled();
});
test('with device — install with config and write to Kobo', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
await connectMockDevice(page, { hasNickelMenu: false });
// Continue to mode selection
await page.click('#btn-device-next');
await expect(page.locator('#step-mode')).not.toBeHidden();
// NickelMenu is pre-selected
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).toBeChecked();
await page.click('#btn-mode-next');
// NickelMenu configure step
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
// Remove option should be disabled (no NickelMenu installed)
await expect(page.locator('#nm-option-remove')).toHaveClass(/nm-option-disabled/);
// Select "Install NickelMenu and configure"
await page.click('input[name="nm-option"][value="sample"]');
await expect(page.locator('#nm-config-options')).not.toBeHidden();
// Enable all options for testing
await page.check('input[name="nm-cfg-simplify-tabs"]');
await page.check('input[name="nm-cfg-simplify-home"]');
await page.click('#btn-nm-next');
// Review step
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-list')).toContainText('NickelMenu');
await expect(page.locator('#nm-review-list')).toContainText('Readerly fonts');
await expect(page.locator('#nm-review-list')).toContainText('Simplified tab menu');
await expect(page.locator('#nm-review-list')).toContainText('Simplified homescreen');
// Both buttons visible when device is connected
await expect(page.locator('#btn-nm-write')).toBeVisible();
await expect(page.locator('#btn-nm-download')).toBeVisible();
// Write to device
await page.click('#btn-nm-write');
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('#nm-done-status')).toContainText('installed');
await expect(page.locator('#nm-write-instructions')).not.toBeHidden();
// Verify files written to mock device
const writtenFiles = await getWrittenFiles(page);
expect(writtenFiles, 'KoboRoot.tgz should be written').toContainEqual(expect.stringContaining('KoboRoot.tgz'));
expect(writtenFiles, 'NickelMenu items should be written').toContainEqual(expect.stringContaining('items'));
// Verify eReader.conf was updated with ExcludeSyncFolders
const conf = await readMockFile(page, '.kobo', 'Kobo', 'Kobo eReader.conf');
expect(conf, 'eReader.conf should contain ExcludeSyncFolders').toContain('ExcludeSyncFolders');
expect(conf, 'eReader.conf should preserve existing settings').toContain('[General]');
// Verify NickelMenu items file exists and has expected modifications
const items = await readMockFile(page, '.adds', 'nm', 'items');
expect(items, '.adds/nm/items should exist').not.toBeNull();
// With simplifyHome enabled, the hide lines should be appended
expect(items).toContain('experimental:hide_home_row1col2_enabled:1');
expect(items).toContain('experimental:hide_home_row3_enabled:1');
});
test('with device — remove NickelMenu', async ({ page }) => {
test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot');
await connectMockDevice(page, { hasNickelMenu: true });
// Continue to mode selection
await page.click('#btn-device-next');
await page.click('#btn-mode-next');
// NickelMenu configure step
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
// Remove option should be enabled (NickelMenu is installed)
await expect(page.locator('#nm-option-remove')).not.toHaveClass(/nm-option-disabled/);
await expect(page.locator('input[name="nm-option"][value="remove"]')).not.toBeDisabled();
// Select remove
await page.click('input[name="nm-option"][value="remove"]');
await page.click('#btn-nm-next');
// Review step
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await expect(page.locator('#nm-review-summary')).toContainText('removal');
// Download should be hidden for remove
await expect(page.locator('#btn-nm-download')).toBeHidden();
// Write should show "Remove from Kobo"
await expect(page.locator('#btn-nm-write')).toContainText('Remove from Kobo');
// Execute removal
await page.click('#btn-nm-write');
await expect(page.locator('#step-nm-done')).toBeVisible({ timeout: 30_000 });
await expect(page.locator('#nm-done-status')).toContainText('removed');
await expect(page.locator('#nm-reboot-instructions')).not.toBeHidden();
// Verify files written to mock device
const writtenFiles = await getWrittenFiles(page);
expect(writtenFiles, 'KoboRoot.tgz should be written for update').toContainEqual(expect.stringContaining('KoboRoot.tgz'));
expect(writtenFiles, 'uninstall marker should be written').toContainEqual(expect.stringContaining('uninstall'));
// Verify the uninstall marker file exists
const uninstallExists = await mockPathExists(page, '.adds', 'nm', 'uninstall');
expect(uninstallExists, '.adds/nm/uninstall should exist').toBe(true);
});
});
// ============================================================
// Custom patches
// ============================================================
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}`);
setupFirmwareSymlink();
await goToManualMode(page);
// Select "Custom Patches" mode
await page.click('input[name="mode"][value="patches"]');
await page.click('#btn-mode-next');
// Manual version/model selection
await expect(page.locator('#step-manual-version')).not.toBeHidden();
await overrideFirmwareURLs(page);
// Select firmware version
await page.selectOption('#manual-version', '4.45.23646');
await expect(page.locator('#manual-model')).not.toBeHidden();
// 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');
// 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);
// 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
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
await expect(page.locator('#btn-patches-next')).toBeEnabled();
// 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');
// 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');
// 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('no device — restore original firmware', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink();
await goToManualMode(page);
// Select "Custom Patches" mode
await page.click('input[name="mode"][value="patches"]');
await page.click('#btn-mode-next');
// Manual version/model selection
await expect(page.locator('#step-manual-version')).not.toBeHidden();
await overrideFirmwareURLs(page);
await page.selectOption('#manual-version', '4.45.23646');
await page.selectOption('#manual-model', 'N428');
await page.click('#btn-manual-confirm');
// Wait for patches to load, then continue with zero patches
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');
// 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');
// 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');
// 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);
});
test('no device — both modes available in manual mode', async ({ page }) => {
await page.goto('/');
await page.click('#btn-manual');
await expect(page.locator('#step-mode')).not.toBeHidden();
// Both modes should be available in manual mode
await expect(page.locator('input[name="mode"][value="patches"]')).not.toBeDisabled();
await expect(page.locator('input[name="mode"][value="nickelmenu"]')).not.toBeDisabled();
});
test('with device — apply patches and verify checksums', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink();
// Override firmware URLs BEFORE connecting so the app captures the local URL
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });
// Continue to mode selection
await page.click('#btn-device-next');
await expect(page.locator('#step-mode')).not.toBeHidden();
// Both modes should be available (firmware is supported)
await expect(page.locator('input[name="mode"][value="patches"]')).not.toBeDisabled();
// Select Custom Patches
await page.click('input[name="mode"][value="patches"]');
await page.click('#btn-mode-next');
// Patches step (patches should already be loaded from device detection)
await expect(page.locator('#step-patches')).not.toBeHidden();
await expect(page.locator('#patch-container .patch-file-section')).not.toHaveCount(0);
// Enable a patch
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();
await expect(page.locator('#patch-count-hint')).toContainText('1 patch selected');
await page.click('#btn-patches-next');
// Build step
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');
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');
// Both write and download should be visible with device connected
await expect(page.locator('#btn-write')).toBeVisible();
await expect(page.locator('#btn-download')).toBeVisible();
// Download 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);
}
});
test('with device — restore original firmware', async ({ page }) => {
test.skip(!fs.existsSync(FIRMWARE_PATH), `Firmware not found at ${FIRMWARE_PATH}`);
setupFirmwareSymlink();
// Override firmware URLs BEFORE connecting so the app captures the local URL
await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true });
// Use the "Restore Unpatched Software" shortcut button on device screen
await page.click('#btn-device-restore');
// Build step should show restore mode
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');
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');
// Download and verify 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);
});
});