diff --git a/CHANGELOG.md b/CHANGELOG.md index 765eeb4..74a20f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Hack Engine are documented here. +## [1.3.5] - 2026-09-29 + +- Unify Simple and Advanced into one scan workflow with expandable Scan options. +- Keep active scan settings visible while options are collapsed. +- Share candidate filtering, sorting, watches, and one value editor across popup, sidebar, and pop-out. +- Preserve scan configuration during refinement and refresh the object-picker summary. +- Prevent paused Ruffle games from resuming through player-overlay input. +- Preserve newer scans during asynchronous reset cleanup and background state recovery. + ## [1.3.0] - 2026-09-27 Not published to browser stores. Game pause controls and automatic pause during scanning. diff --git a/README.md b/README.md index a835f1e..8c2e57a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Hack Engine helps you find, watch, and change accessible numeric values in WebAs Everything happens locally in the inspected tab. Hack Engine has no accounts, telemetry, advertising, or remote service. -> Current release: **v1.3.0 release candidate — not published**. Core workflows are tested locally on Linux Firefox and Chromium; the remaining release gates are recorded in [IMPLEMENTATION_1_0.md](IMPLEMENTATION_1_0.md). +> Current release: **v1.3.5 release candidate — awaiting validation and store review**. The current release checks cover the controlled eight-game matrix and packaged browser regressions described in [test/GAME_TESTING.md](test/GAME_TESTING.md). Historical 1.0 qualification notes remain in [IMPLEMENTATION_1_0.md](IMPLEMENTATION_1_0.md). ## What you can do @@ -13,7 +13,7 @@ Everything happens locally in the inspected tab. Hack Engine has no accounts, te - **Recover mistakes:** Undo one refinement, restore the last write when the game has not changed it, and stop all freezes. - **Edit and freeze:** Replace a discovered value or keep it fixed while the game runs. - **Watch values live:** Keep useful candidates visible as they change and give each watch a descriptive label. -- **Start simple, go deeper:** Use Quick scan for the common workflow, then open Advanced controls when you need more options. +- **Start simple, go deeper:** Scan with Automatic defaults, then expand **Scan options** when you need more control. - **Keep one shared workspace:** Candidates, watches, selections, and freezes stay synchronized between the toolbar, sidebar, and pop-out. ## How to use Hack Engine @@ -24,11 +24,11 @@ Everything happens locally in the inspected tab. Hack Engine has no accounts, te 4. Change that value in the game, enter the new value, and choose **Next scan**. 5. Repeat until only a small number of candidates remain, then select one to watch, edit, or freeze it. -If the exact value is not known, start with **Unknown initial value** and refine after the game changes. **Value range** helps with rounded or approximate values. Advanced mode also provides explicit number-format, alignment, multiplier, and inspection-source controls. Add known addresses, select individual candidates to watch, edit each watch's label, and sort addresses or values in either direction. Write feedback follows verification through 250 ms; expandable details distinguish verification from game restoration or failed reads. +If the exact value is not known, start with **Unknown initial value** and refine after the game changes. **Value range** helps with rounded or approximate values. Expand **Scan options** to choose a number format, alignment, or JavaScript object. Non-default settings remain visible in the collapsed summary. The toolbar, sidebar, and pop-out all provide **Candidates** and **Watches**, filtering, sorting, and one selected-value editor. Select individual candidates to watch, edit each watch's label, and sort addresses or values in either direction. Write feedback follows verification through 250 ms; expandable details distinguish verification from game restoration or failed reads. ## JavaScript games -Choose **JavaScript objects** as the source and scan normally. Advanced controls offer an object picker to narrow discovery. Results show property paths instead of memory addresses. If discovery reaches a limit, the panel reports partial coverage; choose a narrower object and scan again. A replaced object makes its old watches unavailable rather than redirecting writes. +Choose **JavaScript objects** as the source and scan normally. Expand **Scan options** to use the object picker and narrow discovery. Results show property paths instead of memory addresses. If discovery reaches a limit, the panel reports partial coverage; choose a narrower object and scan again. A replaced object makes its old watches unavailable rather than redirecting writes. ## Browser support @@ -62,7 +62,7 @@ Reload the game page after loading the extension so Hack Engine can detect the p ## Good to know - Hack Engine searches captured WebAssembly memory and reachable JavaScript object properties. Private variables, worker state, encoded values, and server-controlled state are outside this release. See [compatibility](COMPATIBILITY.md) for tested coverage and limits. -- A displayed number may be rounded, scaled, copied, or recalculated by the game. Range scans, comparison scans, and Advanced mode can help identify the useful value. +- A displayed number may be rounded, scaled, copied, or recalculated by the game. Range scans, comparison scans, and additional scan options can help identify the useful value. - Editing the wrong address can reset or crash the embedded player. Use Hack Engine only with games and software you own or are authorized to inspect. ## Planned features diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 793bde5..5e314ea 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -11,15 +11,15 @@ Hack Engine finds, watches, and edits accessible numeric values in WebAssembly a 5. Select a candidate. Selection adds it to the shared watch list automatically. 6. Enter a replacement and choose **Write value**. Use **Freeze** only when the game repeatedly restores the address. -## Persistent and advanced views +## One workflow across popup, sidebar, and pop-out -The toolbar popup closes when focus returns to the page. Use the pin to open the persistent sidebar. The sidebar's **Advanced** view adds explicit number format, alignment, and inspection-source controls plus filtering, sorting, and watches. +The toolbar popup closes when focus returns to the page. Use the pin to open the persistent sidebar. Every surface uses the same scan form, with Automatic defaults and expandable **Scan options** for number format, alignment, and the JavaScript object picker. Non-default settings remain visible in the collapsed summary. Expanding or collapsing these options preserves the current scan and results. -Both views show candidates with recommended variable types first: Float64 for AVM1, or Int32, Uint32, then Float64 for AVM2. WebAssembly prioritizes Int32, Uint32, Float32, then Float64. Candidates within each priority are ordered by address; when Ruffle AVM is unknown, candidates are ordered by address. Advanced defaults to **Recommended types**, with ascending/descending Address and Value sorting and Type sorting available. Counts distinguish displayed preview rows from all scan matches. Simple always uses the recommended order. +Candidates default to recommended variable types first: Float64 for AVM1, or Int32, Uint32, then Float64 for AVM2. WebAssembly prioritizes Int32, Uint32, Float32, then Float64. Candidates within each priority are ordered by address; when Ruffle AVM is unknown, candidates are ordered by address. The **Candidates** and **Watches** tabs, filtering, and sorting are available in the toolbar, sidebar, and pop-out. Sorting defaults to **Recommended types**, with ascending/descending Address and Value sorting and Type sorting available. Counts distinguish displayed preview rows from all scan matches; the candidate preview shows up to 200 rows. Select a candidate or watch to use the same value editor. AVM detection uses only players linked to the selected memory through Ruffle's metadata callback. Unknown types are checked once per second for up to 15 retries; detection stops early on success and updates the runtime hints automatically. A new movie's metadata event starts a fresh retry budget. Existing scan results are retained. Some Ruffle players share one memory: if that memory contains both AVM1 and AVM2, or ownership cannot be established, it stays **Unknown** and Automatic searches all numeric types. -The toolbar, sidebar, and pop-out share the inspected tab's scan, candidates, watches, primary selection, and freeze state. Advanced controls provide the complete supported workflow; there is no separate inspector or DevTools entry. +The toolbar, sidebar, and pop-out share the inspected tab's scan, candidates, watches, primary selection, and freeze state. Each surface provides the complete supported workflow; there is no separate inspector or DevTools entry. ### Known addresses and watch labels @@ -30,7 +30,7 @@ A live session supports up to 256 watches. Each watch can be edited or frozen in ## Pausing the game -Use **Pause game** to suspend a supported Ruffle game, then **Resume game** to continue. The controls are available in both Simple and Advanced views. If a Ruffle memory is shared by multiple associated players, pausing that source pauses all of those players. +Use **Pause game** to suspend a supported Ruffle game, then **Resume game** to continue. The controls are available in the toolbar, sidebar, and pop-out. If a Ruffle memory is shared by multiple associated players, pausing that source pauses all of those players. Enable **Pause while scanning** to pause during first scans, refinements, and searches across all number formats. The preference is saved. A game that was running resumes when the scan completes, fails, or is cancelled; a game already paused stays paused. Manual pause remains active when you close the popup, so reopen the controls to resume. Disconnecting the page bridge or leaving the page releases pauses owned by Hack Engine. @@ -38,7 +38,7 @@ Pause requires a Ruffle player linked to the selected memory with a supported pl ## Numeric formats -If the Simple scan does not find the value, try **All numeric types** in Advanced. Common Ruffle representations include `Float64` for AVM1 numbers and `Int32`, `Uint32`, or `Float64` for AVM2 values. **Any byte** alignment is slower but can find unaligned values. +If an Automatic scan does not find the value, try **Search all number formats** after an exact/range scan, or reset the scan and choose **All numeric types** in **Scan options**. Common Ruffle representations include `Float64` for AVM1 numbers and `Int32`, `Uint32`, or `Float64` for AVM2 values. **Any byte** alignment is slower but can find unaligned values. ## Why a displayed value may not appear @@ -76,7 +76,7 @@ Scans are limited to captured memories of at most 256 MiB. Snapshot scans check ## JavaScript discovery -Select **JavaScript objects** for reachable numeric own properties in plain objects, arrays, and numeric typed arrays. First scan discovers available values; subsequent scans filter those same live properties. Advanced offers an object picker; it accepts selections, never executable expressions. Number format, alignment, and scaling apply only to WebAssembly. +Select **JavaScript objects** for reachable numeric own properties in plain objects, arrays, and numeric typed arrays. First scan discovers available values; subsequent scans filter those same live properties. **Scan options** offers an object picker; it accepts selections, never executable expressions. Alignment applies only to WebAssembly. Discovery skips ordinary getters and browser/DOM internals. JavaScript Proxy inspection traps can still execute; this is not an isolated debugger. Closures, module-private state, class instances, Map/Set contents, BigInt, workers, and server state are not searched. @@ -86,6 +86,6 @@ Read-only values can be watched but cannot be edited. Typed-array writes must fi ### Targeted number formats -In Advanced, **Number format** controls the first scan. WebAssembly Automatic starts with Int32, Uint32, Float32 and Float64; decimal searches use Float32 and Float64. These are heuristic starting formats, not detected source-language types. Choose **All numeric types** (or **Search all number formats** after an exact/range scan) to include 8-bit and 16-bit integers. Individual formats remain selectable. Unknown Ruffle runtimes still search all formats. +In **Scan options**, **Number format** controls the first scan. WebAssembly Automatic starts with Int32, Uint32, Float32 and Float64; decimal searches use Float32 and Float64. These are heuristic starting formats, not detected source-language types. Choose **All numeric types** (or **Search all number formats** after an exact/range scan) to include 8-bit and 16-bit integers. Individual formats remain selectable. Unknown Ruffle runtimes still search all formats. For JavaScript, Automatic and All numeric types search all reachable finite numbers. **Number properties** targets ordinary object and array properties. The typed-array choices target actual element storage, such as Float32Array or Int32Array; Uint8 also includes Uint8ClampedArray. A whole-valued ordinary JavaScript Number is still a Number property, not an Int32 element. Choose an object to narrow discovery further. Reset the scan to change formats. diff --git a/design-qa.md b/design-qa.md index 75ad7a2..efd1b50 100644 --- a/design-qa.md +++ b/design-qa.md @@ -2,6 +2,8 @@ ## Evidence +The original captures below document earlier iterations. Current unified-workflow validation is recorded at the end of this file. + - Selected source: `/Users/ahmed/.codex/generated_images/019fc6f5-7b69-7a51-b2e2-cef2d880323c/exec-0fda6964-a151-43c1-8c00-ee857a39774d.png` - Normalized source: `/tmp/hack-engine-source-normalized.png` - Firefox implementation captures: `/tmp/hack-engine-popup-compact.png` and `/tmp/hack-engine-popup-pinned.png` @@ -19,30 +21,29 @@ - Image quality: extension icon source is clean at the toolbar sizes and the popup mark remains legible. - Copy: visible product naming is consistently “Hack Engine”; runtime-specific wording remains concise. - Intentional differences: the browser owns the popup's outer frame, and the live connection state appears as a compact subtitle under the product name. -- Quick scan: the simple view exposes condition and value controls without numeric-type or ActionScript-detail rows; runtime guidance remains automatic internally. +- Quick scan: one form exposes condition and value controls with Automatic defaults; runtime guidance remains automatic internally. - Progressive disclosure: range maximum, cancellation/reset controls, results, and the candidate editor remain hidden until relevant. - Header efficiency: product name, live connection subtitle, and pin occupy one compact header; the decorative mark, active-tab block, separate status card, and captured-memory card are removed. - Docked mode: the active mint pin communicates that the Firefox sidebar is open and remains visible while interacting with the inspected page. - Floating mode: **Pop out window** is a separate secondary action because an ordinary extension window cannot be forced to stay above Firefox. -- Advanced mode: a persistent-only segmented switch reveals explicit scan configuration without adding complexity to the transient toolbar popup. -- Advanced workspace: candidates and watches use separate tabs, a compact narrow-column layout, and an editor shared with the selected value. +- Scan options: an expandable section reveals number format, alignment, and the JavaScript object picker in every surface; non-default settings remain visible in its collapsed summary. +- Shared workspace: Candidates and Watches use separate tabs with filtering, sorting, and one editor for the selected value in the toolbar, sidebar, and pop-out. ## Interaction QA — Firefox - Live connection summary renders from the active tab. -- Open inspector launches the existing inspector in a persistent extension tab and preserves the inspected tab ID. - Refresh connection reloads the original active tab. - How it works opens the capabilities section of the project page. - Popup harness completed without an uncaught runtime error. - ActionScript-guided first scans send the internal smart mode, while candidate writes and freezes retain the detected numeric type. - Quick scan state and completed results survive closing and reopening the toolbar popup. -- Visible simple-view candidates refresh in one batched read every 250 ms without changing the retained scan set. -- Simple and Advanced views share one scan session; switching views neither resets nor repeats the scan. -- The full inspector joins that same tab-scoped session, so opening it inherits the active scan, candidates, watches, primary selection, and freeze state. +- Visible candidates refresh in one batched read every 250 ms without changing the retained scan set. +- Expanding or collapsing Scan options preserves the scan and results. +- The toolbar, sidebar, and pop-out join the same tab-scoped session, including its active scan, candidates, watches, primary selection, and freeze state. - Filters, sorting, expanded sections, bulk checkbox selection, and unsubmitted write drafts remain local, preventing disruptive cross-window UI changes. -- Advanced mode exposes number format, alignment, stored-value multiplier, and captured-memory selection before the first scan, then locks representation controls during refinement. -- The Advanced candidate list displays up to 200 live values, supports filtering and sorting, and automatically watches a value when it is selected. -- Both candidate editors expose compact type-aware minimum and maximum presets without writing until the user confirms **Write value**. +- Scan options exposes number format, alignment, and the JavaScript object picker before the first scan, then locks representation controls during refinement. +- The candidate list in every surface displays up to 200 live values, supports filtering and sorting, and automatically watches a value when it is selected. +- The shared value editor exposes compact type-aware minimum and maximum presets without writing until the user confirms **Write value**. - Sidebar watches continue polling after a scan reset and are capped so one batched read remains within the page agent's 256-entry limit. - Pinning docks the controls in Firefox's sidebar, retains the original inspected-tab target, and the active pin closes the sidebar. - Pop out creates or focuses one floating utility window per inspected tab and opens related tabs in the target's original browser window. @@ -56,6 +57,16 @@ - Compact-header pass: reduced connection state to a subtitle and removed captured-memory and ActionScript-detail rows, bringing scan controls directly below the header. - Pinning pass: replaced the focus-sensitive floating pin behavior with Firefox sidebar docking and kept floating mode as a separate pop-out action. - Advanced-sidebar pass: added progressive scan controls, shared-session candidate rendering, live watches, and responsive candidate/editor layouts without expanding the native popup. -- Post-fix review: no remaining P0, P1, or P2 visual or interaction findings. +- Prior post-fix review: no remaining P0, P1, or P2 visual or interaction findings. +- Unified-workflow pass: removed the Simple/Advanced split, retained expandable scan configuration, and made the results tools and shared editor available in every surface. + +Current unified-workflow verification (2026-09-27): + +- In-app preview checked the collapsed options, visible Float64/Any byte overrides, first scan, candidate selection, and shared editor using simulated extension APIs. +- Unit suite: 146 passed, 2 skipped, no failures outside sandbox restrictions. +- Browser harnesses cover the unified popup, sidebar, pop-out, restored scan sessions, single-dispatch scan/write/freeze, and collapsed option retention. +- Native Firefox and Chromium packaged-extension workflows passed. +- At 300 CSS pixels, toolbar, sidebar, and pop-out have no horizontal overflow with the JavaScript editor selected and scan options both open and closed. +- Release packages rebuilt and validated locally; no store publication or release tag created. -final result: passed +The original screenshots above are historical, not captures of this unified interface. diff --git a/manifest.json b/manifest.json index 56aa4a3..7777b05 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Hack Engine", - "version": "1.3.0", + "version": "1.3.5", "description": "Find, watch, and edit accessible numeric state in WebAssembly and JavaScript browser games.", "homepage_url": "https://abduljawada.github.io/hack-engine/", "permissions": [ diff --git a/package-lock.json b/package-lock.json index d01b7ab..0550951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hack-engine-extension", - "version": "1.3.0", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hack-engine-extension", - "version": "1.3.0", + "version": "1.3.5", "license": "MIT", "devDependencies": { "tesseract.js": "7.0.0", diff --git a/package.json b/package.json index 40916ad..e912d33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hack-engine-extension", - "version": "1.3.0", + "version": "1.3.5", "private": true, "license": "MIT", "type": "module", diff --git a/popup/popup.css b/popup/popup.css index 770be86..dd82bbb 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -16,6 +16,7 @@ [hidden] { display: none !important; } body { width: 380px; + max-width: 100%; min-height: 500px; max-height: 600px; overflow-y: auto; @@ -230,17 +231,6 @@ select, input { font-size: 14px; } .preset-action:hover { border-color: rgb(130 247 197 / 28%); color: var(--accent); background: rgb(130 247 197 / 5%); } .compact-action.freeze-active { border-color: var(--accent); color: var(--accent); background: rgb(130 247 197 / 7%); } -.view-switcher { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3px; - margin-top: 11px; - padding: 3px; - border: 1px solid var(--line); - border-radius: 9px; - background: var(--surface); -} -.view-switcher button, .workspace-tabs button { min-width: 0; min-height: 34px; @@ -252,13 +242,11 @@ select, input { font-size: 14px; } font-size: 12px; font-weight: 700; } -.view-switcher button[aria-pressed="true"], .workspace-tabs button[aria-selected="true"] { background: var(--surface-hover); color: var(--text); box-shadow: inset 0 0 0 1px rgb(130 247 197 / 18%); } -.view-switcher button:focus-visible, .workspace-tabs button:focus-visible, .advanced-candidate:focus-visible, .watch-select:focus-visible, @@ -392,7 +380,6 @@ select, input { font-size: 14px; } font-size: 17px; } .watch-remove:hover { background: rgb(255 115 115 / 10%); color: #ff9b9b; } -body.advanced-active .secondary-actions { display: none; } @media (max-width: 330px) { main { padding-inline: 14px; } @@ -445,11 +432,11 @@ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-v .quick-form > .value-fields, .advanced-form > .value-fields { grid-column: 2; min-width: 0; } .quick-form input, .quick-form select, .advanced-form input, .advanced-form select { min-width: 0; } .quick-form:has(#quick-max-label:not([hidden])), -.advanced-form:has(#advanced-max-label:not([hidden])) { grid-template-columns: minmax(0, 1fr); } +.advanced-form:has(#quick-max-label:not([hidden])) { grid-template-columns: minmax(0, 1fr); } .quick-form:has(#quick-max-label:not([hidden])) > .value-fields, -.advanced-form:has(#advanced-max-label:not([hidden])) > .value-fields { grid-column: 1 / -1; } +.advanced-form:has(#quick-max-label:not([hidden])) > .value-fields { grid-column: 1 / -1; } .quick-form:has(#quick-value-label[hidden]) > label:first-child, -.advanced-form:has(#advanced-value-label[hidden]) > label:first-child { grid-column: 1 / -1; } +.advanced-form:has(#quick-value-label[hidden]) > label:first-child { grid-column: 1 / -1; } .value-fields:not(:has(> label:not([hidden]))) { display: none; } @media (max-width: 380px) { .watch-metadata { flex-wrap: wrap; } @@ -459,7 +446,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-v @media (max-width: 260px) { main { padding-inline: 8px; } .advanced-heading, .quick-heading { flex-wrap: wrap; } - .workspace-tabs, .view-switcher, .value-fields { grid-template-columns: minmax(0, 1fr); } + .workspace-tabs, .value-fields { grid-template-columns: minmax(0, 1fr); } .scan-actions, .editor-actions, .value-presets { flex-wrap: wrap; } .advanced-candidate, .watch-select { grid-template-columns: minmax(0, 1fr); } .candidate-type { justify-self: start; } @@ -472,7 +459,7 @@ main { container-type: inline-size; } .quick-form, .advanced-form { grid-template-columns: minmax(0, 1fr); } .quick-form > .value-fields, .advanced-form > .value-fields { grid-column: 1 / -1; } .advanced-heading, .quick-heading { flex-wrap: wrap; } - .workspace-tabs, .view-switcher, .value-fields, .advanced-grid, .candidate-tools { grid-template-columns: minmax(0, 1fr); } + .workspace-tabs, .value-fields, .advanced-grid, .candidate-tools { grid-template-columns: minmax(0, 1fr); } .scan-actions, .editor-actions, .value-presets { flex-wrap: wrap; } .advanced-candidate, .watch-select { grid-template-columns: minmax(0, 1fr); } .candidate-type { justify-self: start; } @@ -489,3 +476,11 @@ main { container-type: inline-size; } .workspace-heading .workspace-tabs { flex: 1 1 180px; margin-bottom: 0; } .stop-freezes { margin-top: 10px; } main > .session-tools:has(.session-feedback:empty) { display: none; } + +/* One scan flow; specialized configuration stays available in place. */ +.scan-options { border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; } +.scan-options > summary { cursor: pointer; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; } +#scan-options-summary { color: var(--muted); font-weight: 400; margin-left: 6px; } +.scan-options[open] > summary { margin-bottom: 12px; } +.scan-options .runtime-guidance { margin: 12px 0 0; } +.scan-options #javascript-root-controls { margin-top: 12px; } diff --git a/popup/popup.html b/popup/popup.html index e6362e1..486a9bc 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -29,11 +29,6 @@

