// @ts-check const { test, expect } = require('@playwright/test'); const fs = require('fs'); const crypto = require('crypto'); const zlib = require('zlib'); const JSZip = require('jszip'); const { FIRMWARE_PATH, EXPECTED_SHA1, ORIGINAL_TGZ_SHA1 } = require('./helpers/paths'); const { hasNickelMenuAssets, hasKoreaderAssets, hasReaderlyAssets, hasFirmwareZip, setupFirmwareSymlink, cleanupFirmwareSymlink } = require('./helpers/assets'); const { injectMockDevice, connectMockDevice, overrideFirmwareURLs, goToManualMode, readMockFile, mockPathExists, getWrittenFiles } = require('./helpers/mock-device'); const { parseTar } = require('./helpers/tar'); test.afterEach(() => { cleanupFirmwareSymlink(); }); // ============================================================ // NickelMenu // ============================================================ test.describe('NickelMenu', () => { test('no device — install with config via manual download', async ({ page }) => { test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); test.skip(!hasReaderlyAssets(), 'Readerly assets not found (run readerly/setup.sh)'); 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="preset"]'); await page.click('#btn-nm-next'); // Feature selection step await expect(page.locator('#step-nm-features')).not.toBeHidden(); // Verify default checkbox states await expect(page.locator('input[name="nm-cfg-readerly-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-hide-recommendations"]')).not.toBeChecked(); await expect(page.locator('input[name="nm-cfg-hide-notices"]')).not.toBeChecked(); await expect(page.locator('input[name="nm-cfg-koreader"]')).not.toBeChecked(); // Enable both home screen hiding options for testing await page.check('input[name="nm-cfg-hide-recommendations"]'); await page.check('input[name="nm-cfg-hide-notices"]'); await page.click('#btn-nm-features-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 const [download] = await Promise.all([ page.waitForEvent('download'), 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(); // Verify ZIP contents 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); // Must contain KoboRoot.tgz expect(zipFiles).toContainEqual('.kobo/KoboRoot.tgz'); // Must contain NickelMenu items config expect(zipFiles).toContainEqual('.adds/nm/items'); // Must contain Readerly .ttf font files (readerly-fonts is checked by default) const fontFiles = zipFiles.filter(f => f.startsWith('fonts/') && f.endsWith('.ttf')); expect(fontFiles.length).toBeGreaterThan(0); // Must NOT contain screensaver (unchecked by default) expect(zipFiles.some(f => f.startsWith('.kobo/screensaver/'))).toBe(false); // Verify items file has hide-recommendations and hide-notices modifications const itemsContent = await zip.file('.adds/nm/items').async('string'); expect(itemsContent).toContain('experimental:hide_home_row1col2_enabled:1'); 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(!hasReaderlyAssets(), 'Readerly assets not found (run readerly/setup.sh)'); 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="preset"]'); await page.click('#btn-nm-next'); // Feature selection step await expect(page.locator('#step-nm-features')).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-features-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); // KOReader launcher should be at the top of the items file const itemsContent = await zip.file('.adds/nm/items').async('string'); expect(itemsContent.startsWith('menu_item:main: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(!hasReaderlyAssets(), 'Readerly assets not found (run readerly/setup.sh)'); 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="preset"]'); await page.click('#btn-nm-next'); // Feature selection step await expect(page.locator('#step-nm-features')).not.toBeHidden(); // Enable KOReader await page.check('input[name="nm-cfg-koreader"]'); await page.click('#btn-nm-features-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'); await goToManualMode(page); await page.click('#btn-mode-next'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // Select "Install NickelMenu only" — goes directly to review (no features step) await page.click('input[name="nm-option"][value="nickelmenu-only"]'); 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 const [download] = await Promise.all([ page.waitForEvent('download'), 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(); // Verify ZIP contents — should only contain KoboRoot.tgz 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).filter(f => !zip.files[f].dir); expect(zipFiles).toEqual(['.kobo/KoboRoot.tgz']); }); 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'); test.skip(!hasReaderlyAssets(), 'Readerly assets not found (run readerly/setup.sh)'); 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="preset"]'); await page.click('#btn-nm-next'); // Feature selection step await expect(page.locator('#step-nm-features')).not.toBeHidden(); // Enable all options for testing await page.check('input[name="nm-cfg-simplify-tabs"]'); await page.check('input[name="nm-cfg-hide-recommendations"]'); await page.check('input[name="nm-cfg-hide-notices"]'); await page.click('#btn-nm-features-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('Simplify navigation tabs'); await expect(page.locator('#nm-review-list')).toContainText('Hide home screen recommendations'); await expect(page.locator('#nm-review-list')).toContainText('Hide home screen notices'); // 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 Readerly font files were written (readerly-fonts is on by default) const fontFiles = writtenFiles.filter(f => f.includes('fonts/') && f.endsWith('.ttf')); expect(fontFiles.length, 'Readerly .ttf fonts should be written').toBeGreaterThan(0); // 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 hide-recommendations and hide-notices 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 — install NickelMenu only 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 page.click('#btn-mode-next'); // NickelMenu configure step await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // Select "Install NickelMenu only" — goes directly to review (no features step) await page.click('input[name="nm-option"][value="nickelmenu-only"]'); 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)'); // 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'); // Verify only KoboRoot.tgz was written (no config files) const writtenFiles = await getWrittenFiles(page); expect(writtenFiles).toContainEqual(expect.stringContaining('KoboRoot.tgz')); // Should NOT have written items, fonts, etc. expect(writtenFiles.filter(f => !f.includes('KoboRoot.tgz'))).toHaveLength(0); }); 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"]'); // No extra features installed — uninstall options should be hidden await expect(page.locator('#nm-uninstall-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-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); }); test('with device — remove NickelMenu with feature cleanup', async ({ page }) => { test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); await connectMockDevice(page, { hasNickelMenu: true, hasKoreader: true, hasReaderlyFonts: true, hasScreensaver: true, }); await page.click('#btn-device-next'); await page.click('#btn-mode-next'); // Select remove await page.click('input[name="nm-option"][value="remove"]'); // Uninstall checkboxes should appear for all 3 detected features await expect(page.locator('#nm-uninstall-options')).not.toBeHidden(); await expect(page.locator('input[name="nm-uninstall-koreader"]')).toBeChecked(); await expect(page.locator('input[name="nm-uninstall-readerly-fonts"]')).toBeChecked(); await expect(page.locator('input[name="nm-uninstall-screensaver"]')).toBeChecked(); // Uncheck screensaver (keep it) await page.uncheck('input[name="nm-uninstall-screensaver"]'); await page.click('#btn-nm-next'); // Review should list KOReader and Readerly but not Screensaver await expect(page.locator('#nm-review-summary')).toContainText('removal'); await expect(page.locator('#nm-review-list')).toContainText('KOReader'); await expect(page.locator('#nm-review-list')).toContainText('Readerly'); await expect(page.locator('#nm-review-list')).not.toContainText('Screensaver'); // 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'); // NickelMenu uninstall marker should exist expect(await mockPathExists(page, '.adds', 'nm', 'uninstall')).toBe(true); // KOReader directory should be removed expect(await mockPathExists(page, '.adds', 'koreader')).toBe(false); // Readerly fonts should be removed expect(await mockPathExists(page, 'fonts', 'KF_Readerly-Regular.ttf')).toBe(false); expect(await mockPathExists(page, 'fonts', 'KF_Readerly-Bold.ttf')).toBe(false); // Screensaver should NOT be removed (unchecked) expect(await mockPathExists(page, '.kobo', 'screensaver', 'moon.png')).toBe(true); }); test('with device — remove NickelMenu, go back, checklist preserved', async ({ page }) => { test.skip(!hasNickelMenuAssets(), 'NickelMenu assets not found in webroot'); await connectMockDevice(page, { hasNickelMenu: true, hasKoreader: true, hasReaderlyFonts: true, }); await page.click('#btn-device-next'); await page.click('#btn-mode-next'); // NickelMenu configure step await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // Select remove await page.click('input[name="nm-option"][value="remove"]'); // Uninstall checkboxes should appear await expect(page.locator('#nm-uninstall-options')).not.toBeHidden(); await expect(page.locator('input[name="nm-uninstall-koreader"]')).toBeChecked(); await expect(page.locator('input[name="nm-uninstall-readerly-fonts"]')).toBeChecked(); // Uncheck one option await page.uncheck('input[name="nm-uninstall-readerly-fonts"]'); 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'); // Go back await page.click('#btn-nm-review-back'); // Checklist should still be visible with preserved state await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); await expect(page.locator('#nm-uninstall-options')).not.toBeHidden(); await expect(page.locator('input[name="nm-uninstall-koreader"]')).toBeChecked(); // Readerly should still be unchecked (state preserved) await expect(page.locator('input[name="nm-uninstall-readerly-fonts"]')).not.toBeChecked(); }); }); // ============================================================ // Custom patches // ============================================================ test.describe('Custom patches', () => { test('no device — full manual mode patching pipeline', async ({ page }) => { test.skip(!hasFirmwareZip(), `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(!hasFirmwareZip(), `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('with device — incompatible version 5.x shows error', async ({ page }) => { await page.goto('/'); await injectMockDevice(page, { firmware: '5.0.0' }); await page.click('#btn-connect'); // Device info should be displayed 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('5.0.0'); // Status message should show incompatibility warning await expect(page.locator('#device-status')).toContainText('incompatible'); await expect(page.locator('#device-status')).toContainText('NickelMenu does not support it'); await expect(page.locator('#device-status')).toHaveClass(/error/); // Continue and restore buttons should be hidden await expect(page.locator('#btn-device-next')).toBeHidden(); await expect(page.locator('#btn-device-restore')).toBeHidden(); }); test('with device — unknown model shows warning and requires checkbox', async ({ page }) => { await page.goto('/'); await injectMockDevice(page, { serial: 'X9990A0000000' }); await page.click('#btn-connect'); // Device info should be displayed with unknown model await expect(page.locator('#step-device')).not.toBeHidden(); await expect(page.locator('#device-model')).toContainText('Unknown'); await expect(page.locator('#device-firmware')).toHaveText('4.45.23646'); // Warning should be visible with GitHub link await expect(page.locator('#device-unknown-warning')).not.toBeHidden(); await expect(page.locator('#device-unknown-warning')).toContainText('file an issue on GitHub'); await expect(page.locator('#device-unknown-warning a')).toHaveAttribute('href', 'https://github.com/nicoverbruggen/kobopatch-webui/issues/new'); // Checkbox should be visible, Continue should be disabled await expect(page.locator('#device-unknown-ack')).not.toBeHidden(); await expect(page.locator('#btn-device-next')).toBeVisible(); await expect(page.locator('#btn-device-next')).toBeDisabled(); // Restore Software should be hidden (no firmware URL for unknown model) await expect(page.locator('#btn-device-restore')).toBeHidden(); // Checking the checkbox enables Continue await page.check('#device-unknown-checkbox'); await expect(page.locator('#btn-device-next')).toBeEnabled(); // Custom patches should be disabled in mode selection (no firmware URL) await page.click('#btn-device-next'); await expect(page.locator('#step-mode')).not.toBeHidden(); await expect(page.locator('input[name="mode"][value="patches"]')).toBeDisabled(); }); 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(!hasFirmwareZip(), `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(!hasFirmwareZip(), `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); }); test('with device — build failure shows Go Back and returns to patches', async ({ page }) => { test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true }); // Select Custom Patches await page.click('#btn-device-next'); await page.click('input[name="mode"][value="patches"]'); await page.click('#btn-mode-next'); // 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 patchName.locator('xpath=ancestor::label').locator('input').check(); await page.click('#btn-patches-next'); // Mock the WASM patcher to simulate a failure await page.evaluate(() => { KoboPatchRunner.prototype.patchFirmware = async function () { throw new Error('Patch failed to apply: symbol not found'); }; }); // Build — should fail due to mock await page.click('#btn-build'); await expect(page.locator('#step-error')).not.toBeHidden({ timeout: 30_000 }); await expect(page.locator('#error-message')).toContainText('Build failed'); await expect(page.locator('#btn-error-back')).toBeVisible(); // "Select different patches" should return to patches step await page.click('#btn-error-back'); await expect(page.locator('#step-patches')).not.toBeHidden(); }); test('with device — real patch failure with Go Back (Allow rotation)', async ({ page }) => { test.skip(!hasFirmwareZip(), `Firmware not found at ${FIRMWARE_PATH}`); setupFirmwareSymlink(); await connectMockDevice(page, { hasNickelMenu: false, overrideFirmware: true }); // Select Custom Patches await page.click('#btn-device-next'); await page.click('input[name="mode"][value="patches"]'); await page.click('#btn-mode-next'); // Enable "Allow rotation on all devices" — marked as not working on 4.45.23646 const patchName = page.locator('.patch-name', { hasText: 'Allow rotation on all devices' }).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 page.click('#btn-patches-next'); // Build 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') { // Build failed — "Select different patches" should return to patches step await page.click('#btn-error-back'); await expect(page.locator('#step-patches')).not.toBeHidden(); } else { // Build succeeded — check if the patch was skipped const logText = await page.locator('#build-log').textContent(); console.log('Build log:', logText); const hasSkip = logText.includes('SKIP') && logText.includes('Allow rotation on all devices'); expect(hasSkip, 'Expected "Allow rotation" to be skipped or fail').toBe(true); } }); test('with device — back navigation through auto mode flow', async ({ page }) => { await page.goto('/'); await injectMockDevice(page); await page.click('#btn-connect'); // Step 1: Device await expect(page.locator('#step-device')).not.toBeHidden(); // Device → Mode await page.click('#btn-device-next'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → Patches await page.click('input[name="mode"][value="patches"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-patches')).not.toBeHidden(); // Patches → Back → Mode await page.click('#btn-patches-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → NickelMenu config await page.click('input[name="mode"][value="nickelmenu"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // NM config → Back → Mode await page.click('#btn-nm-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → NM config → Continue (nickelmenu-only) → NM review await page.click('input[name="mode"][value="nickelmenu"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); await page.click('input[value="nickelmenu-only"]'); await page.click('#btn-nm-next'); await expect(page.locator('#step-nm-review')).not.toBeHidden(); // NM review → Back → NM config (skips features for nickelmenu-only) await page.click('#btn-nm-review-back'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // NM config → select preset → Continue → Features step await page.click('input[value="preset"]'); await page.click('#btn-nm-next'); await expect(page.locator('#step-nm-features')).not.toBeHidden(); // Features → Continue → NM review await page.click('#btn-nm-features-next'); await expect(page.locator('#step-nm-review')).not.toBeHidden(); // NM review → Back → Features (for preset) await page.click('#btn-nm-review-back'); await expect(page.locator('#step-nm-features')).not.toBeHidden(); // Features → Back → NM config await page.click('#btn-nm-features-back'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // NM config → Back → Mode await page.click('#btn-nm-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → Back → Device await page.click('#btn-mode-back'); await expect(page.locator('#step-device')).not.toBeHidden(); }); test('no device — back navigation through manual mode flow', async ({ page }) => { await page.goto('/'); await goToManualMode(page); // Step 1: Mode await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → Patches → Version selection await page.click('input[name="mode"][value="patches"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-manual-version')).not.toBeHidden(); // Version → Back → Mode await page.click('#btn-manual-version-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → NickelMenu config await page.click('input[name="mode"][value="nickelmenu"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-nickelmenu')).not.toBeHidden(); // NM config → Back → Mode await page.click('#btn-nm-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → Patches → Version selection await page.click('input[name="mode"][value="patches"]'); await page.click('#btn-mode-next'); await expect(page.locator('#step-manual-version')).not.toBeHidden(); // Select version and model, confirm await page.selectOption('#manual-version', '4.45.23646'); await page.locator('#manual-model').waitFor({ state: 'visible' }); await page.selectOption('#manual-model', 'N428'); await page.click('#btn-manual-confirm'); await expect(page.locator('#step-patches')).not.toBeHidden(); // Patches → Back → Version await page.click('#btn-patches-back'); await expect(page.locator('#step-manual-version')).not.toBeHidden(); // Version → Back → Mode await page.click('#btn-manual-version-back'); await expect(page.locator('#step-mode')).not.toBeHidden(); // Mode → Back → Connect await page.click('#btn-mode-back'); await expect(page.locator('#step-connect')).not.toBeHidden(); }); });