From 57f3811932c1d0910a93226ac3c65e4a88948e06 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Thu, 19 Mar 2026 19:12:58 +0100 Subject: [PATCH] Allow going back after failed patch --- tests/e2e/integration.spec.js | 79 ++++++++++++++++++++++++++++++++++ tests/e2e/playwright.config.js | 2 +- web/src/css/style.css | 15 +++++++ web/src/index.html | 10 +++-- web/src/js/app.js | 41 +++++++++++++++++- web/src/js/patch-runner.js | 3 ++ 6 files changed, 144 insertions(+), 6 deletions(-) diff --git a/tests/e2e/integration.spec.js b/tests/e2e/integration.spec.js index 9d8e460..8f386a0 100644 --- a/tests/e2e/integration.spec.js +++ b/tests/e2e/integration.spec.js @@ -870,4 +870,83 @@ test.describe('Custom patches', () => { 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(!fs.existsSync(FIRMWARE_PATH), `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(); + + // Go Back 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(!fs.existsSync(FIRMWARE_PATH), `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 — verify Go Back works + await expect(page.locator('#error-message')).toContainText('Build failed'); + await expect(page.locator('#btn-error-back')).toBeVisible(); + 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); + } + }); }); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index a5d2621..f7d46f8 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -17,7 +17,7 @@ module.exports = defineConfig({ }, }, webServer: { - command: 'cd ../../web && node build.mjs && cd ../kobopatch-wasm && bash build.sh && cd ../web && python3 -m http.server -d dist 8889', + command: 'cd ../../web && npm install && node build.mjs && cd ../kobopatch-wasm && bash build.sh && cd ../web && python3 -m http.server -d dist 8889', port: 8889, reuseExistingServer: true, }, diff --git a/web/src/css/style.css b/web/src/css/style.css index 7d0630b..c0f5d84 100644 --- a/web/src/css/style.css +++ b/web/src/css/style.css @@ -472,6 +472,17 @@ button.secondary:hover { border-color: #9ca3af; } +button.danger { + background: #fff; + color: var(--error-text); + border-color: var(--error-border); +} + +button.danger:hover { + background: var(--error-bg); + border-color: var(--error-text); +} + button:disabled { opacity: 0.4; cursor: not-allowed; @@ -564,6 +575,10 @@ button.btn-success:hover { font-size: 0.88rem; } +#error-message { + margin-top: 1rem; +} + .status-supported { background: var(--success-bg); border: 1px solid var(--success-border); diff --git a/web/src/index.html b/web/src/index.html index 41e8cb8..2ddeb89 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -364,10 +364,14 @@ diff --git a/web/src/js/app.js b/web/src/js/app.js index b295e1d..7897e2a 100644 --- a/web/src/js/app.js +++ b/web/src/js/app.js @@ -101,9 +101,12 @@ import JSZip from 'jszip'; const btnWrite = $('btn-write'); const btnDownload = $('btn-download'); const btnRetry = $('btn-retry'); + const btnErrorBack = $('btn-error-back'); const errorMessage = $('error-message'); const errorLog = $('error-log'); + const errorTitle = $('error-title'); + const errorHint = $('error-hint'); const deviceStatus = $('device-status'); const deviceUnknownWarning = $('device-unknown-warning'); const deviceUnknownAck = $('device-unknown-ack'); @@ -165,6 +168,10 @@ import JSZip from 'jszip'; stepNav.hidden = true; } + function showNav() { + stepNav.hidden = false; + } + // --- Mode selection card interactivity --- function setupCardRadios(container, selectedClass) { const labels = $qa('label', container); @@ -435,6 +442,7 @@ import JSZip from 'jszip'; populateSelect(manualModel, '-- Select your Kobo model --', []); manualModel.hidden = true; btnManualConfirm.disabled = true; + setNavStep(2); showStep(stepManualVersion); } @@ -640,6 +648,7 @@ import JSZip from 'jszip'; btnPatchesBack.addEventListener('click', () => { if (manualMode) { // Go back to version selection in manual mode + setNavStep(2); showStep(stepManualVersion); } else { goToModeSelection(); @@ -836,7 +845,7 @@ import JSZip from 'jszip'; showBuildResult(); await checkExistingTgz(); } catch (err) { - showError('Build failed: ' + err.message, buildLog.textContent); + showError('Build failed: ' + err.message, buildLog.textContent, stepPatches); } }); @@ -874,18 +883,46 @@ import JSZip from 'jszip'; }); // --- Error / Retry --- - function showError(message, log) { + function showError(message, log, backStep) { errorMessage.textContent = message; if (log) { errorLog.textContent = log; errorLog.hidden = false; + requestAnimationFrame(() => { + errorLog.scrollTop = errorLog.scrollHeight; + }); } else { errorLog.hidden = true; } + if (backStep) { + errorTitle.textContent = 'The patch failed to apply'; + errorHint.hidden = false; + btnErrorBack.hidden = false; + btnErrorBack._backStep = backStep; + btnRetry.classList.add('danger'); + } else { + errorTitle.textContent = 'Something went wrong'; + errorHint.hidden = true; + btnErrorBack.hidden = true; + btnErrorBack._backStep = null; + btnRetry.classList.remove('danger'); + } hideNav(); showStep(stepError); } + btnErrorBack.addEventListener('click', () => { + btnErrorBack.hidden = true; + btnRetry.classList.remove('danger'); + showNav(); + if (manualMode) { + setNavStep(2); + showStep(stepManualVersion); + } else { + goToModeSelection(); + } + }); + btnRetry.addEventListener('click', () => { device.disconnect(); firmwareURL = null; diff --git a/web/src/js/patch-runner.js b/web/src/js/patch-runner.js index b729347..cba0748 100644 --- a/web/src/js/patch-runner.js +++ b/web/src/js/patch-runner.js @@ -52,4 +52,7 @@ class KoboPatchRunner { } } +// Expose on window for E2E test compatibility +window.KoboPatchRunner = KoboPatchRunner; + export { KoboPatchRunner };