Hack Engine

- - - - @@ -267,7 +188,7 @@

Value scan

- + diff --git a/popup/popup.js b/popup/popup.js index 68ec9fd..a83f3f3 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -47,10 +47,8 @@ let requestSequence = 1; let quickSession = null; let selectedCandidate = null; - let activeView = "simple"; let activeWorkspace = "candidates"; let memoryDetected = false; - let hasScanResults = false; let candidateTotal = 0; let diagnostics = {}; let scanWatchdog; @@ -69,13 +67,10 @@ statusDot: document.querySelector("#status-dot"), statusTitle: document.querySelector("#status-title"), connectionState: document.querySelector(".header-status"), - viewSwitcher: document.querySelector("#view-switcher"), gameControls: document.querySelector("#game-controls"), pauseGame: document.querySelector("#pause-game"), pauseWhileScanning: document.querySelector("#pause-while-scanning"), pauseStatus: document.querySelector("#pause-status"), - viewButtons: [...document.querySelectorAll("#view-switcher [data-view]")], - quickTools: document.querySelector("#quick-tools"), condition: document.querySelector("#quick-condition"), value: document.querySelector("#quick-value"), valueLabel: document.querySelector("#quick-value-label"), @@ -86,9 +81,6 @@ cancel: document.querySelector("#cancel-quick-scan"), reset: document.querySelector("#reset-quick-scan"), quickStatus: document.querySelector("#quick-status"), - results: document.querySelector("#quick-results"), - resultCount: document.querySelector("#quick-result-count"), - candidates: document.querySelector("#quick-candidates"), broaden: document.querySelector("#broaden-search"), editor: document.querySelector("#quick-editor"), selectedAddress: document.querySelector("#selected-address"), @@ -97,25 +89,15 @@ quickSetMax: document.querySelector("#quick-set-max"), write: document.querySelector("#quick-write"), freeze: document.querySelector("#quick-freeze"), - advancedTools: document.querySelector("#advanced-tools"), + scanTools: document.querySelector("#scan-tools"), advancedAvmType: document.querySelector("#advanced-avm-type"), advancedRecommendedTypes: document.querySelector("#advanced-recommended-types"), advancedRuntimeHint: document.querySelector("#advanced-runtime-hint"), advancedSessionBadge: document.querySelector("#advanced-session-badge"), - advancedCondition: document.querySelector("#advanced-condition"), - advancedValue: document.querySelector("#advanced-value"), - advancedValueLabel: document.querySelector("#advanced-value-label"), - advancedValueText: document.querySelector("#advanced-value-text"), - advancedMaxValue: document.querySelector("#advanced-max-value"), - advancedMaxLabel: document.querySelector("#advanced-max-label"), advancedType: document.querySelector("#advanced-type"), advancedAlignment: document.querySelector("#advanced-alignment"), advancedInstance: document.querySelector("#advanced-instance"), advancedInstanceLabel: document.querySelector("#advanced-instance-label"), - advancedScan: document.querySelector("#advanced-scan"), - advancedCancel: document.querySelector("#cancel-advanced-scan"), - advancedReset: document.querySelector("#reset-advanced-scan"), - advancedStatus: document.querySelector("#advanced-status"), advancedWorkspace: document.querySelector("#advanced-workspace"), workspaceButtons: [...document.querySelectorAll("[data-workspace]")], advancedCandidatePane: document.querySelector("#advanced-candidate-pane"), @@ -127,13 +109,6 @@ advancedCandidates: document.querySelector("#advanced-candidates"), advancedWatches: document.querySelector("#advanced-watches"), advancedWatchEmpty: document.querySelector("#advanced-watch-empty"), - advancedEditor: document.querySelector("#advanced-editor"), - advancedSelectedAddress: document.querySelector("#advanced-selected-address"), - advancedWriteValue: document.querySelector("#advanced-write-value"), - advancedSetMin: document.querySelector("#advanced-set-min"), - advancedSetMax: document.querySelector("#advanced-set-max"), - advancedWrite: document.querySelector("#advanced-write"), - advancedFreeze: document.querySelector("#advanced-freeze"), popOut: document.querySelector("#pop-out-window"), howItWorks: document.querySelector("#how-it-works"), }; @@ -433,32 +408,26 @@ function setQuickStatus(message, state = "") { elements.quickStatus.textContent = message; elements.quickStatus.className = `quick-status ${state}`.trim(); - elements.advancedStatus.textContent = message; - elements.advancedStatus.className = `quick-status ${state}`.trim(); } function updateViewVisibility() { - const persistentSurface = isSidebarPanel || isPopoutWindow; - elements.viewSwitcher.hidden = !persistentSurface || !memoryDetected; elements.gameControls.hidden = !memoryDetected; - elements.quickTools.hidden = !memoryDetected || activeView !== "simple"; - elements.advancedTools.hidden = !memoryDetected || activeView !== "advanced"; - document.body.classList.toggle("advanced-active", activeView === "advanced"); - for (const button of elements.viewButtons) { - button.setAttribute("aria-pressed", String(button.dataset.view === activeView)); - } + elements.scanTools.hidden = !memoryDetected; } - function setActiveView(view) { - activeView = view === "advanced" && (isSidebarPanel || isPopoutWindow) - ? "advanced" - : "simple"; - try { - sessionStorage.setItem("hack-engine-view", activeView); - } catch { - // The view still works when session storage is unavailable. - } - updateViewVisibility(); + function updateOptionsSummary() { + const request = quickSession?.request; + const locked = quickSession?.canRefine || quickSession?.status === "scanning"; + const source = locked ? sessionInstance() : advancedSelectedInstance(); + const type = locked ? request?.type : elements.advancedType.value; + const alignment = locked ? request?.alignment : elements.advancedAlignment.value; + const labels = []; + labels.push([...elements.advancedType.options].find(option => option.value === type)?.textContent || "Automatic"); + if (source?.kind !== "javascript" && alignment === "byte") labels.push("Any byte"); + const root = locked ? request?.rootPath : ui("javascript-root").value; + if (source?.kind === "javascript" && root && (!Array.isArray(root) || root.length)) labels.push("Selected object"); + if (locked && request?.multiplier && request.multiplier !== 1) labels.push(`Scale ×${request.multiplier}`); + ui("scan-options-summary").textContent = labels.join(" · "); } function setActiveWorkspace(workspace) { @@ -512,13 +481,6 @@ elements.valueText, elements.maxLabel, ); - updateConditionFields( - elements.advancedCondition, - elements.advancedValue, - elements.advancedValueLabel, - elements.advancedValueText, - elements.advancedMaxLabel, - ); } function updateInstanceOptions() { @@ -539,10 +501,6 @@ elements.advancedInstance.value = preferred ? `${preferred.frameId}:${preferred.id}` : ""; } elements.advancedInstanceLabel.hidden = records.length <= 1; - const simple = ui("quick-instance"); - simple.replaceChildren(...[...elements.advancedInstance.options].map((option) => new Option(option.textContent, option.value))); - simple.value = elements.advancedInstance.value; - ui("quick-instance-label").hidden = records.length <= 1; } function updateRuntimeGuidance() { @@ -603,27 +561,12 @@ elements.condition.value = "changed"; } elements.scan.textContent = canRefine ? "Next scan" : "First scan"; - elements.scan.disabled = !port || scanning || !selectedInstance(); + elements.scan.disabled = !port || scanning || !(canRefine ? sessionInstance() : advancedSelectedInstance()); elements.cancel.hidden = !scanning; elements.reset.hidden = !quickSession; - for (const option of elements.advancedCondition.querySelectorAll("[data-refine-only]")) { - option.disabled = !canRefine; - } - elements.advancedCondition.querySelector('[value="unknown"]').disabled = canRefine; - if (!canRefine && elements.advancedCondition.selectedOptions[0]?.disabled) { - elements.advancedCondition.value = "exact"; - } - if (canRefine && elements.advancedCondition.value === "unknown") { - elements.advancedCondition.value = "changed"; - } - elements.advancedScan.textContent = canRefine ? "Next scan" : "First scan"; - elements.advancedScan.disabled = !port || scanning || !(canRefine ? sessionInstance() : advancedSelectedInstance()); - elements.advancedCancel.hidden = !scanning; - elements.advancedReset.hidden = !quickSession; elements.advancedType.disabled = canRefine || scanning; elements.advancedAlignment.disabled = canRefine || scanning; elements.advancedInstance.disabled = canRefine || scanning; - ui("quick-instance").disabled = canRefine || scanning; elements.advancedSessionBadge.textContent = scanning ? "Scanning" : canRefine @@ -631,6 +574,7 @@ : "New scan"; elements.advancedSessionBadge.classList.toggle("active", scanning || canRefine); updateRuntimeGuidance(); + updateOptionsSummary(); updateConditionControls(); updateScanWatchdog(); updatePlaybackControls(); @@ -649,9 +593,8 @@ updateDiagnosticUI(); const hasSelection = Boolean(selectedCandidate); const liveSelection = !!port && !!selectedCandidate && instances.has(`${selectedCandidate.frameId}:${selectedCandidate.instanceId}`); - for (const button of [elements.write, elements.advancedWrite, elements.freeze, elements.advancedFreeze]) button.disabled = !liveSelection; + for (const button of [elements.write, elements.freeze]) button.disabled = !liveSelection; elements.editor.hidden = !hasSelection; - elements.advancedEditor.hidden = !hasSelection; if (!selectedCandidate) { editorSelectionKey = ""; return; @@ -659,15 +602,13 @@ const address = candidateLocation(selectedCandidate); const value = candidateValueText(selectedCandidate); elements.selectedAddress.textContent = address; - elements.advancedSelectedAddress.textContent = address; // Workspace/diagnostic updates must not replace a draft used by Write/Freeze. if (editorSelectionKey !== selectedKey) { elements.writeValue.value = value; - elements.advancedWriteValue.value = value; editorSelectionKey = selectedKey; } const frozen = frozenCandidates.has(selectedKey); - for (const button of [elements.freeze, elements.advancedFreeze]) { + for (const button of [elements.freeze]) { button.textContent = frozen ? "Unfreeze" : "Freeze"; button.classList.toggle("freeze-active", frozen); } @@ -771,22 +712,6 @@ left.candidate.address - right.candidate.address; } - function renderSimpleCandidates() { - elements.candidates.replaceChildren(); - for (const [key, entry] of [...candidateRecords].sort(compareRecommendedCandidates).slice(0, 20)) { - const row = document.createElement("button"); - row.type = "button"; - row.className = "quick-candidate"; - row.dataset.candidateKey = key; - const address = document.createElement("span"); - address.className = "candidate-address"; - address.textContent = candidateLocation(entry.candidate); - row.append(address, makeValueCell(entry)); - row.addEventListener("click", () => selectCandidate(entry.candidate)); - elements.candidates.append(row); - } - } - function renderAdvancedCandidates() { elements.advancedCandidates.replaceChildren(); const filter = elements.advancedFilter.value.trim().toLowerCase(); @@ -835,7 +760,6 @@ for (const entry of candidateRecords.values()) { entry.valueCells.clear(); } - renderSimpleCandidates(); renderAdvancedCandidates(); } @@ -924,9 +848,6 @@ ? payload.preview.slice(0, MAX_ADVANCED_CANDIDATES) : []; candidateTotal = Number(payload?.total || 0); - hasScanResults = true; - elements.results.hidden = false; - elements.resultCount.textContent = candidateTotal.toLocaleString(); elements.advancedResultCount.textContent = candidateTotal.toLocaleString(); clearCandidateRefreshState(); selectedCandidate = null; @@ -979,9 +900,6 @@ elements.condition.value = session.request.condition || "exact"; elements.value.value = session.request.rawValue ?? elements.value.value; elements.maxValue.value = session.request.rawMaxValue ?? elements.maxValue.value; - elements.advancedCondition.value = session.request.condition || "exact"; - elements.advancedValue.value = session.request.rawValue ?? elements.advancedValue.value; - elements.advancedMaxValue.value = session.request.rawMaxValue ?? elements.advancedMaxValue.value; elements.advancedType.value = session.request.type || "smart"; elements.advancedAlignment.value = session.request.alignment || "aligned"; } @@ -1003,13 +921,12 @@ } else if (!session) { renderedResult = null; clearCandidateRefreshState(); - hasScanResults = false; candidateTotal = 0; selectedCandidate = null; - elements.results.hidden = true; - elements.candidates.replaceChildren(); elements.advancedCandidates.replaceChildren(); elements.advancedResultCount.textContent = "0"; + elements.broaden.hidden = true; + ui("advanced-preview-count").textContent = ""; renderWatches(); setQuickStatus("Ready to scan this source."); } @@ -1033,6 +950,7 @@ if (Array.isArray(root.path)) picker.append(new Option(root.displayPath || root.path.join("."), JSON.stringify(root.path))); } rootsRequest = null; + updateOptionsSummary(); setQuickStatus(`${payload.roots?.length || 0} accessible objects available. Select one or use automatic discovery.`); return; } @@ -1244,21 +1162,17 @@ candidateRefreshTimer = setInterval(refreshCandidateValues, CANDIDATE_REFRESH_MS); } + for (const id of ["advanced-type", "advanced-alignment", "javascript-root"]) ui(id).addEventListener("change", updateOptionsSummary); elements.condition.addEventListener("change", updateConditionControls); - elements.advancedCondition.addEventListener("change", updateConditionControls); function sourceChanged() { sourceChosen = true; - ui("quick-instance").value = elements.advancedInstance.value; ui("javascript-root").replaceChildren(new Option("Automatic discovery", "")); rootsRequest = null; updateRuntimeGuidance(); updatePlaybackControls(); + updateOptionsSummary(); } elements.advancedInstance.addEventListener("change", sourceChanged); - ui("quick-instance").addEventListener("change", () => { - elements.advancedInstance.value = ui("quick-instance").value; - sourceChanged(); - }); ui("javascript-load-roots").addEventListener("click", () => { const record = advancedSelectedInstance(); if (!record || record.kind !== "javascript") return; @@ -1267,9 +1181,6 @@ send({ kind: "listJavaScriptRoots", requestId, instanceId: record.id }, record.frameId); setQuickStatus("Looking for accessible objects…"); }); - for (const button of elements.viewButtons) { - button.addEventListener("click", () => setActiveView(button.dataset.view)); - } for (const button of elements.workspaceButtons) { button.addEventListener("click", () => setActiveWorkspace(button.dataset.workspace)); } @@ -1335,9 +1246,9 @@ } }); - function startScan({ condition, rawValue, rawMaxValue, multiplier, alignment, type, advanced }) { + function startScan({ condition, rawValue, rawMaxValue, multiplier, alignment, type }) { const refine = Boolean(quickSession?.canRefine); - const record = refine ? sessionInstance() : advanced ? advancedSelectedInstance() : selectedInstance(); + const record = refine ? sessionInstance() : advancedSelectedInstance(); if (!record) { setQuickStatus( refine ? "The memory used by this scan is no longer available. Reset and scan again." : "No inspection source is available.", @@ -1374,7 +1285,7 @@ type: refine ? previous?.type || "smart" : type, refine, pauseWhileScanning: Boolean(record.pauseSupported && pauseWhileScanning), - ...(record.kind === "javascript" ? { rootPath: refine ? previous?.rootPath : advanced && ui("javascript-root").value ? JSON.parse(ui("javascript-root").value) : undefined } : {}), + ...(record.kind === "javascript" ? { rootPath: refine ? previous?.rootPath : ui("javascript-root").value ? JSON.parse(ui("javascript-root").value) : undefined } : {}), }; quickSession = { requestId, @@ -1404,21 +1315,8 @@ rawValue: elements.value.value, rawMaxValue: elements.maxValue.value, multiplier: 1, - alignment: "aligned", - type: "smart", - advanced: false, - }); - }); - - elements.advancedScan.addEventListener("click", () => { - startScan({ - condition: elements.advancedCondition.value, - rawValue: elements.advancedValue.value, - rawMaxValue: elements.advancedMaxValue.value, - multiplier: 1, alignment: elements.advancedAlignment.value, type: elements.advancedType.value, - advanced: true, }); }); @@ -1433,7 +1331,6 @@ }, quickSession.frameId); } elements.cancel.addEventListener("click", cancelScan); - elements.advancedCancel.addEventListener("click", cancelScan); function resetScan() { renderedResult = null; @@ -1450,7 +1347,6 @@ applyQuickSession(null); } elements.reset.addEventListener("click", resetScan); - elements.advancedReset.addEventListener("click", resetScan); elements.broaden.addEventListener("click", () => { const record = sessionInstance(); @@ -1487,7 +1383,6 @@ } const rawValue = input.value; elements.writeValue.value = rawValue; - elements.advancedWriteValue.value = rawValue; send({ kind: "writeValue", requestId: nextRequestId("write"), @@ -1519,10 +1414,7 @@ elements.quickSetMin.addEventListener("click", () => setSelectedLimit(elements.writeValue, "min")); elements.quickSetMax.addEventListener("click", () => setSelectedLimit(elements.writeValue, "max")); - elements.advancedSetMin.addEventListener("click", () => setSelectedLimit(elements.advancedWriteValue, "min")); - elements.advancedSetMax.addEventListener("click", () => setSelectedLimit(elements.advancedWriteValue, "max")); elements.write.addEventListener("click", () => writeSelected(elements.writeValue)); - elements.advancedWrite.addEventListener("click", () => writeSelected(elements.advancedWriteValue)); function toggleFreeze(input) { if (!selectedCandidate) { @@ -1542,7 +1434,6 @@ }, selectedCandidate.frameId); } elements.freeze.addEventListener("click", () => toggleFreeze(elements.writeValue)); - elements.advancedFreeze.addEventListener("click", () => toggleFreeze(elements.advancedWriteValue)); elements.howItWorks.addEventListener("click", async () => { await extensionApi.tabs.create(newTabOptions( @@ -1566,13 +1457,6 @@ updateConditionControls(); updateInstanceOptions(); setActiveWorkspace("candidates"); - if (isSidebarPanel || isPopoutWindow) { - try { - activeView = sessionStorage.getItem("hack-engine-view") === "advanced" ? "advanced" : "simple"; - } catch { - activeView = "simple"; - } - } updateViewVisibility(); updateScanControls(); if (isSidebarPanel) { diff --git a/test/advanced-migration-run.js b/test/advanced-migration-run.js index 022d715..63b7ed2 100644 --- a/test/advanced-migration-run.js +++ b/test/advanced-migration-run.js @@ -12,7 +12,6 @@ }); try { await settle(); - click('[data-view="advanced"]'); click('[data-workspace="watches"]'); assert(!$("#open-inspector"), "Legacy inspector launcher remains"); assert(!$("#manual-address") && !$("#manual-add"), "Removed manual address controls remain"); @@ -27,22 +26,19 @@ set("#advanced-filter", ""); for (const row of document.querySelectorAll(".advanced-candidate")) { row.click(); await settle(); } assert(migrationState.watches.length === 3, "Individual candidate selection did not add watches"); - for (const [view, prefix] of [["simple", "quick"], ["advanced", "advanced"]]) { - click(`[data-view="${view}"]`); - set(`#${prefix}-write-value`, "777"); - // Background diagnostics can arrive before the write acknowledgement. - publishMigrationWorkspace(); - assert($(`#${prefix}-write-value`).value === "777", `${view}: workspace update replaced the edit draft`); - click(`#${prefix}-write`); - await settle(); - publishMigrationWorkspace(); - click(`#${prefix}-freeze`); - const freeze = popupHarnessState.commands.filter((command) => command.payload?.kind === "setFreeze").at(-1); - assert(freeze?.payload.enabled && freeze.payload.rawValue === "777", `${view}: Write then Freeze used an old value`); - await settle(); - click(`#${prefix}-freeze`); - await settle(); - } + set("#quick-write-value", "777"); + // Background diagnostics can arrive before the write acknowledgement. + publishMigrationWorkspace(); + assert($("#quick-write-value").value === "777", "Workspace update replaced the edit draft"); + click("#quick-write"); + await settle(); + publishMigrationWorkspace(); + click("#quick-freeze"); + const freeze = popupHarnessState.commands.filter((command) => command.payload?.kind === "setFreeze").at(-1); + assert(freeze?.payload.enabled && freeze.payload.rawValue === "777", "Write then Freeze used an old value"); + await settle(); + click("#quick-freeze"); + await settle(); click('[data-workspace="watches"]'); assert(!$("#watch-select-mode") && !$("[aria-label^='Group for']"), "Removed watch selection or groups remain"); const labelInput = $("[aria-label^='Watch label']"); @@ -81,9 +77,7 @@ migrationState.diagnostics = { [key]: { requestId: "quick:final", state: "verified", detail: "Complete 250 ms check" } }; migrationState.selectedKey = key; publishMigrationWorkspace(); - click('[data-view="simple"]'); - assert($("#quick-editor .selected-feedback").textContent.includes("250 ms"), "Simple selected feedback missed final diagnostic"); - click('[data-view="advanced"]'); + assert($("#quick-editor .selected-feedback").textContent.includes("250 ms"), "Selected feedback missed final diagnostic"); const beforeDisconnectReads = reads().length; popupHarnessState.emitMessage({ kind: "frameDisconnected", frameId: 0 }); assert($(".watch-row .candidate-value").textContent === "—" && $(".watch-state").textContent === "Unavailable", "Disconnected frame left a live watch value"); @@ -101,7 +95,7 @@ click(".advanced-candidate"); await settle(); assert(migrationState.watches.length === 256, "Watch capacity exceeded"); - assert(/limit|256|skip|full/i.test($("#advanced-status").textContent), "Watch capacity failure lacks feedback"); + assert(/limit|256|skip|full/i.test($("#quick-status").textContent), "Watch capacity failure lacks feedback"); click('[data-workspace="watches"]'); const scanning = { requestId: "quick:stalled", status: "scanning", frameId: 0, instanceId: "memory-1", canRefine: true, progress: null }; popupHarnessState.emitMessage({ kind: "quickSession", session: scanning }); @@ -109,15 +103,15 @@ const recoveryCount = () => popupHarnessState.commands.filter((command) => command.payload?.kind === "getSessionState").length; const beforeRecovery = recoveryCount(); migrationState.stalledCallbacks.at(-1)(); - assert(/No recent progress/.test($("#advanced-status").textContent), "Stalled scan lacks progress feedback"); + assert(/No recent progress/.test($("#quick-status").textContent), "Stalled scan lacks progress feedback"); assert(recoveryCount() === beforeRecovery + 1, "Stalled scan did not request authoritative state once"); - assert($("#advanced-scan").disabled && !$("#cancel-advanced-scan").hidden, "Watchdog unlocked scan or removed cancellation"); + assert($("#quick-scan").disabled && !$("#cancel-quick-scan").hidden, "Watchdog unlocked scan or removed cancellation"); const timerCount = migrationState.stalledCallbacks.length; popupHarnessState.emitMessage({ kind: "quickSession", session: { ...scanning } }); popupHarnessState.emitPagePayload({ kind: "scanProgress", requestId: "quick:stalled", inspected: 10, total: 100 }); assert(migrationState.stalledCallbacks.length === timerCount && recoveryCount() === beforeRecovery + 1, "Watchdog repeated recovery for same scan"); popupHarnessState.emitPagePayload({ kind: "scanResults", requestId: "quick:stalled", instanceId: "memory-1", type: "i32", total: 1, preview: [{ address: 64, type: "i32", value: 8 }] }); - assert(!$("#advanced-scan").disabled && $("#cancel-advanced-scan").hidden, "Delayed legitimate scan completion did not recover"); + assert(!$("#quick-scan").disabled && $("#cancel-quick-scan").hidden, "Delayed legitimate scan completion did not recover"); await new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = "../workspace-controls.js"; script.onload = resolve; script.onerror = reject; document.body.append(script); @@ -125,12 +119,12 @@ await settle(); assert(!$("[data-action='save']") && !$("[data-action='import']"), "Removed saved-workspace controls remain"); assert($("[data-action='restore']") && $("[data-action='stop']"), "Session recovery controls missing"); - assert($("#quick-write").nextElementSibling.dataset.action === "restore" && $("#advanced-write").nextElementSibling.dataset.action === "restore", "Undo write is not beside Write"); + assert($("#quick-write").nextElementSibling.dataset.action === "restore", "Undo write is not beside Write"); assert($(".workspace-heading [data-action='stop']"), "Stop freezes is not beside Watches"); popupHarnessState.emitMessage({ kind: "workspaceState", workspace: { frozenKeys: [], lastWrite: null } }); assert([...document.querySelectorAll("[data-action='restore'], [data-action='stop']")].every((button) => button.hidden), "Unavailable recovery actions remain visible"); popupHarnessState.emitMessage({ kind: "quickSession", session: { ...scanning, status: "complete", request: { type: "i32" }, results: { canUndo: true } } }); - const undo = $("#advanced-tools [data-action='undo']"); + const undo = $("#scan-tools [data-action='undo']"); assert(!undo.hidden && !undo.disabled, "Undo unavailable after a recoverable scan"); undo.click(); assert(popupHarnessState.commands.at(-1).payload.kind === "undoScan", "Undo did not reach the game"); @@ -142,7 +136,7 @@ assert(popupHarnessState.commands.at(-1).payload.kind === "restoreWrite", "Restore did not reach the game"); click('[data-action="stop"]'); assert(popupHarnessState.commands.at(-1).payload.kind === "stopAllFreezes", "Stop freezes did not reach the game"); - result.textContent = "PASS: Advanced candidate selection, individual labels, diagnostics/read recovery, capacity, stalled recovery, and session controls work."; + result.textContent = "PASS: Unified candidate selection, individual labels, diagnostics/read recovery, capacity, stalled recovery, and session controls work."; } catch (error) { result.textContent = `FAIL: ${error.stack || error}`; } diff --git a/test/extension-ui-scenario.mjs b/test/extension-ui-scenario.mjs index 92ac03b..c497cb2 100644 --- a/test/extension-ui-scenario.mjs +++ b/test/extension-ui-scenario.mjs @@ -8,15 +8,15 @@ export const extensionUiScenario = `(${async function () { const api = globalThis.browser ?? globalThis.chrome; const ui = (selector) => document.querySelector(selector); await wait(() => !ui('#quick-scan').disabled, 'Game connection'); - ui('[data-view="advanced"]').click(); + ui('#scan-options').open = true; await wait(() => [...ui('#advanced-instance').options].some((option) => !/JavaScript/.test(option.textContent)), 'WebAssembly source'); ui('#advanced-instance').value = [...ui('#advanced-instance').options].find((option) => !/JavaScript/.test(option.textContent)).value; ui('#advanced-instance').dispatchEvent(new Event('change', { bubbles: true })); ui('#advanced-type').value = 'i32'; - ui('#advanced-value').value = '100'; - ui('#advanced-scan').click(); - await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#advanced-scan').disabled, 'First scan'); - ui('#advanced-value').value = '1234567'; ui('#advanced-scan').click(); + ui('#quick-value').value = '100'; + ui('#quick-scan').click(); + await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#quick-scan').disabled, 'First scan'); + ui('#quick-value').value = '1234567'; ui('#quick-scan').click(); await wait(() => ui('#advanced-result-count').textContent === '0' && !ui('[data-action="undo"]').disabled, 'Wrong refinement'); ui('[data-action="undo"]').click(); await wait(() => ui('#advanced-result-count').textContent === '1' && ui('.advanced-candidate'), 'Undo scan'); @@ -24,12 +24,12 @@ export const extensionUiScenario = `(${async function () { if (ui('#candidate-select-mode') || ui('#batch-watch')) throw new Error('Removed candidate selection controls remain'); ui('.advanced-candidate').click(); await wait(() => ui('#advanced-watch-count').textContent === '1', 'Individual candidate watch'); - ui('#advanced-write-value').value = '500'; ui('#advanced-write').click(); + ui('#quick-write-value').value = '500'; ui('#quick-write').click(); await wait(() => !ui('[data-action="restore"]').disabled, 'Write bookkeeping'); await wait(() => [...document.querySelectorAll('.watch-state')].some((node) => node.textContent.includes('Verified through 250 ms')), 'Final write diagnostic'); - if (!ui('#advanced-editor .selected-feedback').textContent.includes('250 ms')) throw new Error('Selected value missed final diagnostic'); - if (ui('#advanced-write-value').value !== '500') throw new Error('Write acknowledgement replaced the entered value'); - ui('#advanced-freeze').click(); + if (!ui('#quick-editor .selected-feedback').textContent.includes('250 ms')) throw new Error('Selected value missed final diagnostic'); + if (ui('#quick-write-value').value !== '500') throw new Error('Write acknowledgement replaced the entered value'); + ui('#quick-freeze').click(); await wait(() => ui('[data-count]').textContent === '1', 'Freeze written value'); const frozenValue = await new Promise((resolve, reject) => { const targetTab = Number(new URLSearchParams(location.search).get('tabId')); @@ -47,12 +47,12 @@ export const extensionUiScenario = `(${async function () { kind: 'readValues', requestId, instanceId, entries: [{ id: 'release-check', type: 'i32', address }], } }); }); - if (ui('#advanced-write-value').value !== '500' || frozenValue !== 500) throw new Error('Write then Freeze used an older value: ' + frozenValue); + if (ui('#quick-write-value').value !== '500' || frozenValue !== 500) throw new Error('Write then Freeze used an older value: ' + frozenValue); ui('[data-action="stop"]').click(); await wait(() => ui('[data-count]').textContent === '0', 'Stop written-value freeze'); ui('[data-action="restore"]').click(); await wait(() => ui('[data-action="restore"]').disabled, 'Restore write'); - ui('#advanced-write-value').value = '200'; ui('#advanced-freeze').click(); + ui('#quick-write-value').value = '200'; ui('#quick-freeze').click(); await wait(() => ui('[data-count]').textContent === '1', 'Freeze status'); ui('[data-action="stop"]').click(); await wait(() => ui('[data-count]').textContent === '0', 'Stop all freezes'); @@ -65,7 +65,7 @@ export const extensionUiScenario = `(${async function () { ui('[data-workspace="candidates"]').click(); ui('.advanced-candidate').click(); if (ui('[aria-label^="Watch label"]').value !== 'Practice score') throw new Error('Watch metadata lost when reselecting a candidate'); if (ui('[data-action="save"]') || ui('[data-action="import"]')) throw new Error('Removed workspace controls remain'); - ui('#reset-advanced-scan').click(); + ui('#reset-quick-scan').click(); await wait(() => !ui('#advanced-instance').disabled, 'Reset before JavaScript scan'); const jsOption = [...ui('#advanced-instance').options].find((option) => /JavaScript/.test(option.textContent)); if (!jsOption) throw new Error('JavaScript source missing'); @@ -78,21 +78,21 @@ export const extensionUiScenario = `(${async function () { ui('#javascript-load-roots').click(); await wait(() => [...ui('#javascript-root').options].some((option) => option.value === JSON.stringify(['hackEnginePracticeJS'])), 'JavaScript object picker'); ui('#javascript-root').value = JSON.stringify(['hackEnginePracticeJS']); - ui('#advanced-value').value = '100'; ui('#advanced-scan').click(); - await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#advanced-scan').disabled, 'JavaScript scan'); + ui('#quick-value').value = '100'; ui('#quick-scan').click(); + await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#quick-scan').disabled, 'JavaScript scan'); if (!ui('.advanced-candidate').textContent.includes('hackEnginePracticeJS.score')) throw new Error('JavaScript property path missing'); - ui('#advanced-value').value = '1234567'; ui('#advanced-scan').click(); + ui('#quick-value').value = '1234567'; ui('#quick-scan').click(); await wait(() => ui('#advanced-result-count').textContent === '0' && !ui('[data-action="undo"]').disabled, 'JavaScript refinement'); ui('[data-action="undo"]').click(); await wait(() => ui('#advanced-result-count').textContent === '1' && ui('.advanced-candidate'), 'JavaScript undo'); ui('.advanced-candidate').click(); await wait(() => ui('#advanced-watch-count').textContent === '2', 'Mixed source watches'); - ui('#advanced-write-value').value = '500'; ui('#advanced-write').click(); + ui('#quick-write-value').value = '500'; ui('#quick-write').click(); await wait(() => !ui('[data-action="restore"]').disabled, 'JavaScript write bookkeeping'); - await wait(() => ui('#advanced-editor .selected-feedback').textContent.includes('250 ms'), 'JavaScript verified write'); + await wait(() => ui('#quick-editor .selected-feedback').textContent.includes('250 ms'), 'JavaScript verified write'); ui('[data-action="restore"]').click(); await wait(() => ui('[data-action="restore"]').disabled, 'JavaScript restore'); - ui('#advanced-write-value').value = '200'; ui('#advanced-freeze').click(); + ui('#quick-write-value').value = '200'; ui('#quick-freeze').click(); await wait(() => ui('[data-count]').textContent === '1', 'JavaScript freeze'); ui('[data-action="stop"]').click(); await wait(() => ui('[data-count]').textContent === '0', 'JavaScript stop freezes'); @@ -111,7 +111,7 @@ export const extensionUiScenario = `(${async function () { ui('#javascript-load-roots').click(); await wait(() => [...ui('#javascript-root').options].some((option) => option.value === JSON.stringify(['hackEnginePracticeJS'])), 'Reloaded JavaScript root'); ui('#javascript-root').value = JSON.stringify(['hackEnginePracticeJS']); - ui('#advanced-value').value = '100'; ui('#advanced-scan').click(); - await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#advanced-scan').disabled, 'Reloaded JavaScript scan'); + ui('#quick-value').value = '100'; ui('#quick-scan').click(); + await wait(() => ui('#advanced-result-count').textContent === '1' && !ui('#quick-scan').disabled, 'Reloaded JavaScript scan'); return 'PASS: packaged Wasm and JavaScript controls scan, refine, undo, watch, diagnose writes, restore, freeze/stop, reload, and invalidate old watches.'; }})()`; diff --git a/test/game-pause-observation.test.mjs b/test/game-pause-observation.test.mjs index ed9a699..e31b7e1 100644 --- a/test/game-pause-observation.test.mjs +++ b/test/game-pause-observation.test.mjs @@ -4,12 +4,12 @@ import vm from 'node:vm'; import {armPlaybackObservation,armControlObservation,stopPlaybackObservation,stopControlObservation,observedScanPause,withScanPauseObservation,scanCancellationAcknowledged,armProgressCancellation,stopProgressCancellation,withProgressCancellation} from './games/pause-observation.mjs'; function fixture() { - let time=0,tick,mutation,cleared=false,disconnected=false; + let time=0,tick,mutation,playbackMutation,cleared=false,disconnected=false,playbackDisconnected=false; const api={suspended:false},button={disabled:false}; - const game=vm.createContext({document:{querySelector:()=>({ruffle:()=>api})},Date:{now:()=>time},setInterval:fn=>{tick=fn;return 1;},clearInterval:()=>{cleared=true;}}); - const controls=vm.createContext({document:{querySelector:()=>button},Date:{now:()=>time},MutationObserver:class{constructor(fn){mutation=fn;}observe(){}disconnect(){disconnected=true;}}}); + const game=vm.createContext({document:{querySelector:()=>({ruffle:()=>api,shadowRoot:{}})},performance:{timeOrigin:1000,now:()=>time},setInterval:fn=>{tick=fn;return 1;},clearInterval:()=>{cleared=true;},MutationObserver:class{constructor(fn){playbackMutation=fn;}observe(){}disconnect(){playbackDisconnected=true;}}}); + const controls=vm.createContext({document:{querySelector:()=>button},performance:{timeOrigin:1000,now:()=>time},MutationObserver:class{constructor(fn){mutation=fn;}observe(){}disconnect(){disconnected=true;}}}); vm.runInContext(armPlaybackObservation,game);vm.runInContext(armControlObservation,controls); - return {game,controls,api,button,setTime:t=>{time=t;},tick:()=>tick(),mutation:()=>mutation(),get cleaned(){return cleared&&disconnected;}}; + return {game,controls,api,button,setTime:t=>{time=t;},tick:()=>tick(),mutation:()=>mutation(),playbackMutation:()=>playbackMutation(),get cleaned(){return cleared&&disconnected&&playbackDisconnected;}}; } test('prearmed observations retain actual fast-scan overlap after the click has returned',()=>{ const f=fixture(); @@ -71,7 +71,7 @@ test('a genuine zero-result scan can prove pause without being mistaken for a sc function cancellationFixture() { let mutation,clicks=0,disconnected=false,time=0; const scan={disabled:false},cancel={hidden:true,disabled:false,click(){clicks++;}},status={textContent:'Ready to scan this source.'}; - const context=vm.createContext({document:{querySelector:s=>({'#advanced-scan':scan,'#cancel-advanced-scan':cancel,'#advanced-status':status}[s])},Date:{now:()=>time},MutationObserver:class{constructor(fn){mutation=fn;}observe(){}disconnect(){disconnected=true;}}}); + const context=vm.createContext({document:{querySelector:s=>({'#quick-scan':scan,'#cancel-quick-scan':cancel,'#quick-status':status}[s])},performance:{timeOrigin:1000,now:()=>time},MutationObserver:class{constructor(fn){mutation=fn;}observe(){}disconnect(){disconnected=true;}}}); vm.runInContext(armProgressCancellation,context); return{context,scan,cancel,status,mutate(){time++;mutation();},get clicks(){return clicks;},get disconnected(){return disconnected;}}; } @@ -121,3 +121,30 @@ test('partially armed cancellation observers are cleaned when setup rejects',asy await assert.rejects(withProgressCancellation(ui,async()=>{}),/Setup interrupted/); assert.ok(scripts.includes(stopProgressCancellation)); }); + + +test('rendered-player mutations capture a sub-timer pause with strict sub-millisecond overlap',()=>{ + const f=fixture(); + f.setTime(0.2);f.button.disabled=true;f.mutation(); + f.setTime(0.4);f.api.suspended=true;f.playbackMutation(); + f.setTime(0.7);f.api.suspended=false;f.playbackMutation(); + f.setTime(0.9);f.button.disabled=false;f.mutation(); + f.setTime(8);f.tick(); + assert.deepEqual(observedScanPause(f.game.__hackPauseObservation.samples,f.controls.__hackPauseObservation.samples), + {suspendedAt:1000.4,busyFrom:1000.2,busyUntil:1000.9}); + vm.runInContext(stopPlaybackObservation,f.game);vm.runInContext(stopControlObservation,f.controls); + assert.equal(f.cleaned,true); + assert.equal(f.game.__hackPauseObservation,undefined); +}); + +test('rendered mutations during an active scan never imply suspension',()=>{ + const f=fixture(); + f.setTime(1);f.button.disabled=true;f.mutation(); + f.setTime(2);f.playbackMutation(); + f.setTime(3);f.playbackMutation(); + f.setTime(4);f.button.disabled=false;f.mutation(); + assert.equal(observedScanPause(f.game.__hackPauseObservation.samples,f.controls.__hackPauseObservation.samples),null); + assert.ok(f.game.__hackPauseObservation.samples.every(sample=>sample.playing===true)); + vm.runInContext(stopPlaybackObservation,f.game);vm.runInContext(stopControlObservation,f.controls); + assert.equal(f.cleaned,true); +}); diff --git a/test/games/bloons-live.mjs b/test/games/bloons-live.mjs index 803bb13..12d0348 100644 --- a/test/games/bloons-live.mjs +++ b/test/games/bloons-live.mjs @@ -151,7 +151,7 @@ export async function runBloonsLive({session,gamePage,site,controls,baseline,art // cannot reliably screenshot a paused hidden surface. The actual write // still occurs with the unrelated tab active, as the binding test requires. await step('tab-binding',async()=>{const id=await ui.evaluate('new URLSearchParams(location.search).get("tabId")');await session.activate(gamePage);await setPaused(true);const before=await number('lives');const other=await session.newPage('about:blank');try{await session.activate(other);if(await ui.evaluate('new URLSearchParams(location.search).get("tabId")')!==id)throw new GameTestError('Controls lost original website tab binding.','extension','FAIL');await ui.wait(`Number(document.querySelector('#advanced-watch-count').textContent)>0`,'Original live game watch retained');await ui.write(500);await session.activate(gamePage);await setPaused(false);const after=await naturalLives(before);if(after<=400||after>=500)throw new GameTestError(`Write with another tab active did not affect original game: ${after}.`,'extension','FAIL');return{boundTab:id,writtenWithOtherTabActive:500,originalGameAfterLoss:after};}finally{await session.closePage(other);await session.activate(gamePage);}}); - await step('reload',async()=>{await session.navigate(gamePage,site.metadata.url);await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0' && document.querySelector('#advanced-write').disabled`,'Website reload invalidates watches and writes');return{staleWatches:0,staleWritesDisabled:true};}); + await step('reload',async()=>{await session.navigate(gamePage,site.metadata.url);await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0' && document.querySelector('#quick-write').disabled`,'Website reload invalidates watches and writes');return{staleWatches:0,staleWritesDisabled:true};}); await session.closePage(ui.page); return{targets:['cash','lives'],observations:'Rendered screenshot OCR and genuine economy/wave events'}; }finally{await worker.close();} diff --git a/test/games/buddy-live.mjs b/test/games/buddy-live.mjs index 0f0473a..7a88409 100644 --- a/test/games/buddy-live.mjs +++ b/test/games/buddy-live.mjs @@ -178,12 +178,12 @@ export async function runBuddyLive({session,gamePage,site,controls,baseline,arti await step('stop',async()=>{await ui.stop();return buy();}); await click(385,55); // Close the visible shop. await runPauseCases({ui,read:cash,naturalChange:earn,target:{scan:{type:'f64'},change:[{click:[205,350]},{click:[275,350]},{click:[345,350]}]},runStep:step,session,gamePage:page}); - await step('reopen',async()=>{const before=await ui.state();await session.closePage(ui.page);ui.page=await session.openControls(gamePage);await ui.wait(`document.querySelector('#quick-scan')&&!document.querySelector('#quick-scan').disabled`,'Reopened Buddy controls');await ui.wait(`Number(document.querySelector('#advanced-watch-count').textContent)===${before.watches}`,'Buddy watch survives reopen');await ui.click('[data-view="advanced"]');await ui.click('.watch-select');await ui.wait(`!document.querySelector('#advanced-write').disabled`,'Retained Buddy watch selected for editing');return{watches:before.watches};}); + await step('reopen',async()=>{const before=await ui.state();await session.closePage(ui.page);ui.page=await session.openControls(gamePage);await ui.wait(`document.querySelector('#quick-scan')&&!document.querySelector('#quick-scan').disabled`,'Reopened Buddy controls');await ui.wait(`Number(document.querySelector('#advanced-watch-count').textContent)===${before.watches}`,'Buddy watch survives reopen');await ui.click('[data-workspace="watches"]');await ui.click('.watch-select');await ui.wait(`!document.querySelector('#quick-write').disabled`,'Retained Buddy watch selected for editing');return{watches:before.watches};}); await step('tab-binding',async()=>{ const id=await ui.evaluate('new URLSearchParams(location.search).get("tabId")');const other=await session.newPage('about:blank'); try{await paused(true);await session.activate(other);await ui.write(700);if(await ui.evaluate('new URLSearchParams(location.search).get("tabId")')!==id)throw new GameTestError('Buddy controls lost original tab binding.');await session.activate(gamePage);await paused(false);const after=await poll(cash,v=>v>=70000&&v<70500,{timeout:5000,description:'Original Buddy game receives write while another tab was active'});return{boundTab:id,written:700,originalGameCents:after};}finally{await session.closePage(other);await session.activate(gamePage);} }); - await step('reload',async()=>{await session.navigate(gamePage,site.metadata.url);await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0'&&document.querySelector('#advanced-write').disabled`,'Reload invalidates Buddy watch and writes');return{staleWatches:0,staleWritesDisabled:true};}); + await step('reload',async()=>{await session.navigate(gamePage,site.metadata.url);await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0'&&document.querySelector('#quick-write').disabled`,'Reload invalidates Buddy watch and writes');return{staleWatches:0,staleWritesDisabled:true};}); await session.closePage(ui.page); return{targets:['money'],observations:'High-DPI rendered cash and shop OCR, genuine hand earnings and previously unaffordable purchases'}; } finally {await ocr.close();} diff --git a/test/games/canabalt-live.mjs b/test/games/canabalt-live.mjs index 06f0c4a..552f1ae 100644 --- a/test/games/canabalt-live.mjs +++ b/test/games/canabalt-live.mjs @@ -113,7 +113,7 @@ export async function runCanabaltLive({session,gamePage,site,controls,baseline,a finding.changed=await ui.scan('changed'); finding.changedCandidates=await ui.candidates(); } - findings.push(finding);if(await ui.evaluate(`!document.querySelector('#reset-advanced-scan').disabled`))await ui.reset(); + findings.push(finding);if(await ui.evaluate(`!document.querySelector('#reset-quick-scan').disabled`))await ui.reset(); } } await session.screenshot(controls,join(artifactDir,'canabalt-javascript-roots.png')).catch(error => writeFile(join(artifactDir,'canabalt-controls-screenshot-unavailable.json'),JSON.stringify({browser:session.browser,reason:error.message,evidence:'Packaged source and root DOM observations remain in canabalt-javascript-roots.json.'},null,2))); diff --git a/test/games/fixture-scenario.mjs b/test/games/fixture-scenario.mjs index 9862905..ae763a3 100644 --- a/test/games/fixture-scenario.mjs +++ b/test/games/fixture-scenario.mjs @@ -46,7 +46,7 @@ export async function runFixtureHarness({ browser, url, noSandbox = true, headed const deadline = Date.now() + 15000; while (Date.now() < deadline) { const session = await chrome.runtime.sendMessage({ kind: 'getQuickSession', tabId: ${controls.gameTabId} }); - if (session?.status === 'complete' && session.results?.total === 1 && !document.querySelector('#advanced-scan').disabled) return true; + if (session?.status === 'complete' && session.results?.total === 1 && !document.querySelector('#quick-scan').disabled) return true; await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Scan session failed to recover after worker termination'); diff --git a/test/games/layouts.mjs b/test/games/layouts.mjs index 5ac9d58..1bb1947 100644 --- a/test/games/layouts.mjs +++ b/test/games/layouts.mjs @@ -4,7 +4,7 @@ import { GameTestError, poll, readRenderedValue, delay } from './observations.mj async function selectScore(session, controls) { const ui = new GameUI(session, controls); await ui.wait(`document.querySelector('#quick-scan') && !document.querySelector('#quick-scan').disabled`, 'Frame connection'); - await ui.click('[data-view="advanced"]'); + await ui.openOptions(); const sources = await ui.evaluate(`Array.from(document.querySelector('#advanced-instance').options,o=>({value:o.value,text:o.textContent})).filter(o=>o.text.includes('JavaScript'))`); for (const source of sources) { await ui.set('#advanced-instance', source.value); diff --git a/test/games/pause-observation.mjs b/test/games/pause-observation.mjs index 3f5c822..308c9a6 100644 --- a/test/games/pause-observation.mjs +++ b/test/games/pause-observation.mjs @@ -8,28 +8,34 @@ export const armPlaybackObservation = `(() => { const player = document.querySelector('ruffle-player'); const api = typeof player?.ruffle === 'function' ? player.ruffle(1) : player; const playing = typeof api?.suspended === 'boolean' ? !api.suspended : typeof api?.isPlaying === 'boolean' ? api.isPlaying : null; - samples.push({time:Date.now(),playing}); + samples.push({time:performance.timeOrigin + performance.now(),playing}); if (samples.length > 4096) samples.shift(); }; sample(); - globalThis.__hackPauseObservation = {samples,timer:setInterval(sample,8)}; + // Ruffle's visible Play overlay changes when public suspension changes. + // Mutation delivery can observe a pause shorter than the timer interval; + // only the actual public getter supplies the recorded playback state. + const observer = new MutationObserver(sample); + globalThis.__hackPauseObservation = {samples,timer:setInterval(sample,8),observer}; + const player = document.querySelector('ruffle-player'); + if (player?.shadowRoot) observer.observe(player.shadowRoot,{attributes:true,childList:true,subtree:true,characterData:true}); })()`; export const armControlObservation = `(() => { - const button = document.querySelector('#advanced-scan'); - const samples = [{time:Date.now(),busy:button.disabled}]; - const observer = new MutationObserver(() => samples.push({time:Date.now(),busy:button.disabled})); + const button = document.querySelector('#quick-scan'); + const samples = [{time:performance.timeOrigin + performance.now(),busy:button.disabled}]; + const observer = new MutationObserver(() => samples.push({time:performance.timeOrigin + performance.now(),busy:button.disabled})); observer.observe(button,{attributes:true,attributeFilter:['disabled']}); globalThis.__hackPauseObservation = {samples,observer}; })()`; -export const stopPlaybackObservation = `(() => { const state=globalThis.__hackPauseObservation; if(state) clearInterval(state.timer); delete globalThis.__hackPauseObservation; })()`; +export const stopPlaybackObservation = `(() => { const state=globalThis.__hackPauseObservation; if(state) { clearInterval(state.timer); state.observer.disconnect(); } delete globalThis.__hackPauseObservation; })()`; export const stopControlObservation = `(() => { globalThis.__hackPauseObservation?.observer.disconnect(); delete globalThis.__hackPauseObservation; })()`; // React in the controls document, not after multiple automation round trips. // This is only a public DOM click after the real scan reports partial progress. export const armProgressCancellation = String.raw`(() => { - const scan=document.querySelector('#advanced-scan'); - const cancel=document.querySelector('#cancel-advanced-scan'); - const status=document.querySelector('#advanced-status'); + const scan=document.querySelector('#quick-scan'); + const cancel=document.querySelector('#cancel-quick-scan'); + const status=document.querySelector('#quick-status'); const state={clicked:null}; const observer=new MutationObserver(() => { if(state.clicked || !scan.disabled || cancel.hidden || cancel.disabled)return; @@ -39,7 +45,7 @@ export const armProgressCancellation = String.raw`(() => { const inspected=Number(match[1].replaceAll(',','')); const total=Number(match[2].replaceAll(',','')); if(!(inspected>0 && inspectednull); + const state=await ui.evaluate(`({clicked:globalThis.__hackProgressCancellation?.clicked,status:document.querySelector('#quick-status').textContent,busy:document.querySelector('#quick-scan').disabled})`).catch(()=>null); error.message += `; cancellation dispatch evidence: ${JSON.stringify(state)}`; throw error; } finally { @@ -98,7 +104,7 @@ export async function withScanPauseObservation({session,gamePage,ui}, action) { try { evidence=await poll(async()=>{ const [playback,control]=await Promise.all([ session.evaluate(gamePage,'globalThis.__hackPauseObservation.samples'), - ui.evaluate(`({samples:globalThis.__hackPauseObservation.samples,busy:document.querySelector('#advanced-scan').disabled,error:document.querySelector('#advanced-status').classList.contains('error'),status:document.querySelector('#advanced-status').textContent})`), + ui.evaluate(`({samples:globalThis.__hackPauseObservation.samples,busy:document.querySelector('#quick-scan').disabled,error:document.querySelector('#quick-status').classList.contains('error'),status:document.querySelector('#quick-status').textContent})`), ]); lastObservation={playback,control}; if (!control.busy && control.error && !control.status.startsWith('No matching values.')) throw new GameTestError(`Packaged scan failed before pause could be observed: ${control.status}`,'extension','FAIL'); diff --git a/test/games/scenarios.mjs b/test/games/scenarios.mjs index 33e29a1..9ad5ba1 100644 --- a/test/games/scenarios.mjs +++ b/test/games/scenarios.mjs @@ -338,7 +338,7 @@ export async function runGame({ session, game, asset, gamePage, controls, baseli await step('reload', async () => { const oldSource = (await ui.state()).source; await session.navigate(gamePage, gameUrl); - await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0' && document.querySelector('#advanced-write').disabled`, 'Reload invalidated watches and edits'); + await ui.wait(`document.querySelector('#advanced-watch-count').textContent==='0' && document.querySelector('#quick-write').disabled`, 'Reload invalidated watches and edits'); await ui.wait(`Array.from(document.querySelector('#advanced-instance').options).some(o=>o.value!==${JSON.stringify(oldSource)})`, 'New document source'); await session.screenshot(gamePage, join(artifactDir, 'reloaded.png')); return { staleWatches: 0, staleWritesDisabled: true }; @@ -387,15 +387,15 @@ export async function runPauseCases({ ui, read, naturalChange, target, runStep, await ui.reset(); // Search real memory rather than copying a baseline: exhaustive comparison // yields naturally and gives the user an actual operation to cancel. - await ui.set('#advanced-condition', 'exact'); - await ui.set('#advanced-value', 987654321); + await ui.set('#quick-condition', 'exact'); + await ui.set('#quick-value', 987654321); const cancelledScan = await observeScan('Scan cancellation', async observe => { - const cancellation=await withProgressCancellation(ui,()=>ui.click('#advanced-scan')); + const cancellation=await withProgressCancellation(ui,()=>ui.click('#quick-scan')); await observe().catch(error=>{error.message += `; cancellation dispatch evidence: ${JSON.stringify(cancellation)}`;throw error;}); return cancellation; }); - const cancellationStatus = await poll(() => ui.evaluate(`document.querySelector('#advanced-status').textContent`), scanCancellationAcknowledged, {timeout:5000,description:'Packaged scan cancellation acknowledged',category:'extension',status:'FAIL'}).catch(error=>{error.message += `; cancellation dispatch evidence: ${JSON.stringify(cancelledScan)}`;throw error;}); - await ui.wait(`!document.querySelector('#advanced-scan').disabled && document.querySelector('#pause-game').getAttribute('aria-pressed')==='false'`, 'Cancellation released scan-owned pause'); + const cancellationStatus = await poll(() => ui.evaluate(`document.querySelector('#quick-status').textContent`), scanCancellationAcknowledged, {timeout:5000,description:'Packaged scan cancellation acknowledged',category:'extension',status:'FAIL'}).catch(error=>{error.message += `; cancellation dispatch evidence: ${JSON.stringify(cancelledScan)}`;throw error;}); + await ui.wait(`!document.querySelector('#quick-scan').disabled && document.querySelector('#pause-game').getAttribute('aria-pressed')==='false'`, 'Cancellation released scan-owned pause'); await poll(isPlaying, value => value === true, { description: 'Public Ruffle playback resumed after cancellation', category: 'extension', status: 'FAIL' }); return { cancelled: true, cancellationStatus, resumed: true, observation: cancelledScan.evidence, cancellation: cancelledScan.result }; }); diff --git a/test/games/ui.mjs b/test/games/ui.mjs index 6c817e8..841bb6a 100644 --- a/test/games/ui.mjs +++ b/test/games/ui.mjs @@ -14,9 +14,13 @@ export class GameUI { async set(selector, value) { await this.evaluate(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); if (!e || e.disabled) throw Error('Unavailable input: '+${JSON.stringify(selector)}); e.value = ${JSON.stringify(String(value))}; e.dispatchEvent(new Event('change', {bubbles:true})); })()`); } + async openOptions() { + if (!await this.evaluate(`document.querySelector('#scan-options').open`)) await this.click('#scan-options > summary'); + await this.wait(`document.querySelector('#scan-options').open`, 'Scan options expanded'); + } async ready({ javascript = false, type = 'u32', root = null, sourceIndex = 0 } = {}) { await this.wait(`document.querySelector('#quick-scan') && !document.querySelector('#quick-scan').disabled`, 'Packaged controls connected'); - await this.click('[data-view="advanced"]'); + await this.openOptions(); // A parent JavaScript source can connect before an embedded Wasm player's // frame bridge. Wait for the requested source, not merely any enabled scan. const options = await poll( @@ -35,13 +39,13 @@ export class GameUI { return source; } async scan(condition, value, maximum, { during } = {}) { - await this.set('#advanced-condition', condition); - if (value !== undefined) await this.set('#advanced-value', value); - if (maximum !== undefined) await this.set('#advanced-max-value', maximum); - await this.click('#advanced-scan'); + await this.set('#quick-condition', condition); + if (value !== undefined) await this.set('#quick-value', value); + if (maximum !== undefined) await this.set('#quick-max-value', maximum); + await this.click('#quick-scan'); if (during) await during(); await poll(async () => { - const state = await this.evaluate(`({busy:document.querySelector('#advanced-scan').disabled, next:document.querySelector('#advanced-scan').textContent.includes('Next'), error:document.querySelector('#advanced-status').classList.contains('error'), status:document.querySelector('#advanced-status').textContent})`); + const state = await this.evaluate(`({busy:document.querySelector('#quick-scan').disabled, next:document.querySelector('#quick-scan').textContent.includes('Next'), error:document.querySelector('#quick-status').classList.contains('error'), status:document.querySelector('#quick-status').textContent})`); if (!state.busy && state.error && !state.status.startsWith('No matching values.')) { throw new GameTestError(`Packaged scan failed: ${state.status}`, 'extension', 'FAIL'); } @@ -50,16 +54,16 @@ export class GameUI { return this.count(); } count() { return this.evaluate(`Number(document.querySelector('#advanced-result-count').textContent.replaceAll(',',''))`); } - async reset() { await this.click('#reset-advanced-scan'); await this.wait(`!document.querySelector('#advanced-type').disabled`, 'Scan reset'); } + async reset() { await this.click('#reset-quick-scan'); await this.wait(`!document.querySelector('#advanced-type').disabled`, 'Scan reset'); } candidates() { return this.evaluate(`Array.from(document.querySelectorAll('.advanced-candidate'),e=>({text:e.textContent,location:e.querySelector('.candidate-address')?.textContent,key:e.dataset.candidateKey}))`); } - async select(index) { await this.click('.advanced-candidate', index); await this.wait(`Number(document.querySelector('#advanced-watch-count').textContent)>0 && !document.querySelector('#advanced-write').disabled`, 'Selected candidate watched'); } - async write(value) { await this.set('#advanced-write-value', value); await this.click('#advanced-write'); await this.wait(`Array.from(document.querySelectorAll('[data-action="restore"]')).some(e=>!e.disabled)`, 'Undo write available'); } + async select(index) { await this.click('[data-workspace="candidates"]'); await this.click('.advanced-candidate', index); await this.wait(`Number(document.querySelector('#advanced-watch-count').textContent)>0 && !document.querySelector('#quick-write').disabled`, 'Selected candidate watched'); await this.click('[data-workspace="watches"]'); } + async write(value) { await this.set('#quick-write-value', value); await this.click('#quick-write'); await this.wait(`Array.from(document.querySelectorAll('[data-action="restore"]')).some(e=>!e.disabled)`, 'Undo write available'); } async restore({ guarded = false } = {}) { await this.click('[data-action="restore"]'); if (guarded) await this.wait(`document.body.innerText.includes('Restore was cancelled')`, 'Guarded undo refusal'); else await this.wait(`Array.from(document.querySelectorAll('[data-action="restore"]')).every(e=>e.disabled)`, 'Undo write completed'); } - async freeze(value) { await this.set('#advanced-write-value', value); await this.click('#advanced-freeze'); await this.wait(`Array.from(document.querySelectorAll('[data-count]')).some(e=>Number(e.textContent)>0)`, 'Freeze enabled'); } + async freeze(value) { await this.set('#quick-write-value', value); await this.click('#quick-freeze'); await this.wait(`Array.from(document.querySelectorAll('[data-count]')).some(e=>Number(e.textContent)>0)`, 'Freeze enabled'); } async stop() { await this.click('[data-action="stop"]'); await this.wait(`Array.from(document.querySelectorAll('[data-count]')).every(e=>Number(e.textContent)===0)`, 'All freezes stopped'); } async undoScan(count) { await this.click('[data-action="undo"]'); await this.wait(`Number(document.querySelector('#advanced-result-count').textContent.replaceAll(',',''))===${count}`, 'Undo scan restored candidates'); } async state() { return this.evaluate(`({watches:Number(document.querySelector('#advanced-watch-count').textContent), count:document.querySelector('#advanced-result-count').textContent, source:document.querySelector('#advanced-instance').value, text:document.body.innerText.slice(-6000)})`); } diff --git a/test/popup-harness-run.js b/test/popup-harness-run.js index c0500ad..feaf8ed 100644 --- a/test/popup-harness-run.js +++ b/test/popup-harness-run.js @@ -12,9 +12,8 @@ function checkRecommendedSorting() { const expectOrder = (label, selector, expected) => { if (JSON.stringify(order(selector)) !== JSON.stringify(expected)) failures.push(label); }; - const bothOrders = (label, expected) => { - expectOrder(`${label} Simple`, ".quick-candidate", expected); - expectOrder(`${label} Advanced`, ".advanced-candidate", expected); + const candidateOrder = (label, expected) => { + expectOrder(label, ".advanced-candidate", expected); }; const setInstances = (avmKind, additional = []) => popupHarnessState.emitPagePayload({ kind: "instanceList", @@ -38,15 +37,15 @@ function checkRecommendedSorting() { { address: 50, type: "i32", value: 4 }, { address: 20, type: "f64", value: 3 }, ]; - if (sort.value !== "recommended") failures.push("Advanced defaults to recommended"); + if (sort.value !== "recommended") failures.push("Results default to recommended"); setInstances("avm1"); showResults(preview); - bothOrders("AVM1", ["f64:20", "f64:40", "u8:10", "u32:30", "i32:50"]); + candidateOrder("AVM1", ["f64:20", "f64:40", "u8:10", "u32:30", "i32:50"]); setInstances("avm2"); const avm2Order = ["i32:50", "u32:30", "f64:20", "f64:40", "u8:10"]; - bothOrders("Updated runtime metadata", avm2Order); + candidateOrder("Updated runtime metadata", avm2Order); showResults(preview); - bothOrders("AVM2", avm2Order); + candidateOrder("AVM2", avm2Order); for (const [mode, expected] of [ ["address", ["u8:10", "f64:20", "u32:30", "f64:40", "i32:50"]], ["value", ["u8:10", "f64:40", "f64:20", "i32:50", "u32:30"]], @@ -57,47 +56,89 @@ function checkRecommendedSorting() { sort.value = mode; sort.dispatchEvent(new Event("change")); expectOrder(`${mode} override`, ".advanced-candidate", expected); - expectOrder(`${mode} leaves Simple recommended`, ".quick-candidate", avm2Order); } sort.value = "recommended"; sort.dispatchEvent(new Event("change")); setInstances("unknown"); showResults(preview); - bothOrders("Unknown", ["u8:10", "f64:20", "u32:30", "f64:40", "i32:50"]); + candidateOrder("Unknown", ["u8:10", "f64:20", "u32:30", "f64:40", "i32:50"]); setInstances("avm1", [{ id: "memory-2", memoryBytes: 4096, looksLikeRuffle: true, avmKind: "avm2" }]); showResults(preview, "memory-2"); - bothOrders("Scan instance metadata", avm2Order); + candidateOrder("Scan instance metadata", avm2Order); setInstances("avm1"); showResults(preview, "memory-1", "avm2"); - bothOrders("Live source metadata supersedes old scan metadata", ["f64:20", "f64:40", "u8:10", "u32:30", "i32:50"]); + candidateOrder("Live source metadata supersedes old scan metadata", ["f64:20", "f64:40", "u8:10", "u32:30", "i32:50"]); setInstances("unknown"); showResults(preview, "memory-1", "unknown"); popupHarnessState.emitPagePayload({ kind: "instanceUpdated", instance: { id: "memory-1", memoryBytes: 4096, looksLikeRuffle: true, avmKind: "avm2", } }); - bothOrders("Delayed source metadata supersedes unknown scan metadata", avm2Order); + candidateOrder("Delayed source metadata supersedes unknown scan metadata", avm2Order); if (document.querySelector("#advanced-avm-type").textContent !== "AVM2") failures.push("Delayed AVM guidance updates"); setInstances("avm1"); showResults([ ...Array.from({ length: 21 }, (_, index) => ({ address: index, type: "i32", value: index })), { address: 100, type: "f64", value: 100 }, ]); - expectOrder("Sort before Simple cap", ".quick-candidate", [ - "f64:100", ...Array.from({ length: 19 }, (_, index) => `i32:${index}`), + expectOrder("Sort retains full preview", ".advanced-candidate", [ + "f64:100", ...Array.from({ length: 21 }, (_, index) => `i32:${index}`), ]); - if (document.querySelectorAll(".advanced-candidate").length !== 22) failures.push("Advanced retains all candidates"); + if (document.querySelectorAll(".advanced-candidate").length !== 22) failures.push("Results retain all candidates"); document.querySelector("#reset-quick-scan").click(); popupHarnessState.resetInstances(); popupHarnessState.sortingFailures = failures; return failures.length === 0; } +function checkNarrowLayout() { + const options = document.querySelector("#scan-options"); + const previous = options.open; + for (const open of [false, true]) { + options.open = open; + if (document.documentElement.scrollWidth > document.documentElement.clientWidth) { + const overflowing = [...document.querySelectorAll("body *")].filter(element => element.getBoundingClientRect().right > document.documentElement.clientWidth + 1).map(element => `${element.tagName}#${element.id}.${element.className}:${Math.round(element.getBoundingClientRect().right)}`).slice(0, 12); + throw new Error(`Horizontal overflow at ${innerWidth}px with options ${open ? "open" : "closed"}: ${overflowing.join(", ")}`); + } + } + options.open = previous; +} + +async function checkUnifiedOptions() { + const ui = (id) => document.getElementById(id); + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + const count = (kind) => popupHarnessState.commands.filter(({ payload }) => payload?.kind === kind).length; + assert(!ui("view-switcher") && !document.querySelector("[data-view]"), "Legacy modes remain"); + assert(!ui("scan-options").open, "Specialized options should start collapsed"); + assert(document.querySelectorAll("#quick-scan, #advanced-scan").length === 1, "Duplicate scan controls remain"); + assert(document.querySelectorAll("#quick-editor, #advanced-editor").length === 1, "Duplicate selected editors remain"); + ui("scan-options").open = true; + ui("advanced-type").value = "f64"; + ui("advanced-type").dispatchEvent(new Event("change")); + ui("advanced-alignment").value = "byte"; + ui("advanced-alignment").dispatchEvent(new Event("change")); + ui("scan-options").open = false; + assert(/Float64/.test(ui("scan-options-summary").textContent) && /byte/i.test(ui("scan-options-summary").textContent), "Collapsed options hide active overrides"); + ui("quick-value").value = "8"; + const before = count("memoryScan"); + ui("quick-scan").click(); + await delay(); await delay(); + const scan = popupHarnessState.commands.filter(({ payload }) => payload?.kind === "memoryScan").at(-1).payload; + assert(count("memoryScan") === before + 1 && scan.type === "f64" && scan.alignment === "byte", "Unified scan must dispatch once using collapsed options"); + assert(!ui("scan-options").open && ui("advanced-type").value === "f64", "Scan changed option disclosure or configuration"); + ui("reset-quick-scan").click(); + await delay(); + ui("advanced-type").value = "smart"; + ui("advanced-type").dispatchEvent(new Event("change")); + ui("advanced-alignment").value = "aligned"; + ui("advanced-alignment").dispatchEvent(new Event("change")); +} + async function checkPlaybackControls() { const ui = (id) => document.getElementById(id); const assert = (ok, message) => { if (!ok) throw new Error(message); }; popupHarnessState.emitMessage({ kind: "quickSession", session: null }); popupHarnessState.resetInstances(); - assert(!ui("game-controls").hidden, "Playback controls are visible in both views"); + assert(!ui("game-controls").hidden, "Playback controls are visible"); assert(ui("pause-while-scanning").checked, "Saved scan pause preference is restored"); assert(!ui("pause-game").disabled && ui("pause-game").textContent === "Pause game", "Supported games expose pause"); ui("pause-game").click(); @@ -128,6 +169,9 @@ async function checkPlaybackControls() { } async function checkJavaScriptSources() { + document.querySelector("#advanced-filter").value = ""; + document.querySelector("#advanced-filter").dispatchEvent(new Event("input")); + document.querySelector('[data-workspace="candidates"]').click(); await checkPlaybackControls(); const ui = (id) => document.getElementById(id); const assert = (ok, message) => { if (!ok) throw new Error(message); }; @@ -136,11 +180,11 @@ async function checkJavaScriptSources() { { id: "js-1", kind: "javascript", displayName: "JavaScript objects", memoryBytes: 0 }, { id: "memory-1", kind: "wasm", memoryBytes: 4096 }, ] }); - ui("quick-instance").value = "0:js-1"; - ui("quick-instance").dispatchEvent(new Event("change")); + ui("advanced-instance").value = "0:js-1"; + ui("advanced-instance").dispatchEvent(new Event("change")); assert(ui("pause-game").disabled && ui("pause-while-scanning").disabled && ui("pause-while-scanning").checked, "Unsupported sources disable pause without clearing the saved preference"); assert(ui("pause-status").textContent === "Pause is available for supported Ruffle games.", "Unsupported pause explains its scope"); - assert(ui("advanced-instance").value === "0:js-1", "Source selectors must agree"); + assert(ui("advanced-instance").value === "0:js-1", "Selected source is retained"); assert(!ui("advanced-type").closest("label").hidden, "JavaScript exposes targeted number formats"); assert(!ui("advanced-type").querySelector('[value="number"]').disabled, "JavaScript allows Number properties"); ui("advanced-type").value = "number"; @@ -160,16 +204,23 @@ async function checkJavaScriptSources() { ui("javascript-load-roots").click(); assert(ui("javascript-root").options.length === 2, "Object picker lists accessible roots"); ui("javascript-root").value = '["game"]'; - ui("advanced-scan").click(); + ui("javascript-root").dispatchEvent(new Event("change")); + assert(ui("scan-options-summary").textContent.includes("Selected object"), "Selected object is missing from collapsed summary"); + ui("javascript-load-roots").click(); + assert(!ui("scan-options-summary").textContent.includes("Selected object"), "Refreshed root picker left a stale selected-object summary"); + ui("javascript-root").value = '["game"]'; + ui("javascript-root").dispatchEvent(new Event("change")); + ui("quick-scan").click(); await delay(); const scan = popupHarnessState.commands.filter(({ payload }) => payload.kind === "memoryScan").at(-1).payload; assert(scan.instanceId === "js-1" && scan.type === "number" && scan.multiplier === 1 && JSON.stringify(scan.rootPath) === '["game"]', "JavaScript scan uses selected object and no multiplier"); assert(scan.pauseWhileScanning === false, "Unsupported scans never request pausing"); assert(ui("quick-status").textContent.includes("incomplete"), "Partial discovery must be visible"); assert(ui("broaden-search").hidden, "JavaScript does not offer byte formats"); - const row = document.querySelector(".quick-candidate"); + const row = document.querySelector(".advanced-candidate"); assert(row.querySelector(".candidate-address").textContent === "game.score", "JavaScript candidates show paths"); row.click(); + checkNarrowLayout(); const watch = popupHarnessState.commands.filter((command) => command.action === "upsertWatch").at(-1)?.watch; assert(watch?.kind === "javascript" && watch.displayPath === "game.score" && watch.path[0] === "game", "Shared watch retains JavaScript identity metadata"); popupHarnessState.emitPagePayload({ kind: "scanResults", requestId: "quick:empty-js", instanceId: "js-1", total: 0, preview: [], coverage: { complete: true, numbers: 0 } }); @@ -191,31 +242,32 @@ setTimeout(async () => { document.querySelector(".popup-header #status-title")?.textContent === "Game inspection available" && !document.querySelector(".connection-state") && !document.querySelector("#memory-summary") && - !document.querySelector("#quick-tools").hidden && + !document.querySelector("#scan-tools").hidden && getComputedStyle(document.querySelector("#quick-max-label")).display === "none" && getComputedStyle(document.querySelector("#quick-editor")).display === "none" && !document.querySelector("#scan-strategy") && !document.querySelector("#open-inspector") && !document.querySelector("#type"); const recommendedSorting = checkRecommendedSorting(); + await checkUnifiedOptions(); if (sidebarMode) { const pin = document.querySelector("#pin-popup"); - const viewSwitcher = document.querySelector("#view-switcher"); + const scanOptions = document.querySelector("#scan-options"); const boundToOriginalTab = document.body.classList.contains("sidebar-panel") && - !viewSwitcher.hidden && + !scanOptions.open && pin.classList.contains("active") && pin.getAttribute("aria-label").includes("Close Hack Engine sidebar") && popupHarnessState.retrievedTabs.length === 1 && popupHarnessState.retrievedTabs[0] === 77 && popupHarnessState.queriedTabs === 0; - viewSwitcher.querySelector('[data-view="advanced"]').click(); + scanOptions.open = true; document.querySelector("#advanced-type").value = "f64"; document.querySelector("#advanced-alignment").value = "byte"; - document.querySelector("#advanced-value").value = "8"; - document.querySelector("#advanced-scan").click(); + document.querySelector("#quick-value").value = "8"; + document.querySelector("#quick-scan").click(); await delay(); await delay(); const advancedCommand = popupHarnessState.commands.find(({ payload }) => @@ -224,43 +276,42 @@ setTimeout(async () => { await delay(280); const advancedRow = document.querySelector(".advanced-candidate"); const advancedScanWorked = - document.body.classList.contains("advanced-active") && - document.querySelector("#quick-tools").hidden && - !document.querySelector("#advanced-tools").hidden && + !document.querySelector("#scan-tools").hidden && advancedCommand?.payload.alignment === "byte" && advancedCommand.payload.multiplier === 1 && advancedRow?.querySelector(".candidate-value")?.textContent === "9" && advancedRow?.querySelector(".candidate-type")?.textContent === "f64" && - document.querySelector("#advanced-scan").textContent === "Next scan"; + document.querySelector("#quick-scan").textContent === "Next scan"; advancedRow?.click(); - document.querySelector("#advanced-set-min").click(); - const advancedMinPreset = Number(document.querySelector("#advanced-write-value").value) === -Number.MAX_VALUE; - document.querySelector("#advanced-set-max").click(); - const advancedMaxPreset = Number(document.querySelector("#advanced-write-value").value) === Number.MAX_VALUE; + checkNarrowLayout(); + document.querySelector("#quick-set-min").click(); + const advancedMinPreset = Number(document.querySelector("#quick-write-value").value) === -Number.MAX_VALUE; + document.querySelector("#quick-set-max").click(); + const advancedMaxPreset = Number(document.querySelector("#quick-write-value").value) === Number.MAX_VALUE; const watchAdded = document.querySelector("#advanced-watch-count").textContent === "1" && document.querySelectorAll(".watch-row").length === 1 && - !document.querySelector("#advanced-editor").hidden; + !document.querySelector("#quick-editor").hidden; document.querySelector("#advanced-filter").value = "missing"; document.querySelector("#advanced-filter").dispatchEvent(new Event("input")); const filterWorked = document.querySelectorAll(".advanced-candidate").length === 0; - viewSwitcher.querySelector('[data-view="simple"]').click(); + scanOptions.open = false; const sharedSession = - !document.querySelector("#quick-tools").hidden && + !document.querySelector("#scan-tools").hidden && document.querySelector("#quick-scan").textContent === "Next scan" && - document.querySelector("#quick-result-count").textContent === "1"; - viewSwitcher.querySelector('[data-view="advanced"]').click(); + document.querySelector("#advanced-result-count").textContent === "1"; + scanOptions.open = true; document.querySelector('[data-workspace="watches"]').click(); const watchWorkspace = !document.querySelector("#advanced-watch-pane").hidden && document.querySelector("#advanced-candidate-pane").hidden; - document.querySelector("#reset-advanced-scan").click(); + document.querySelector("#reset-quick-scan").click(); await delay(); const watchSurvivedReset = document.querySelector("#advanced-watch-count").textContent === "1" && document.querySelectorAll(".watch-row").length === 1 && document.querySelectorAll(".advanced-candidate").length === 0 && - document.querySelector("#advanced-scan").textContent === "First scan"; + document.querySelector("#quick-scan").textContent === "First scan"; document.querySelector("#how-it-works").click(); await delay(); @@ -273,8 +324,8 @@ setTimeout(async () => { const sidebarClosed = popupHarnessState.sidebarCloseCount === 1 && !popupHarnessState.closed; const javascriptSources = await checkJavaScriptSources(); popupHarnessResult.textContent = javascriptSources && rendered && recommendedSorting && boundToOriginalTab && advancedScanWorked && advancedMinPreset && advancedMaxPreset && watchAdded && filterWorked && sharedSession && watchWorkspace && watchSurvivedReset && openedInOriginalWindow && sidebarClosed - ? "PASS: Firefox sidebar shares Simple and Advanced scans, live candidates, watches, and tab-bound docking." - : "FAIL: Firefox sidebar Advanced mode did not preserve its scan, candidates, watches, or docked state."; + ? "PASS: Firefox sidebar exposes unified scans, live candidates, watches, and tab-bound docking." + : "FAIL: Firefox sidebar unified controls did not preserve its scan, candidates, watches, or docked state."; return; } @@ -282,7 +333,7 @@ setTimeout(async () => { const pin = document.querySelector("#pin-popup"); const boundToOriginalTab = document.body.classList.contains("popout-window") && - !document.querySelector("#view-switcher").hidden && + !document.querySelector("#view-switcher") && !pin.classList.contains("active") && pin.getAttribute("aria-label").includes("Dock Hack Engine") && document.querySelector("#pop-out-window").hidden && @@ -306,7 +357,7 @@ setTimeout(async () => { } const pin = document.querySelector("#pin-popup"); - const nativePopupStayedSimple = document.querySelector("#view-switcher").hidden; + const nativePopupUnified = !document.querySelector("#view-switcher") && !document.querySelector("#scan-options").open; pin.click(); await delay(); const sidebarUrl = new URL(popupHarnessState.sidebarPanels[0]?.panel || location.href); @@ -349,12 +400,12 @@ setTimeout(async () => { document.querySelector("#quick-scan").click(); await delay(); await delay(); - const scanCommand = popupHarnessState.commands.find(({ payload }) => payload.kind === "memoryScan"); + const scanCommand = popupHarnessState.commands.filter(({ payload }) => payload.kind === "memoryScan").at(-1); const automaticScan = scanCommand?.payload.type === "smart" && scanCommand.payload.rawValue === "8" && - document.querySelector("#quick-result-count").textContent === "1" && - document.querySelectorAll(".quick-candidate").length === 1 && + document.querySelector("#advanced-result-count").textContent === "1" && + document.querySelectorAll(".advanced-candidate").length === 1 && document.querySelector("#quick-scan").textContent === "Next scan"; await delay(); const liveCandidateRefresh = @@ -362,18 +413,23 @@ setTimeout(async () => { popupHarnessState.commands.some(({ payload }) => payload.kind === "readValues") && document.querySelector(".candidate-value").textContent === "9"; - document.querySelector(".quick-candidate").click(); + document.querySelector(".advanced-candidate").click(); + checkNarrowLayout(); document.querySelector("#quick-set-min").click(); const quickMinPreset = document.querySelector("#quick-write-value").value === "-2147483648"; document.querySelector("#quick-set-max").click(); const quickMaxPreset = document.querySelector("#quick-write-value").value === "2147483647"; document.querySelector("#quick-write-value").value = "999"; + const writesBefore = popupHarnessState.commands.filter(({ payload }) => payload?.kind === "writeValue").length; + const freezesBefore = popupHarnessState.commands.filter(({ payload }) => payload?.kind === "setFreeze").length; document.querySelector("#quick-write").click(); document.querySelector("#quick-freeze").click(); await delay(); const writeCommand = popupHarnessState.commands.find(({ payload }) => payload.kind === "writeValue"); const freezeCommand = popupHarnessState.commands.find(({ payload }) => payload.kind === "setFreeze"); const typedActions = + popupHarnessState.commands.filter(({ payload }) => payload?.kind === "writeValue").length === writesBefore + 1 && + popupHarnessState.commands.filter(({ payload }) => payload?.kind === "setFreeze").length === freezesBefore + 1 && writeCommand?.payload.type === "i32" && writeCommand.payload.address === 4096 && writeCommand.payload.rawValue === "999" && @@ -389,7 +445,7 @@ setTimeout(async () => { const javascriptSources = await checkJavaScriptSources(); popupHarnessResult.textContent = - javascriptSources && rendered && recommendedSorting && nativePopupStayedSimple && pinDocked && firstPopoutOpened && secondPopoutReused && automaticScan && liveCandidateRefresh && quickMinPreset && quickMaxPreset && typedActions && helpOpened + javascriptSources && rendered && recommendedSorting && nativePopupUnified && pinDocked && firstPopoutOpened && secondPopoutReused && automaticScan && liveCandidateRefresh && quickMinPreset && quickMaxPreset && typedActions && helpOpened ? "PASS: compact toolbar popup, live candidates, Firefox sidebar docking, pop-out reuse, and typed quick-scan actions work." : "FAIL: toolbar quick-scan behavior did not match the active Ruffle state."; } catch (error) { popupHarnessResult.textContent = `FAIL: ${error.stack || error}`; } diff --git a/workspace-controls.js b/workspace-controls.js index 7fce967..07e9a31 100644 --- a/workspace-controls.js +++ b/workspace-controls.js @@ -19,7 +19,7 @@ document.querySelector(".header-copy")?.append(sessionTarget); const action = (name) => el(`[data-action="${name}"]`); const undoButtons = [action("undo")]; - const scanRows = document.querySelectorAll("#quick-tools > .scan-actions, #advanced-tools > .scan-actions"); + const scanRows = document.querySelectorAll("#scan-tools > .scan-actions"); if (scanRows.length) { const original = undoButtons.pop(); for (const row of scanRows) { @@ -38,7 +38,7 @@ const stopButtons = []; const restoreTemplate = action("restore"); const stopTemplate = action("stop"); - for (const write of document.querySelectorAll("#quick-write, #advanced-write")) { + for (const write of document.querySelectorAll("#quick-write")) { const button = restoreTemplate.cloneNode(true); button.className = "compact-action"; write.after(button); @@ -55,13 +55,6 @@ heading.append(button); stopButtons.push(button); } - const quickEditor = document.querySelector("#quick-editor"); - if (quickEditor) { - const button = stopTemplate.cloneNode(true); - button.className = "compact-action stop-freezes"; - quickEditor.after(button); - stopButtons.push(button); - } restoreTemplate.remove(); stopTemplate.remove(); el(".session-actions").remove();