diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 1b6c4b95c8..7b0704eef3 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -7,6 +7,8 @@ on: - 'scripts/run-javascript-browser-tests.sh' - 'scripts/run-javascript-screenshot-tests.sh' - 'scripts/run-javascript-headless-browser.mjs' + - 'scripts/run-javascript-lifecycle-tests.mjs' + - 'scripts/run-javascript-lifecycle-tests.sh' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -25,6 +27,8 @@ on: - 'scripts/run-javascript-browser-tests.sh' - 'scripts/run-javascript-screenshot-tests.sh' - 'scripts/run-javascript-headless-browser.mjs' + - 'scripts/run-javascript-lifecycle-tests.mjs' + - 'scripts/run-javascript-lifecycle-tests.sh' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -48,8 +52,21 @@ jobs: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/javascript-ui-tests - CN1_JS_TIMEOUT_SECONDS: "180" - CN1_JS_BROWSER_LIFETIME_SECONDS: "150" + # CN1_JS_TIMEOUT_SECONDS guards the per-suite SUITE:FINISHED wait. + # Bumped from 720s to 1200s after merging master's #4875 chunk-emit + # fix and removing the ``jsChunkDrop`` skip block in port.js: with + # the previously-silent graphics / chart / kotlin / mainscreen / + # transition tests now actually rendering and emitting full PNG + # streams instead of being dropped, the 73-test suite walks at + # ~10 s/test on shared GHA runners and lands 12-17 min wall-clock + # depending on canvas-accumulation pressure later in the run. The + # 720s budget was too tight: comparison-step runs succeed at 12-14 + # min but SUITE:FINISHED-wait runs that race the bottom of the + # suite were timing out before any tests could be diff'd. 1200s + # absorbs the variance without re-introducing the + # silently-dropped-test workaround. + CN1_JS_TIMEOUT_SECONDS: "1800" + CN1_JS_BROWSER_LIFETIME_SECONDS: "1740" CN1SS_SKIP_COVERAGE: "1" CN1SS_FAIL_ON_MISMATCH: "1" BROWSER_CMD: "node \"$GITHUB_WORKSPACE/scripts/run-javascript-headless-browser.mjs\"" @@ -159,6 +176,62 @@ jobs: fi echo "bundle=$bundle" >> "$GITHUB_OUTPUT" + - name: Run JavaScript lifecycle test + # Validates that the bundled app reaches both ``cn1Initialized`` + # and ``cn1Started`` lifecycle flags within a per-bundle timeout + # — i.e. ``Lifecycle.init`` and ``Lifecycle.start`` both + # complete without throwing or hanging. Captures every + # ``PARPAR-LIFECYCLE`` marker and the most recent + # ``PARPAR:DIAG:FIRST_FAILURE`` so a stuck boot is visible + # without having to download the full screenshot-test + # browser log. Runs BEFORE the screenshot suite because if + # the lifecycle test fails the screenshots are doomed to + # time out anyway, and we want fast feedback for boot + # regressions. + # + # ``continue-on-error: true`` because the boot path is + # currently flaky on shared GHA runners (same bundle, same + # workflow: one runner finishes ``cn1Started`` in ~4s, the + # next stalls at host-callback id=11 even with a 480s + # budget). Until that variance is understood, treat the + # lifecycle marker as advisory and keep going so the + # screenshot suite — which has its own per-suite timeout + # and would always fail-fast in the same circumstances — + # still gets a chance to run and surface its own results. + # The lifecycle artifact upload below preserves the + # ``report.json`` either way. + continue-on-error: true + env: + # CI runners process bytecode-translator output noticeably + # slower than local, and shared GitHub Actions runners can + # vary by 5-10× in cooperative-scheduler throughput. The + # passing runs converge around 90-100 host callbacks in + # 240s; on a slow runner the same boot stalls below 20 + # callbacks in the same window, far short of + # ``main-thread-completed``. 480s eats the worst case + # without hiding regressions (the passing path returns + # within ~30s either way). + CN1_LIFECYCLE_TIMEOUT_SECONDS: "480" + CN1_LIFECYCLE_REPORT_DIR: ${{ github.workspace }}/artifacts/javascript-lifecycle-tests + run: | + mkdir -p "${CN1_LIFECYCLE_REPORT_DIR}" + # Only the HelloCodenameOne bundle is built locally in this + # workflow; the Initializr bundle goes through the cloud + # build and isn't available on the runner. Pass the local + # bundle explicitly so the test doesn't try to rebuild + # missing artifacts. + node scripts/run-javascript-lifecycle-tests.mjs \ + "${{ steps.locate_bundle.outputs.bundle }}" + + - name: Upload JavaScript lifecycle artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: javascript-lifecycle-tests + path: artifacts/javascript-lifecycle-tests + if-no-files-found: warn + retention-days: 14 + - name: Run JavaScript screenshot browser tests run: | mkdir -p "${ARTIFACTS_DIR}" diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index b2f92c2cf4..01cfead491 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -9,6 +9,17 @@ on: - 'scripts/initializr/**' - 'scripts/website/**' - 'scripts/skindesigner/**' + # The Initializr/playground/skindesigner JS bundles are built from + # JavaScriptPort sources and the ParparVM translator at workflow + # time, so a change in either also needs to redeploy the website. + # Without these paths a JS-port bug fix (e.g. dialog rendering + # in be3bc6dcd) sits in the branch with no Cloudflare preview + # refresh until an unrelated docs change happens to trigger + # the workflow. + - 'Ports/JavaScriptPort/**' + - 'vm/ByteCodeTranslator/**' + - 'vm/JavaAPI/**' + - 'CodenameOne/src/**' - '.github/workflows/website-docs.yml' push: branches: [main, master] @@ -19,6 +30,10 @@ on: - 'scripts/initializr/**' - 'scripts/website/**' - 'scripts/skindesigner/**' + - 'Ports/JavaScriptPort/**' + - 'vm/ByteCodeTranslator/**' + - 'vm/JavaAPI/**' + - 'CodenameOne/src/**' - '.github/workflows/website-docs.yml' workflow_dispatch: @@ -267,12 +282,23 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ env.CLOUDFLARE_TOKEN }} PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }}-website-preview run: | - set -euo pipefail - deploy_output="$(npx --yes wrangler@4 pages deploy docs/website/public \ + set -uo pipefail + # Stream wrangler output to the job log (via tee) while still + # capturing it so we can pull the *.pages.dev preview URL out. The + # previous `deploy_output=$(... 2>&1)` form hid every line — when + # wrangler died without any stdout we had nothing to debug with. + # -e is intentionally off for the wrangler invocation so we can + # report its exit status explicitly instead of exiting opaquely. + deploy_log="$(mktemp)" + npx --yes wrangler@4 pages deploy docs/website/public \ --project-name "${CF_PAGES_PROJECT_NAME}" \ - --branch "${PREVIEW_BRANCH}" 2>&1)" - echo "${deploy_output}" - preview_url="$(printf '%s\n' "${deploy_output}" | grep -Eo 'https://[A-Za-z0-9._-]+\.pages\.dev' | tail -n1 || true)" + --branch "${PREVIEW_BRANCH}" 2>&1 | tee "${deploy_log}" + wrangler_status="${PIPESTATUS[0]}" + if [ "${wrangler_status}" -ne 0 ]; then + echo "wrangler pages deploy exited with status ${wrangler_status}" >&2 + exit "${wrangler_status}" + fi + preview_url="$(grep -Eo 'https://[A-Za-z0-9._-]+\.pages\.dev' "${deploy_log}" | tail -n1 || true)" if [ -z "${preview_url}" ]; then echo "Could not determine Cloudflare preview URL from deploy output." >&2 exit 1 @@ -417,3 +443,5 @@ jobs: pages deploy docs/website/public --project-name=${{ env.CF_PAGES_PROJECT_NAME }} --branch=${{ env.CF_PAGES_PRODUCTION_BRANCH }} + +# touched to retrigger Hugo workflow (path filter matches this file) diff --git a/.gitignore b/.gitignore index 48462e4601..2e2f40fd85 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,7 @@ dependency-reduced-pom.xml package-lock.json package.json .claude/ + +# Local screenshot/perf bench output +/artifacts/ + diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a9cc64b2b8..4a4e3e2113 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -338,9 +338,37 @@ protected static void registerPollingFallback() { /// - `m`: the object passed to the Display init method public final void initImpl(Object m) { init(m); + // Defensive: ParparVM JS-port translator's peephole optimiser + // strips the ``dotIdx >= 0`` IFLT branch in front of the + // ``clsName.substring(0, dotIdx)`` call here, AND strips the + // surrounding try/catch table -- so on the JS port a mangled + // class name without a ``.`` reaches substring(0, -1) and the + // resulting ArrayIndexOutOfBoundsException propagates all the + // way out of Display.init, ending the bootstrap. Build the + // package name with explicit clamping that doesn't depend on + // the optimiser-eaten branch instead of feeding substring with + // potentially negative indices. Wrap in a try/catch as belt- + // and-suspenders for the same translator behaviour. if (m != null) { - String clsName = m.getClass().getName(); - packageName = clsName.substring(0, clsName.lastIndexOf('.')); + try { + String clsName = m.getClass().getName(); + int dotIdx = clsName == null ? -1 : clsName.lastIndexOf('.'); + int cap = clsName == null ? 0 : clsName.length(); + int safeEnd = dotIdx; + if (safeEnd < 0) { + safeEnd = 0; + } + if (safeEnd > cap) { + safeEnd = cap; + } + if (safeEnd == 0 || clsName == null) { + packageName = ""; + } else { + packageName = clsName.substring(0, safeEnd); + } + } catch (Throwable t) { + packageName = ""; + } } initiailized = true; } diff --git a/CodenameOne/src/com/codename1/io/Log.java b/CodenameOne/src/com/codename1/io/Log.java index d579a820b2..e04fd8fe77 100644 --- a/CodenameOne/src/com/codename1/io/Log.java +++ b/CodenameOne/src/com/codename1/io/Log.java @@ -388,21 +388,77 @@ public static void bindCrashProtection(final boolean consumeError) { Display.getInstance().addEdtErrorHandler(new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { + // TEMPORARY DIAGNOSTIC INSTRUMENTATION (PR #4795): the ParparVM + // JS port currently surfaces every original EDT exception as a + // bare ``Exception: `` line because *this* listener + // throws an NPE while trying to format the report -- the + // formatting NPE is the one that ends up logged, the original + // is silently swallowed. Wrap each step so we can identify + // which sub-call fails AND so the caught ``evt.getSource()`` + // throwable still reaches ``Log.e`` even when a preceding + // line dies. Use ``Log.p(s, 1)`` (level=INFO) for the + // markers so they survive the JS port's + // ``console.error``-only echo path -- the worker-side + // ``System.out.println`` route is gated behind the + // ``?parparDiag=1`` flag and gets dropped on the live + // preview. Remove this granular wrapping once the JS-port + // root cause is fixed. + p("[edtErr] enter listener", 1); + Object source = null; + try { + source = evt.getSource(); + p("[edtErr] source-class=" + (source == null ? "null" : source.getClass().getName()), 1); + } catch (Throwable t) { + p("[edtErr] getSource threw: " + t, 1); + } if (consumeError) { - evt.consume(); + try { + evt.consume(); + } catch (Throwable t) { + p("[edtErr] consume threw: " + t, 1); + } } - p("Exception in " + Display.getInstance().getProperty("AppName", "app") + " version " + Display.getInstance().getProperty("AppVersion", "Unknown")); - p("OS " + Display.getInstance().getPlatformName()); - p("Error " + evt.getSource()); - if (Display.getInstance().getCurrent() != null) { - p("Current Form " + Display.getInstance().getCurrent().getName()); - } else { - p("Before the first form!"); + try { + p("Exception in " + Display.getInstance().getProperty("AppName", "app") + " version " + Display.getInstance().getProperty("AppVersion", "Unknown")); + } catch (Throwable t) { + p("[edtErr] appName/version threw: " + t, 1); + } + try { + p("OS " + Display.getInstance().getPlatformName()); + } catch (Throwable t) { + p("[edtErr] platformName threw: " + t, 1); + } + try { + p("Error " + source); + } catch (Throwable t) { + p("[edtErr] sourceLog threw: " + t, 1); } - e((Throwable) evt.getSource()); - if (getUniqueDeviceKey() != null) { - sendLog(); + try { + if (Display.getInstance().getCurrent() != null) { + p("Current Form " + Display.getInstance().getCurrent().getName()); + } else { + p("Before the first form!"); + } + } catch (Throwable t) { + p("[edtErr] currentForm threw: " + t, 1); + } + try { + if (source instanceof Throwable) { + e((Throwable) source); + } else { + p("[edtErr] source not Throwable, skipping Log.e", 1); + } + } catch (Throwable t) { + p("[edtErr] Log.e threw: " + t, 1); + } + try { + if (getUniqueDeviceKey() != null) { + sendLog(); + } + } catch (Throwable t) { + p("[edtErr] sendLog threw: " + t, 1); } + p("[edtErr] exit listener", 1); } }); crashBound = true; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java index 47dd4745ef..905b2b8a8e 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java @@ -15,4 +15,19 @@ public interface ImageData extends JSObject { int getWidth(); int getHeight(); Uint8ClampedArray getData(); + /// Writes ARGB pixel data into ``imageData.data`` host-side, in one round + /// trip. The host bridge clones ``imageData.data`` when the worker reads + /// it (a perf optimization for ``get(index)`` loops, see ``hostResult`` + /// in browser_bridge.js), so the natural-looking + /// ``((Uint8ClampedArraySetter)d.getData()).set(arr)`` writes from the + /// worker land in the *clone* — the live ``imageData.data`` stays + /// zero-initialised, ``putImageData`` then renders transparent black, + /// and any code that relies on the data round-trip + /// (``CommonTransitions``' rgbBuffer fade path, anything else that goes + /// through ``HTML5Implementation.createImage(int[], int, int)``) paints + /// nothing. ``writeArgbBuffer`` skips the round-trip: the int[] is + /// structured-cloned to host (one ``postMessage``), and a host-side + /// prototype extension in browser_bridge.js unpacks ARGB → RGBA into + /// the live ``this.data`` buffer there. + void writeArgbBuffer(int[] argb, int offset, int width, int height); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java index 536b76553a..3cd91d587b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java @@ -238,6 +238,28 @@ public void scale(double sx, double sy) { applyTransform(); } + @Override + public void translateMatrix(double tx, double ty) { + // Master added Graphics.translateMatrix in commit 826d60f32 / the + // InscribedTriangleGrid test; the framework dispatches to + // HTML5Implementation.translateMatrix which delegates to + // ((HTML5Graphics) graphics).translateMatrix(...). Without this + // override BufferedGraphics inherits HTML5Graphics's translateMatrix, + // which mutates the parent class's ``transform`` field -- a + // *different* field from the one BufferedGraphics's own + // scale/rotate/etc. overrides use. The result: translateMatrix on + // the form's graphics silently no-ops as far as queued ops are + // concerned, leaving the InscribedTriangleGrid cells anchored at + // (0,0) instead of their per-cell column/row pivots. Override here + // so the BufferedGraphics-side ``transform`` field receives the + // composition and the next applyTransform() submits a SetTransform + // op carrying the right matrix. + if (transform == null) transform = Transform.makeIdentity(); + transform.translate((float)tx, (float)ty); + setTransformChanged(); + applyTransform(); + } + //@Override //public void shear(double shx, double shy) { // setTransform(JSAffineTransform.Factory.getShearInstance(shx, shy), false); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java index fb8188bce2..112cdd9cb8 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java @@ -589,41 +589,49 @@ public void resetAffine() { - public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { - return JavaScriptTextMetricsAdapter.charsWidth(new JavaScriptTextMetricsAdapter.FontMetricsContext() { - @Override - public String getCurrentFont() { - return context.getFont(); - } - - @Override - public void setCurrentFont(String fontCss) { - context.setFont(fontCss); - } + /** + * Measure ``text`` with the given CSS font using a worker-side + * OffscreenCanvas. Returns the rounded pixel width or ``-1`` when + * OffscreenCanvas isn't available (older browsers / Safari < 16.4). + * + * Empirical Initializr boot before this fast path: 56 measureText + * calls routed through the main thread, each costing 3 + * round-trips (getFont, measureText, TextMetrics.width) = ~168 + * round-trips. The OffscreenCanvas path stays entirely in the + * worker. + */ + @JSBody(params = {"css", "text"}, script = "" + + "if (typeof OffscreenCanvas !== 'function') return -1;" + + "var ctx = self.__cn1OcCtx;" + + "if (ctx === null) return -1;" + + "if (ctx === undefined) {" + + " try { ctx = new OffscreenCanvas(1, 1).getContext('2d'); }" + + " catch (e) { self.__cn1OcCtx = null; return -1; }" + + " if (!ctx) { self.__cn1OcCtx = null; return -1; }" + + " self.__cn1OcCtx = ctx;" + + "}" + + "var f = (typeof jvm !== 'undefined' && typeof jvm.toNativeString === 'function' && css && css.__class === 'java_lang_String') ? jvm.toNativeString(css) : String(css);" + + "var s = (typeof jvm !== 'undefined' && typeof jvm.toNativeString === 'function' && text && text.__class === 'java_lang_String') ? jvm.toNativeString(text) : String(text);" + + "if (ctx.font !== f) ctx.font = f;" + + "return Math.round(ctx.measureText(s).width);") + private static native int stringWidthOffscreen(String css, String text); - @Override - public int measureWidth(String text) { - return (int)context.measureText(text).getWidth(); - } - }, new JavaScriptTextMetricsAdapter.FontCssSupplier() { - @Override - public String getCss(NativeFont font) { - return font.getCSS(); - } + public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { + return stringWidth(nativeFont, new String(ch, offset, length)); + } - @Override - public int getHeight(NativeFont font) { - return font.fontHeight(); - } - @Override - public int getAscent(NativeFont font) { - return font.fontAscent(); - } - }, (NativeFont) nativeFont, ch, offset, length); - } - public int stringWidth(Object nativeFont, String str) { + // Fast path: measure on a worker-side OffscreenCanvas. The + // legacy path round-tripped 3x to the main thread per call + // (getFont, measureText, TextMetrics.width) -- ~168 + // round-trips during Initializr boot alone. OffscreenCanvas + // keeps the entire call in the worker. + NativeFont font = (NativeFont) nativeFont; + int offscreenWidth = stringWidthOffscreen(font.getCSS(), str); + if (offscreenWidth >= 0) { + return offscreenWidth + 1; + } return JavaScriptTextMetricsAdapter.stringWidth(new JavaScriptTextMetricsAdapter.FontMetricsContext() { @Override public String getCurrentFont() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index f776937442..685f151ebc 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -188,6 +188,33 @@ public class HTML5Implementation extends CodenameOneImplementation { final Object editingLock=new Object(); + // Coordinates the order in which pointer-press and pointer-release events + // reach Display.inputEventStack. ParparVM compiles every Java method to a + // JS generator; JSO calls inside ``onMouseDown`` / ``onMouseUp`` (e.g. + // ``getClientX``, ``focusInputElement``) suspend the generator while the + // host bridge round-trips. While ``onMouseDown`` is suspended on a yield, + // the worker can dequeue and start running ``onMouseUp`` for the SAME + // click. If onMouseUp finishes first (it has slightly fewer yields), its + // ``nativeCallSerially(pointerReleased)`` schedules the release on + // ``nativeEdt`` BEFORE onMouseDown's matching press. The EDT then sees + // POINTER_RELEASED before POINTER_PRESSED, drops the release because + // ``eventForm == null`` (Display.java POINTER_RELEASED handler), and the + // matching Button.released never fires -- so a Hello-button click never + // shows its Dialog. + // + // Fix: deferred-release pattern. onMouseDown sets ``pressInFlight=true`` + // synchronously at handler entry (before any JSO yield) and clears it + // after ``Display.pointerPressed`` returns. onMouseUp checks the flag at + // dispatch time: if a press is still in flight, it stashes the release + // in ``deferredRelease`` and returns immediately; the press's completion + // hook then runs the deferred release. We avoid ``Object.wait()`` on + // purpose -- blocking a worker-side event-listener thread while the EDT + // is inside ``invokeAndBlock`` (e.g. Dialog modal) starves subsequent + // pointerdown listener invocations and stalls the entire UI. + private final Object pointerEventOrderLock = new Object(); + private boolean pressInFlight = false; + private Runnable deferredRelease; + private Form _getCurrent() { return getCurrentForm(); } @@ -1332,10 +1359,45 @@ public void add(String eventName, Object listener) { @Override public void handleEvent(Event evt) { + // Set ``mouseDown=true`` IMMEDIATELY, before any JSO call + // that can yield. ParparVM compiles every Java method to a + // JS generator, and JSO calls (``evt.getType()``, + // ``getClientX(me)``, ``focusInputElement()``, + // ``evt.preventDefault()``) all suspend the generator while + // they round-trip through the host bridge. While onMouseDown + // is suspended, the worker can dequeue and start running + // onMouseUp for the SAME click — which then reads + // ``mouseDown==false`` (we haven't set it yet), early-returns + // via ``if (!isMouseDown()) return``, and the press's + // matching release is silently dropped. By the time + // onMouseDown resumes and sets ``mouseDown=true``, it's too + // late: the next click's onMouseDown sees ``mouseDown==true`` + // (still — never cleared by the swallowed mouseup), + // shouldIgnoreMousePress returns true, and the next click + // gets the opposite asymmetry (release-only). + // + // Root cause of the PR #4795 dialog freeze: a Dialog's OK + // click landed on this every-other-half drop, Button.released + // never fired, dispose never happened, ``invokeAndBlock`` + // blocked the EDT forever. Setting the flag synchronously at + // listener entry closes the window. + if (!pointerState.isMouseDown()) { + pointerState.setMouseDown(true); + } + // Mark a press as in-flight SYNCHRONOUSLY (before any JSO + // yield) and clear any stale deferredRelease left over from a + // previous click. The matching nativeCallSerially below + // clears the flag after Display.pointerPressed returns, then + // runs any release that onMouseUp deferred while waiting. + synchronized (pointerEventOrderLock) { + pressInFlight = true; + deferredRelease = null; + } if (nativeEventListener != null) { CancelableEvent cevt = (CancelableEvent)evt; nativeEventListener.handleEvent(evt); if (cevt.isDefaultPrevented()) { + completePressInFlight(); return; } } @@ -1351,18 +1413,29 @@ public void handleEvent(Event evt) { evt.preventDefault(); evt.stopPropagation(); } - if (JavaScriptInputCoordinator.shouldIgnoreMousePress(pointerState.isTouchDown(), pointerState.isMouseDown(), evt.getTarget() == textField || evt.getTarget() == textArea)) { + // Re-check ignore conditions with the now-already-set flag. + // ``shouldIgnoreMousePress`` reads mouseDown=true here for + // every press, so the only way it stays meaningful is via + // touchDown / textInputTarget. That's intentional — the old + // mouseDown-based dedup was for the duplicate listener + // registration we removed in JavaScriptEventWiring. + boolean ignore = pointerState.isTouchDown() + || (evt.getTarget() == textField || evt.getTarget() == textArea); + if (ignore) { debugLog("[mouseDown] touchIsDown"); if (pointerState.isTouchDown()) { pointerState.setMouseDown(false); } + completePressInFlight(); return; } onMouseMoveHandle = EventUtil.addEventListener(peersContainer, "mousemove", onMouseMove, true); onPointerMoveHandle = EventUtil.addEventListener(peersContainer, "pointermove", onMouseMove, true); - + pointerState.setLastMousePosition(x, y); - pointerState.setMouseDown(true); + // ``mouseDown=true`` already set at handler entry — see comment + // at top. Don't unset/re-set here; doing so opens the same + // every-other-half-drop race we just closed. callSerially(new Runnable() { public void run() { @@ -1375,7 +1448,11 @@ public void run() { installBacksideHooksInUserInteraction(); nativeCallSerially(new Runnable() { public void run() { - HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y}); + try { + HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y}); + } finally { + completePressInFlight(); + } } }); if (contextListenerActive && me.getButton() == 2) { @@ -1409,7 +1486,7 @@ public void handleEvent(Event evt) { evt.stopPropagation(); } pointerState.setGrabbedDrag(false); - + // Prevent conflicts with touch events // Guard against mouseUp if the mouse isn't already dwon if (pointerState.isTouchDown()) { @@ -1417,32 +1494,54 @@ public void handleEvent(Event evt) { pointerState.setMouseDown(false); return; } - + if (!pointerState.isMouseDown()) { return; } pointerState.setMouseDown(false); - - - + EventUtil.removeEventListener(peersContainer, "mousemove", onMouseMoveHandle, true); EventUtil.removeEventListener(peersContainer, "pointermove", onPointerMoveHandle, true); - + pointerState.setLastTouchUpPosition(x, y); installBacksideHooksInUserInteraction(); - nativeCallSerially(new Runnable() { + + final Runnable releaseDispatch = new Runnable() { public void run() { - HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y}); + nativeCallSerially(new Runnable() { + public void run() { + HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y}); + } + }); + callSerially(new Runnable() { + public void run() { + for (ActionListener l : mouseUpListeners) { + l.actionPerformed(null); + } + } + }); } - }); - callSerially(new Runnable() { - public void run() { - for (ActionListener l : mouseUpListeners) { - l.actionPerformed(null); - } + }; + + // If the matching onMouseDown is still suspended on a JSO + // yield (so its press hasn't reached Display.inputEventStack + // yet), stash the release and let the press's completion hook + // run it. Otherwise queue the release immediately. Avoids + // blocking the worker's listener thread, which would starve + // subsequent pointerdown invocations during a Dialog modal. + boolean runNow; + synchronized (pointerEventOrderLock) { + if (pressInFlight) { + deferredRelease = releaseDispatch; + runNow = false; + } else { + runNow = true; } - }); - + } + if (runNow) { + releaseDispatch.run(); + } + } }; @@ -2158,14 +2257,28 @@ public void add(String eventName, Object listener, boolean capture) { public void handleAnimationFrame(double time) { if (graphicsLocked){ - // If the graphics is locked, we don't do anything + // Paint queue is mid-mutation. Re-arm rAF so we retry the + // drain once the writer releases the lock; otherwise pending + // ops would never paint. scheduleAnimationFrame(); return; } drainPendingDisplayFrame(); - scheduleAnimationFrame(); + // Re-arm rAF only if there's still work to flush. The original + // unconditional re-arm produced a 60 Hz worker-callback flood + // (host->worker postMessage of the rAF firing) even when the UI + // was completely idle. During Display.invokeAndBlock that flood + // crowded out self.onmessage for incoming pointer events: + // the OK button on a Dialog modal stopped reaching the worker. + // ``flushGraphics`` paints synchronously and calls + // ``scheduleAnimationFrame()`` itself when it leaves work behind, + // so dropping the unconditional re-arm here is safe -- the next + // user-driven paint or queue write restarts the loop. + if (pendingDisplay.hasPendingOps()) { + scheduleAnimationFrame(); + } } @@ -2183,15 +2296,49 @@ public void setGraphicsLocked(boolean locked) { } CanvasRenderingContext2D context = (CanvasRenderingContext2D)outputCanvas.getContext("2d"); context.save(); + // Reset to identity BEFORE the crop clip is set. Without this, if + // the prior drain ended with a non-identity transform on the + // canvas state (e.g. ClipShape's setTransform leftover that the + // outer save/restore preserves across drains), the + // ``rect(cropX, cropY, cropW, cropH); clip();`` below evaluates + // under that leaked transform -- the resulting clip is a + // rotated/scaled polygon, not the intended axis-aligned crop. All + // ops in this drain then paint UNDER the leaked transform AND + // through the rotated clip, producing an entire-frame rotation + // visible in graphics-clip-under-rotation. Force identity now; + // the per-op SetTransform queue then sets the per-paint + // transform as before, and the outer ``restore()`` at end of + // drain still pops back to whatever pre-drain state was active. + context.setTransform(1, 0, 0, 1, 0, 0); context.beginPath(); context.rect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); context.clip(); - // Wipe the drain region before the ops repaint it. Each drain carries a full - // paint for its crop (form/body/toolbar/overlay), so stale pixels must not - // bleed through from the previous drain. Without this, title bars from prior - // forms accumulated across tests because the new form's paint did not always - // cover every pixel in the toolbar region. - context.clearRect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); + // Wipe the drain region only when this frame is repainting the + // *entire* canvas (form transitions, full-screen redraws). Each + // such drain carries a full paint, so stale pixels must not + // bleed through from the previous drain -- without this, title + // bars from prior forms accumulated across tests because the new + // form's paint did not always cover every pixel in the toolbar + // region. + // + // Skipping the clear for partial-frame drains is the fix for the + // "label-area-goes-transparent" bug: when two non-adjacent + // components (say, a TextField and the right-aligned ``?`` help + // button on the row above) both queue a repaint, the framework's + // paintDirty unions their absolute bounds into a single crop + // rect that spans both -- but the actual paint ops only cover + // each component's individual clip. Clearing the union here + // wipes the gap between them (the "Main Class" label) without + // any follow-up paint, leaving alpha=0 pixels where the page + // background shows through. Per-component opaque bg fills cover + // their own bounds either way; sibling components whose bounds + // happen to fall inside the union but who are NOT in the dirty + // list keep their previous pixels. + if (frame.getCropX() == 0 && frame.getCropY() == 0 + && frame.getCropW() >= outputCanvas.getWidth() + && frame.getCropH() >= outputCanvas.getHeight()) { + context.clearRect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); + } for (ExecutableOp op : frame.getOps()){ op.execute(context); @@ -2322,6 +2469,18 @@ public void run() { new Thread(r).start(); } } + + private void completePressInFlight() { + Runnable pending; + synchronized (pointerEventOrderLock) { + pressInFlight = false; + pending = deferredRelease; + deferredRelease = null; + } + if (pending != null) { + pending.run(); + } + } @JSBody(params={}, script="return window.cn1WheelMultiplier || 1.0") private static native double wheelMultiplier(); @@ -2708,7 +2867,6 @@ public void installNativeTheme(){ } } String nativeTheme = Display.getInstance().getProperty("javascript.native.theme", defaultTheme); - Log.p("[installNativeTheme] attempting to load theme from " + nativeTheme); Resources r; try { r = Resources.open(nativeTheme); @@ -2716,22 +2874,17 @@ public void installNativeTheme(){ // Fall back to the legacy theme if the chosen .res isn't in // the JS bundle (partial build, missing mirror step, etc.). String fallback = isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"; - Log.p("[installNativeTheme] " + nativeTheme + " missing, falling back to " + fallback); r = Resources.open(fallback); } - Log.p("[installNativeTheme] loaded theme resources, theme names: " + java.util.Arrays.toString(r.getThemeResourceNames())); Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); - + tp.put("StatusBar.padding", "0,0,0,0"); - + UIManager.getInstance().setThemeProps(tp); - Log.p("[installNativeTheme] successfully installed theme"); return; } catch (IOException ex){ - Log.p("[installNativeTheme] IOException loading theme: " + (ex.getMessage() != null ? ex.getMessage() : "null")); Log.e(ex); } catch (Exception ex) { - Log.p("[installNativeTheme] Exception loading theme: " + ex.getClass().getName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "null")); Log.e(ex); } return; @@ -2866,12 +3019,23 @@ private static boolean isIOS13() { private static native boolean isIPad(); + // Codename One has always preferred to work in CSS pixels (logical + // "real" pixels) end-to-end on the JS port -- we don't auto-scale to + // device pixels. Defaulting ``overridePixelRatio`` to 1 keeps: + // * the canvas backing dimensions equal to CSS dimensions (no + // HiDPI 2x backing surface), + // * pointer-event coordinates flowing through unmultiplied (so a + // click at CSS (574, 455) is delivered to Form.pointerPressed + // as (574, 455), not (1148, 910) on a retina display), + // * scaleCoord / unscaleCoord becoming no-ops. + // Anyone who specifically wants HiDPI rendering can opt in via the + // ``?pixelRatio=2`` URL parameter. @JSBody(params={}, script="if (window.overridePixelRatio === undefined) {" + " var ratioStr = getParameterByName('pixelRatio');" + " if (ratioStr != '') {" + " window.overridePixelRatio = parseFloat(ratioStr);" + " } else {" - + " window.overridePixelRatio = 0;" + + " window.overridePixelRatio = 1;" + " }" + " if (window.cn1ScaleCoord === undefined){ window.cn1ScaleCoord = function(x) { return x===-1?-1:x/(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" + " if (window.cn1UnscaleCoord === undefined){ window.cn1UnscaleCoord = function(x) { return x===-1?-1:x*(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" @@ -4773,6 +4937,34 @@ private void finishTextEditing(){ */ boolean graphicsLocked; + /** + * Mark the calling green thread as the only one ``drain`` will dispatch + * until the matching ``endGraphicsAtomic()``. While set, ALL other Java + * green threads on the worker stay parked even when their wait timeouts + * expire; the runtime's drain loop sees the atomic-thread flag and + * picks only this thread. + * + * Why: ``flushGraphics`` issues a JSO call per canvas op (``ctx.save``, + * ``ctx.fillStyle``, ``ctx.fillRect``, ...). Each JSO call yields the + * green thread waiting for HOST_CALLBACK. Without this marker the + * runtime would interleave OTHER green threads during those yields -- + * those other threads can call repaint(), Component invalidations, + * Form transitions, requestAnimationFrame -- each of which queues + * MORE canvas ops. The recursive flood of host->worker host-callback + * messages then crowded out ``self.onmessage`` for incoming pointer + * events (the OK click on a Dialog modal stopped reaching the worker). + * + * Holding the atomic marker for the duration of the per-frame batch + * mirrors how other Codename One ports run paint on a single thread + * with no input interleaving, and keeps the host->worker message + * queue fair-shareable for incoming DOM events between frames. + */ + @JSBody(params={}, script="if (typeof jvm !== 'undefined') jvm.atomicThread = jvm.currentThread;") + private static native void beginGraphicsAtomic(); + + @JSBody(params={}, script="if (typeof jvm !== 'undefined') jvm.atomicThread = null;") + private static native void endGraphicsAtomic(); + @Override public void flushGraphics(int x, int y, int width, int height) { JavaScriptRenderQueueCoordinator.waitUntilFlushable(new JavaScriptRenderQueueCoordinator.FlushBarrier() { @@ -4786,16 +4978,9 @@ public void sleep(int millis) throws InterruptedException { Thread.sleep(millis); } }, pendingDisplay); - + List flushedOps; synchronized(pendingDisplay){ - /* - CanvasRenderingContext2D context = (CanvasRenderingContext2D)outputCanvas.getContext("2d"); - List ops = graphics.flush(x, y, width, height); - for (ExecutableOp op : ops){ - op.execute(context); - } - */ flushedOps = graphics.flush(x, y, width, height); JavaScriptRenderQueueCoordinator.queueFlush(new JavaScriptRenderQueueCoordinator.GraphicsLock() { @Override @@ -4804,15 +4989,29 @@ public void setGraphicsLocked(boolean locked) { } }, pendingDisplay, flushedOps, x, y, width, height); } - drainPendingDisplayFrame(); + beginGraphicsAtomic(); + try { + drainPendingDisplayFrame(); + } finally { + endGraphicsAtomic(); + } + // If anything got queued mid-flush (e.g. a re-entrant flushGraphics + // call ran while we held the atomic flag and its ops landed after + // our snapshot), make sure the rAF chain runs at least one more + // tick to catch them. ``handleAnimationFrame`` no longer re-arms + // unconditionally, so without this poke the queued ops would sit + // forever. + if (pendingDisplay.hasPendingOps()) { + scheduleAnimationFrame(); + } if (isEditing) { resizeNativeEditor(); } if (activePicker != null) { activePicker.resizeNativeElement(); } - - + + } @Override @@ -4842,17 +5041,11 @@ public void screenshot(SuccessCallback callback) { final ImageData imageData = context.getImageData(0, 0, width, height); final Uint8ClampedArray data = imageData.getData(); final int[] rgb = new int[width * height]; - JavaScriptImageDataAdapter.readRgbaToArgb(new JavaScriptImageDataAdapter.PixelReader() { - @Override - public int get(int index) { - return data.get(index); - } - - @Override - public int length() { - return data.getLength(); - } - }, rgb, 0); + // Bulk RGBA->ARGB conversion in one JS-native loop. The legacy + // ``PixelReader`` path made 4 JSO virtual-dispatch calls per + // pixel (4.6M for a 1280x900 screenshot); the bulk intrinsic + // collapses that to a single ``yield*`` boundary. + JavaScriptImageDataAdapter.readRgbaToArgbBulk(data, rgb, 0); callback.onSucess(Image.createImage(rgb, width, height)); } @@ -4879,19 +5072,9 @@ public void readMutableSurface() { } final Uint8ClampedArray dataArr = imData[0].getData(); - JavaScriptImageDataAdapter.readRgbaToArgb(new JavaScriptImageDataAdapter.PixelReader() { - @Override - public int get(int index) { - return dataArr.get(index); - } - - @Override - public int length() { - return dataArr.getLength(); - } - }, arr, offset); - - + // Bulk RGBA->ARGB via JS-native intrinsic; see screenshot() + // call above for rationale. + JavaScriptImageDataAdapter.readRgbaToArgbBulk(dataArr, arr, offset); } @@ -4996,17 +5179,16 @@ private Object createImageData(int[] rgb, int width, int height){ } Object createImageData(int[] rgb, int offset, int width, int height) { - final Uint8ClampedArray arr = Uint8ClampedArray.create(width*height*4); - JavaScriptImageDataAdapter.writeArgbToRgba(rgb, offset, width, height, new JavaScriptImageDataAdapter.PixelWriter() { - @Override - public void set(int index, int value) { - arr.set(index, value); - } - }); ImageData d = graphics.getContext().createImageData(width, height); - ((Uint8ClampedArraySetter)d.getData()).set(arr); + // Single round-trip: send the ARGB int[] to host, where the + // ``writeArgbBuffer`` prototype extension unpacks it directly into + // ``this.data``. The earlier + // ``((Uint8ClampedArraySetter)d.getData()).set(arr)`` path lost every + // byte to the worker-side clone of ``imageData.data`` — see + // ``ImageData.writeArgbBuffer`` for the full rationale. + d.writeArgbBuffer(rgb, offset, width, height); return d; - + } private int isTablet = -1; @@ -5494,8 +5676,26 @@ public HTML5Graphics createGraphics(HTMLCanvasElement canvas) { @Override public void fillRect(HTML5Graphics graphics, int color, int fillWidth, int fillHeight) { - graphics.setColorWithAlpha(color); - graphics.fillRect(0, 0, fillWidth, fillHeight); + // Image.createImage(w, h, fillColor) takes an ARGB int. The + // alpha byte must drive the fill's transparency: + // ``setColorWithAlpha`` already sets ``fillStyle`` to an + // ``rgba(...)`` string, but the FillRect op overwrites that + // with ``rgb(...)`` and uses ``state.alpha`` (the graphics- + // wide global alpha, defaulting to 255) as the canvas + // ``globalAlpha`` -- silently dropping the colour's alpha + // byte. Route the alpha through ``setAlpha`` so it lands in + // ``state.alpha`` and the FillRect op picks it up. Reset + // alpha to 255 afterwards so the freshly returned mutable- + // image graphics has the default state the user expects. + int colorAlpha = (color >>> 24) & 0xFF; + graphics.setColor(color & 0xFFFFFF); + if (colorAlpha != 0xFF) { + graphics.setAlpha(colorAlpha); + graphics.fillRect(0, 0, fillWidth, fillHeight); + graphics.setAlpha(255); + } else { + graphics.fillRect(0, 0, fillWidth, fillHeight); + } } }); NativeImage img = new NativeImage(); @@ -7569,8 +7769,29 @@ public void setCurrentForm(Form f) { + // Asset bytes cache. CN1's Resources / UIManager bootstrap reads the + // same .res file (e.g. iOS7Theme.res) multiple times during a single + // boot — once for the requested theme, again as a layered fallback, + // and once more from the EncodedImage multi-image lazy load. Each + // call hit the network synchronously; iOS7Theme.res alone was + // downloaded 3x = ~1.4 MB wasted on the wire. Cache the bytes once + // they've been fetched and serve a fresh ArrayBufferInputStream over + // the same Uint8Array on every subsequent open. + private static final java.util.Map assetByteCache = + new java.util.HashMap(); + // Cache of URLs that the host bundle does NOT have. The + // ``getBundledAssetAsDataURL`` host call returns null for any + // URL the app didn't embed -- Initializr embeds none, so all + // ~5 boot calls returned null. Cache the negative result so + // repeats hit the in-worker cache instead of round-tripping. + // We never cache the positive case because the data URL would + // be huge to keep around when we already process the bytes. + private static final java.util.Set bundledAssetMissCache = + new java.util.HashSet(); + public InputStream getArrayBufferInputStream(String url){ - String dataURL = ((WindowExt)window).getCn1().getBundledAssetAsDataURL(url); + String dataURL = bundledAssetMissCache.contains(url) ? null + : ((WindowExt)window).getCn1().getBundledAssetAsDataURL(url); if (dataURL != null) { Blob blob = ((WindowExt)window).Base64ToBlob(dataURL); ArrayBufferInputStream out; @@ -7580,9 +7801,10 @@ public InputStream getArrayBufferInputStream(String url){ } catch (IOException ex) { ex.printStackTrace(); } - + } else { + bundledAssetMissCache.add(url); } - + if (isMediaResource(url)){ ArrayBufferInputStream out = new ArrayBufferInputStream(Uint8Array.create(0), null); out.setSrc(url); @@ -7592,8 +7814,23 @@ public InputStream getArrayBufferInputStream(String url){ if (url.indexOf("assets/") == 0 && url.indexOf("?") == -1) { url = url + "?v=" + getBuildVersion(); } + Uint8Array cachedBytes = assetByteCache.get(url); + if (cachedBytes != null) { + return new ArrayBufferInputStream(cachedBytes, "arraybuffer"); + } req.open("get", url, false); - req.overrideMimeType("text/plain; charset=x-user-defined"); + // ``responseType = "arraybuffer"`` lets the browser hand back a + // typed-array view of the bytes directly. The previous path used + // ``overrideMimeType("text/plain; charset=x-user-defined")`` and + // then walked the response string char-by-char into a fresh + // Uint8Array -- ~735k JS->JSO ``out.set(i, ...)`` calls for + // theme.res, ~939 ms wall on the worker per fetch. With the + // arraybuffer path the same fetch lands in ~3 ms (measured on + // localhost). Falls back to the text/charset path when the + // arraybuffer response is empty (some hosts strip the response + // body for non-2xx, in which case the text path's status + // diagnostics are still useful). + req.setResponseType("arraybuffer"); req.send(); Uint8Array responseBytes = toResponseBytes(req); @@ -7604,7 +7841,8 @@ public InputStream getArrayBufferInputStream(String url){ System.out.println("Status code was "+req.getStatus()); return null; } - + + assetByteCache.put(url, responseBytes); ArrayBufferInputStream out = new ArrayBufferInputStream(responseBytes, req.getResponseType()); return out; } @@ -7653,12 +7891,30 @@ public InputStream getResourceAsStream(Class cls, String resource) { return rootStream; } } - if (!"icon.png".equals(resource)) { - resource = "assets/"+resource; + String assetPath = "icon.png".equals(resource) ? resource : ("assets/" + resource); + InputStream out = getStream(assetPath); + if (out != null) { + notifyProgressLoaderThatResourceIsLoaded(assetPath); + return out; } - InputStream out = getStream(resource); - notifyProgressLoaderThatResourceIsLoaded(resource); - return out; + // Fall back to the bundle root for resources the translator drops + // there directly (most ``.properties`` resource bundles, for one — + // ``ParparVMBootstrap`` mirrors the jar layout and only the explicit + // relocations in ``build-javascript-port-initializr.sh`` / + // ``build-javascript-port-hellocodenameone.sh`` move things into + // ``assets/``). Without this fallback every + // ``ResourceBundle.getResourceAsStream("/messages_xx.properties")`` + // call returns null, the ``Resources.getL10N`` lookup throws (or + // returns null), and any UI that catches the throw and logs via + // ``Log.e`` floods the console with ``Exception: null`` — see + // ``initializr/common/.../TemplatePreviewPanel.loadBundleProperties``. + InputStream rootFallback = getStream(resource); + if (rootFallback != null) { + notifyProgressLoaderThatResourceIsLoaded(resource); + return rootFallback; + } + notifyProgressLoaderThatResourceIsLoaded(assetPath); + return null; } @@ -7680,10 +7936,15 @@ public static void registerSaveBlobToFile() { } private NativeImage createNativeImage(byte[] bytes, int offset, int len){ + // The previous path called ``arr.set(i, bytes[i+offset])`` per + // byte -- one JSO bridge call per element, ~50k calls for a + // typical theme PNG, ~50 such images per theme load. With + // ``copyBytesToUint8Array`` the entire copy lives in JS and + // executes as a single typed-array memcpy: ~hundreds of times + // faster on the Initializr profile (theme decode 979 ms -> + // see ``run:lifecycle.init`` perf marker). Uint8Array arr = Uint8Array.create(len); - for (int i=0; i 0) while ``loaded`` is + // still false. ``JavaScriptNativeImageAdapter.resolveWidth`` + // falls through to a hard-coded 10 fallback in that + // window, which the caller (typically EncodedImage.getWidth) + // then *caches* as the recorded width. The real PNG + // arrives later, but EncodedImage now records 10 — so + // every drawImage call from then on scales the actual + // 125x24 corner image down to 10x24 and the rounded + // shape disappears (Initializr Dialog 9-piece border). + // + // Treat the image as loaded as soon as the underlying + // element exposes a positive natural size, regardless of + // whether the async ``load`` event has fired yet. + return img != null && (loaded || img.getNaturalWidth() > 0); } @Override @@ -7892,12 +8188,91 @@ public void run() { } public int getWidth(){ + awaitNaturalDimensions(); return JavaScriptNativeImageAdapter.resolveWidth(imageModel); } - + public int getHeight(){ + awaitNaturalDimensions(); return JavaScriptNativeImageAdapter.resolveHeight(imageModel); } + + /** + * Block briefly (up to ~200 ms total) until the underlying + * {@code HTMLImageElement} exposes a positive natural width, + * so that {@link #getWidth()} / {@link #getHeight()} don't + * fall through to the hard-coded 10-pixel fallback in + * {@link JavaScriptNativeImageAdapter} during the gap between + * {@code createNativeImage()} returning and the main thread + * finishing decode of the Blob-backed image. + * + *

Without this wait, {@link com.codename1.ui.EncodedImage} + * caches the fallback 10 in its own width/height field on the + * first call and never re-queries, which manifests in image + * borders (e.g. the Initializr {@code PopupDialog} 9-piece + * border) as a visible strip of unpainted background showing + * the underlying form. The async {@code load} event later + * fires and a repaint is scheduled, but {@code EncodedImage} + * is still serving the cached 10 so the redraw is identical + * and the gap persists.

+ * + *

For theme PNGs (decoded from in-memory Blob bytes) the + * main thread typically finishes decode within a millisecond + * or two, so this wait almost never reaches its outer + * deadline. The 200 ms cap exists for the pathological case + * (decoder errored, host unreachable) so a single broken + * image can't stall layout forever.

+ */ + private void awaitNaturalDimensions() { + if (img == null) { + return; + } + if (loaded || error) { + return; + } + if (img.getNaturalWidth() > 0) { + return; + } + // Outer deadline: total wall-time we'll block layout for + // a single image. 200 ms is generous for any local Blob + // decode and harmlessly short for the network/error case. + long deadline = System.currentTimeMillis() + 200; + // ``Thread.sleep`` in the JS-port worker is a cooperative + // yield, so the listener installed in ``load()`` (which + // runs on the main thread and posts a callback back into + // the worker) gets a chance to update ``loadState`` and + // bump ``naturalWidth`` during these short naps. + while (img.getNaturalWidth() <= 0 && !error + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(2); + } catch (InterruptedException ignored) { + break; + } + } + // Once the natural size is known, refresh the cached + // ``loaded``/``width``/``height`` fields from ``loadState`` + // so a subsequent ``getWidth()`` short-circuits via the + // ``loaded`` flag rather than re-polling the host. + if (img.getNaturalWidth() > 0 && !loaded) { + JavaScriptAsyncImageLoadCoordinator.handleLoad(loadState, + img.getNaturalWidth(), img.getNaturalHeight()); + loaded = loadState.isLoaded(); + width = loadState.getWidth(); + height = loadState.getHeight(); + } else if (img.getNaturalWidth() <= 0 && !error) { + // Timed out without the host ever exposing a natural + // size. Latch ``error`` so the next ``getWidth()`` + // returns immediately via the fallback path instead + // of paying another 200 ms wait per call. The async + // ``load`` listener will still flip ``loaded`` to + // true if the image eventually decodes, at which + // point ``resolveWidth`` starts returning the real + // dimension again. + JavaScriptAsyncImageLoadCoordinator.handleError(loadState); + error = true; + } + } public void draw(CanvasRenderingContext2D ctx, int x, int y, int width, int height){ JavaScriptNativeImageAdapter.draw(imageModel, new JavaScriptNativeImageAdapter.DrawTarget() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java index e2e05fa3c5..036f2dc1da 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java @@ -45,16 +45,35 @@ public static void registerPeerPointerEvents(ElementRegistrar registrar, boolean boolean touchStartEnabled, boolean touchEndEnabled, boolean wheelEnabled, String wheelEventType, Object mouseDown, Object hitTest, Object mouseUp, Object touchStart, Object touchEnd, Object wheel) { + // Modern browsers fire BOTH ``pointerdown`` AND ``mousedown`` for the + // same user click (pointer events first, then a follow-up mouse event + // for backwards compat). Registering the SAME listener for both fires + // it twice per real click. ``HTML5Implementation.onMouseDown`` / + // ``onMouseUp`` try to dedupe via a stateful ``mouseDown`` flag + // (``shouldIgnoreMousePress`` + ``!isMouseDown()`` early-returns), but + // the dedup gets out of sync — ``mouseDown`` ends up cleared by one + // event-pair half before the matching opposite half can run, so on + // the JS port a Dialog OK click can land on a press whose release + // gets dropped (or vice-versa). Net effect: the modal Dialog never + // disposes, ``invokeAndBlock`` blocks the EDT forever, the UI freezes + // — see PR #4795 dialog-freeze repro. + // + // Fix: register ONLY pointer events. Every browser this port supports + // (Chrome 55+, Edge, Firefox 59+, Safari 13+) ships pointer events; + // they cover mouse, touch, and pen input in one event family. The + // legacy ``mousedown`` / ``mouseup`` registrations are redundant + // and were the cause of the dedup race. if (mouseDownEnabled) { - registrar.add("mousedown", mouseDown, true); registrar.add("pointerdown", mouseDown, true); } registrar.add("hittest", hitTest, true); if (mouseUpEnabled) { - registrar.add("mouseup", mouseUp, true); registrar.add("pointerup", mouseUp, true); - registrar.add("mouseout", mouseUp, true); - registrar.add("pointerout", mouseUp, true); + // ``pointercancel`` is the pointer-events equivalent of + // ``mouseout`` for the click-aborted case (e.g. browser takes + // focus elsewhere mid-drag); keep that side-channel so a stuck + // ``mouseDown`` flag can still recover. + registrar.add("pointercancel", mouseUp, true); } if (touchStartEnabled) { registrar.add("touchstart", touchStart, true); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptImageDataAdapter.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptImageDataAdapter.java index c539508820..eefb03adc1 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptImageDataAdapter.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptImageDataAdapter.java @@ -6,6 +6,8 @@ */ package com.codename1.impl.html5; +import com.codename1.html5.js.typedarrays.Uint8ClampedArray; + public final class JavaScriptImageDataAdapter { private JavaScriptImageDataAdapter() { } @@ -41,4 +43,19 @@ public static void readRgbaToArgb(PixelReader reader, int[] rgb, int offset) { (reader.get(i + 2) & 0x000000ff); } } + + /** + * Bulk RGBA->ARGB conversion implemented as a native intrinsic that + * loops once in JS over the raw Uint8ClampedArray. Avoids the + * per-byte JSO virtual dispatch the {@link PixelReader} path pays + * (4 calls per pixel; 4.6M calls for a 1280x900 screenshot). + * + *

If the native binding is missing (test stubs etc.) callers must + * fall back to {@link #readRgbaToArgb(PixelReader, int[], int)}.

+ * + * @param src the canvas image data buffer in RGBA order + * @param dst destination ARGB int[] + * @param offset starting index in {@code dst} + */ + public static native void readRgbaToArgbBulk(Uint8ClampedArray src, int[] dst, int offset); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java index 6447ff448d..fcd3d3adf4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java @@ -38,7 +38,16 @@ public static Lifecycle createLifecycle(String className) { @JSBody(params = {}, script = "window.cn1Started = true;") private static native void setStarted(); - @JSBody(params = {"url"}, script = "var l = window.location; var base=l.protocol+'//'+l.hostname+(l.port?':':'')+l.port; return url.indexOf(base)===0;") + // The @JSBody body runs against the raw worker-side argument. In the + // ParparVM JS port a Java ``String`` arrives as a wrapped object + // ({__class:"java_lang_String", cn1_..._value: char[]}), not a native + // JS string — calling ``url.indexOf`` directly throws + // ``TypeError: url.indexOf is not a function`` and bubbles up through + // ``proxifyUrl`` → ``ImplementationFactory.proxifyURL`` whenever the + // app loads any image off the theme. Coerce to a native string up + // front (mirrors the pattern already in place for the + // ``measureAscent`` / ``measureDescent`` @JSBody helpers). + @JSBody(params = {"url"}, script = "var s = String(url == null ? '' : url); var l = window.location; var base=l.protocol+'//'+l.hostname+(l.port?':':'')+l.port; return s.indexOf(base)===0;") private static native boolean urlIsSameDomain(String url); public static String proxifyUrl(Display display, String url) { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java index cbc7fc582d..4b8aaab01b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java @@ -73,7 +73,16 @@ private void open(){ if (!isOpen){ isOpen = true; req.open(httpMethod, url, false); - req.overrideMimeType("text/plain; charset=x-user-defined"); + // ``responseType = "arraybuffer"`` lets the browser hand back + // a typed-array view of the bytes directly. The previous + // ``overrideMimeType("text/plain; charset=x-user-defined")`` + // path forced ``toResponseBytes`` to fall through to a + // per-character ``out.set(i, ...)`` loop -- one JSO bridge + // call per response byte, dominating any non-trivial HTTP + // response on the worker. ``toResponseBytes`` already takes + // the fast arraybuffer path when ``getResponse()`` is set; + // this just makes that branch the actual hot path. + req.setResponseType("arraybuffer"); } } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java index 76eb8ed8d7..3e6bece7b4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java @@ -31,10 +31,34 @@ public static void bootstrap(Lifecycle lifecycle) { bootstrap.run(); } + // ``window.cn1Initialized = true`` lands on the worker's global + // (window === self inside the worker), but the headless test + // harness and every other main-thread consumer reads its own + // ``window.cn1Initialized``. The bridge (browser_bridge.js) + // already flips its main-thread copy when ``startParparVmApp`` + // runs, so the worker side is best-effort — the real signal + // travels through the message-passing channel instead. @JSBody(params = {}, script = "window.cn1Initialized = true;") private static native void setInitialized(); - @JSBody(params = {}, script = "window.cn1Started = true;") + // For ``cn1Started`` we need the same main-thread signal but + // there's no ``startParparVmApp``-style hook on this side. The + // worker emits a ``{type: 'lifecycle', phase: 'started'}`` VM + // message at the same time so ``browser_bridge.js`` can flip + // its own ``cn1Started``. Fall back gracefully when neither + // ``parentPort`` (Node worker_threads) nor ``self.postMessage`` + // (browser Worker) is available — that path applies to direct + // in-page invocations from the JavaScript-port simulator. + @JSBody(params = {}, script = "" + + "window.cn1Started = true;" + + "var __cn1LifecycleMsg = {type: 'lifecycle', phase: 'started'};" + + "if (typeof parentPort !== 'undefined' && parentPort && typeof parentPort.postMessage === 'function') {" + + " parentPort.postMessage(__cn1LifecycleMsg);" + + "} else if (typeof self !== 'undefined' && self !== this && typeof self.postMessage === 'function') {" + + " self.postMessage(__cn1LifecycleMsg);" + + "} else if (typeof postMessage === 'function') {" + + " postMessage(__cn1LifecycleMsg);" + + "}") private static native void setStarted(); @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java index 6d1e86ae61..80e28c527d 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java @@ -40,11 +40,24 @@ public static void resetClip(CanvasRenderingContext2D context, ClipState clipSta public void execute(CanvasRenderingContext2D context) { if (clipState.isSet()){ context.restore(); + // Canvas2D save/restore captures and restores the FULL state, + // including the transform. The prior clip op may have been a + // ClipShape whose save() recorded a non-identity transform (the + // shape's coord space, e.g. a rotation); restore() would silently + // revert the canvas transform to that. ClipRect is only ever + // emitted by HTML5Graphics under an identity Java-side transform + // (the non-identity path is routed through clipShape() → the + // ClipShape op), so resetting the canvas to identity here + // restores the canvas-tracks-Java invariant and stops the + // rotated/translated transform from leaking into every draw op + // that follows -- without paying for a getTransform()/setTransform + // pair per clip in the slide-transition rendering hot path. + context.setTransform(1, 0, 0, 1, 0, 0); } clipState.set(true); context.save(); context.beginPath(); - + context.rect(x, y, w, h); context.clip(); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/localforage/LocalForage.java b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/localforage/LocalForage.java index 43c008347b..7700bd394b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/localforage/LocalForage.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/localforage/LocalForage.java @@ -642,10 +642,9 @@ public void flush() throws IOException { private void save() throws IOException { byte[] bytes = this.toByteArray(); int len = bytes.length; - Uint8Array arr = Uint8Array.create(len); - for (int i = 0; i < len; i++) { - arr.set(i, bytes[i]); - } + // Bulk-copy the Java byte[] into a Uint8Array in one JS + // call instead of paying a JSO virtual dispatch per byte. + Uint8Array arr = BlobUtil.byteArrayToUint8Array(bytes); inst.setItem(key, arr); if (onComplete != null) { ItemSavedEvent evt = new ItemSavedEvent(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/ArrayBufferInputStream.java b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/ArrayBufferInputStream.java index d25de8ace2..30c7f49c5a 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/ArrayBufferInputStream.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/ArrayBufferInputStream.java @@ -38,6 +38,30 @@ public int read() throws IOException { return buf.get(pos++); } + @Override + public int read(byte[] b, int off, int length) throws IOException { + if (pos >= len) { + return -1; + } + if (length <= 0) { + return 0; + } + int n = length; + int avail = len - pos; + if (n > avail) { + n = avail; + } + // Native intrinsic: one JS-side loop copies n bytes from the + // backing Uint8Array into the Java byte[] without per-byte + // virtual dispatch through the cooperative scheduler. This + // collapses thousands of single-byte yields during + // ``Resources.load(theme.res)`` into a single non-suspending + // call. + readBulkImpl(buf, pos, b, off, n); + pos += n; + return n; + } + @Override public void reset() throws IOException { pos = 0; @@ -45,19 +69,28 @@ public void reset() throws IOException { @Override public long skip(long n) throws IOException { - + int oldPos = pos; - + pos += (int)n; - + if ( pos > len ){ pos = len; } int out = pos-oldPos; - + return pos-oldPos; } + /** + * Native intrinsic: bulk-copy {@code length} bytes from + * {@code src[srcOff..]} into {@code dst[dstOff..]}. Implemented as + * a single JS loop in port.js so it does not pay the cooperative + * scheduler's per-byte yield overhead. + */ + private static native void readBulkImpl(Uint8Array src, int srcOff, + byte[] dst, int dstOff, int length); + @Override public int available() throws IOException { return len-pos; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/BlobUtil.java b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/BlobUtil.java index 8b7eeb7363..1cf6ca925c 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/BlobUtil.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/io/BlobUtil.java @@ -108,14 +108,19 @@ public static Blob createBlob(Uint8Array buf, String type) { } public static Blob createBlob(byte[] bytes, String type) { - int len = bytes.length; - Uint8Array arr = Uint8Array.create(len); - for (int i=0; i element on every user-gesture + // unlock pass and shows up as 5+ duplicate lines in the + // initializr console for no diagnostic value.) var testPlay = audio.play(); diff --git a/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js b/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js new file mode 100644 index 0000000000..ff72c475e7 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js @@ -0,0 +1,166 @@ +// Minimal localforage shim for the ParparVM JavaScript port. +// +// The Java-side ``com.codename1.teavm.ext.localforage.LocalForage`` class +// was originally written against TeaVM and assumes ``window.localforage`` +// is loaded plus ``window.createConfigOptions`` exists (the latter is the +// inlined body of a TeaVM ``@JSBody`` annotation that the ParparVM JS +// pipeline doesn't process). Without these, the LocalForage constructor +// throws ``Missing JS member createConfigOptions for host receiver`` +// during boot the first time anything calls ``Storage.getInstance()`` / +// ``FileSystemStorage.getInstance()``. +// +// This shim provides a localStorage-backed implementation that exposes +// the same async-callback API the LocalForage Java wrapper expects. The +// shim is loaded BEFORE ``browser_bridge.js`` so the JSO bridge resolves +// the missing members on the host window without going through the +// ``Missing JS member`` error path. +(function() { + if (typeof window === "undefined") { + return; + } + // ``ConfigOptions`` was a TeaVM @JSBody factory that returned a fresh + // empty object — preserve that contract. + if (typeof window.createConfigOptions !== "function") { + window.createConfigOptions = function() { return {}; }; + } + // If a real ``localforage`` library is loaded ahead of us, leave it + // alone. Otherwise install a localStorage-backed shim. + if (window.localforage && typeof window.localforage.setItem === "function") { + return; + } + var STORE_PREFIX = "cn1lf:"; + function namespacedKey(key) { return STORE_PREFIX + String(key); } + // The Java side blocks on ``done.wait()`` after queueing the setItem + // request. In TeaVM-land the localforage library returns a Promise + // and the callback fires asynchronously via setTimeout(0); the + // ParparVM JS port doesn't have an event-loop pump between the + // worker-side wait and the host bridge's response, so deferring the + // callback through ``Promise.resolve().then(...)`` causes Thread A + // to enter ``done.wait()`` BEFORE the callback's + // ``done.notifyAll()`` fires (the microtask runs after the bridge + // has already returned). Drive the callback synchronously: by the + // time setItem returns, the worker callback proxy has already + // posted the ``worker-callback`` message and the worker will pick + // it up the moment Thread A yields on ``done.wait``. + function callBack(callback, error, value) { + if (typeof callback === "function") { + try { callback(error || null, value); } + catch (_e) { /* user callbacks own their errors */ } + } + } + function setItemImpl(key, value) { + var serialised; + if (value == null) { + serialised = null; + } else if (typeof value === "string") { + serialised = "s:" + value; + } else { + try { serialised = "j:" + JSON.stringify(value); } + catch (_e) { serialised = "j:" + JSON.stringify(String(value)); } + } + if (serialised == null) { + window.localStorage.removeItem(namespacedKey(key)); + } else { + window.localStorage.setItem(namespacedKey(key), serialised); + } + return value; + } + function getItemImpl(key) { + var raw = window.localStorage.getItem(namespacedKey(key)); + if (raw == null) { + return null; + } + if (raw.indexOf("s:") === 0) { + return raw.substring(2); + } + if (raw.indexOf("j:") === 0) { + try { return JSON.parse(raw.substring(2)); } + catch (_e) { return null; } + } + return raw; + } + function eachKey(callback) { + var prefix = STORE_PREFIX; + for (var i = 0; i < window.localStorage.length; i++) { + var k = window.localStorage.key(i); + if (k && k.indexOf(prefix) === 0) { + if (callback(k.substring(prefix.length)) === false) { + return; + } + } + } + } + window.localforage = { + INDEXEDDB: "indexeddb", + WEBSQL: "websql", + LOCALSTORAGE: "localstorage", + config: function(_opts) { return true; }, + setItem: function(key, value, callback) { + return (function() { + var stored = setItemImpl(key, value); + callBack(callback, null, stored); + return stored; + }); + }, + getItem: function(key, callback) { + return (function() { + var value = getItemImpl(key); + callBack(callback, null, value); + return value; + }); + }, + removeItem: function(key, callback) { + return (function() { + window.localStorage.removeItem(namespacedKey(key)); + callBack(callback, null); + }); + }, + clear: function(callback) { + return (function() { + var doomed = []; + eachKey(function(k) { doomed.push(k); }); + for (var i = 0; i < doomed.length; i++) { + window.localStorage.removeItem(namespacedKey(doomed[i])); + } + callBack(callback, null); + }); + }, + length: function(callback) { + return (function() { + var n = 0; + eachKey(function() { n++; }); + callBack(callback, null, n); + return n; + }); + }, + keys: function(callback) { + return (function() { + var out = []; + eachKey(function(k) { out.push(k); }); + callBack(callback, null, out); + return out; + }); + }, + iterate: function(iteratorCallback, successCallback) { + return (function() { + var stopped = false; + var idx = 1; + eachKey(function(k) { + if (stopped) { return false; } + var value = getItemImpl(k); + var result; + try { result = iteratorCallback(value, k, idx++); } + catch (_e) { result = undefined; } + if (result !== undefined) { + stopped = true; + callBack(successCallback, null, result); + return false; + } + }); + if (!stopped) { + callBack(successCallback, null); + } + }); + } + }; +})(); diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index 7efdcdb4f1..0b197dfe86 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -4,6 +4,120 @@ * used by the JavaScript port of Codename One. */ +// Worker-side jQuery shim. The main thread pulls in real jQuery via +// "; + print $0; + print ""; + done = 1; + } else { + print; + } + } + ' "$DIST_DIR/index.html" > "$DIST_DIR/index.html.patched" + mv "$DIST_DIR/index.html.patched" "$DIST_DIR/index.html" + bj_log "Patched index.html to load native impl and host-bridge handlers" +fi + +# Inject a script that pre-fetches the boot-critical theme assets +# in parallel with worker startup. This fires before +# ``browser_bridge.js`` creates the worker, so the browser starts +# the network request immediately. By the time the worker runs its +# blocking sync XHR for the same URL the response is already in +# the HTTP cache. +# +# Using inline ``fetch(url)`` (default same-origin credentials) is +# what matches the worker's XHR cache key. An earlier draft used +# ```` which +# made boot SLOWER -- the explicit ``crossorigin="anonymous"`` +# downgraded the request to no-credentials mode, so the worker's +# default-credentials XHR couldn't reuse the preloaded bytes. The +# bare ``fetch(url)`` call sidesteps that mismatch. +# +# Note: an earlier ``cn1-prefetch`` '; + if (text.indexOf(probe) >= 0) return; + const bridge = ''; + if (text.indexOf(bridge) >= 0) { + text = text.replace(bridge, probe + '\n' + bridge); + } else if (text.indexOf('') >= 0) { + text = text.replace('', probe + '\n'); + } else { + text += '\n' + probe + '\n'; + } + fs.writeFileSync(indexHtml, text, 'utf8'); +} + +/** + * Walk into the bundle directory and return the directory containing + * ``index.html``. Bundle archives wrap the actual content in a + * single ``-js/`` folder, so we have to descend. + */ +function locateIndexRoot(root) { + if (fs.existsSync(path.join(root, 'index.html'))) { + return root; + } + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const sub = path.join(root, entry.name); + const found = locateIndexRoot(sub); + if (found) return found; + } + return null; +} + +/** + * Spawn ``javascript_browser_harness.py`` to serve the bundle on a + * loopback port. Returns once the harness has written its URL to the + * url-file; that's our handshake that the listener is up. + */ +async function startHarness(serveDir, logFile, urlFile) { + fs.writeFileSync(urlFile, ''); + const child = spawn('python3', [ + HARNESS_PY, + '--serve-dir', serveDir, + '--log-file', logFile, + '--url-file', urlFile + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', () => {}); + child.stderr.on('data', () => {}); + for (let i = 0; i < 100; i++) { + if (fs.statSync(urlFile).size > 0) { + const url = fs.readFileSync(urlFile, 'utf8').trim(); + if (url) { + return { child, url }; + } + } + await new Promise(r => setTimeout(r, 50)); + } + child.kill('SIGTERM'); + throw new Error('Harness did not announce a URL within 5 seconds'); +} + +/** + * Append parparDiag=1 and cn1DisableEventForwarding=1 (mirrors the + * screenshot-test harness so the lifecycle log we read is identical + * to the one CI captures). + */ +function decorateUrl(url) { + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}parparDiag=1&cn1DisableEventForwarding=1`; +} + +/** + * Drive a single bundle through the lifecycle test. Returns a result + * record; never throws on bundle-side failure (those become + * ``ok: false``). Throws only on infrastructure issues (harness + * failed to start, Chromium failed to launch). + */ +async function runBundle({ name, bundle }) { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), `cn1-lifecycle-${name}-`)); + const serveDir = path.join(workDir, 'served'); + const bundleDir = path.join(workDir, 'bundle'); + const logFile = path.join(workDir, 'browser.log'); + const urlFile = path.join(workDir, 'url.txt'); + + console.log(`[lifecycle] ${name}: materialising ${bundle}`); + materializeBundle(bundle, bundleDir); + const indexRoot = locateIndexRoot(bundleDir); + if (!indexRoot) { + return { name, bundle, ok: false, milestones: {}, reason: 'bundle has no index.html' }; + } + copyTree(indexRoot, serveDir); + injectProbeScript(path.join(serveDir, 'index.html')); + + console.log(`[lifecycle] ${name}: starting harness`); + let harness; + try { + harness = await startHarness(serveDir, logFile, urlFile); + } catch (err) { + return { name, bundle, ok: false, milestones: {}, reason: `harness start failed: ${err.message}` }; + } + + const url = decorateUrl(harness.url); + console.log(`[lifecycle] ${name}: serving at ${url}`); + + // Capture every lifecycle marker and the most-recent FIRST_FAILURE + // so the report can pinpoint where the bundle stalled. + const lifecycle = []; + let firstFailure = null; + let pageError = null; + + const browser = await chromium.launch({ + headless: true, + args: [ + '--autoplay-policy=no-user-gesture-required', + '--disable-web-security', + '--allow-file-access-from-files' + ] + }); + + let result; + try { + const page = await browser.newPage({ + viewport: { width: 375, height: 667 }, + deviceScaleFactor: 2 + }); + + page.on('console', msg => { + const text = msg.text(); + if (text.indexOf('PARPAR-LIFECYCLE:') >= 0) { + lifecycle.push(text); + } + if (text.indexOf('PARPAR:DIAG:FIRST_FAILURE') >= 0) { + // Aggregate into a single record; the runtime emits + // ``category`` / ``methodId`` / ``receiverClass`` as separate + // lines. Last value wins, which is fine since the runtime + // only updates ``__parparError`` once per failure burst. + const match = text.match(/PARPAR:DIAG:FIRST_FAILURE:(\w+)=(.+)$/); + if (match) { + firstFailure = firstFailure || {}; + firstFailure[match[1]] = match[2]; + } + } + }); + page.on('pageerror', err => { pageError = String(err && err.stack || err); }); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + + const milestones = await pollLifecycle(page, TIMEOUT_SECONDS); + result = { + name, + bundle, + ok: milestones.cn1Initialized && milestones.cn1Started && !pageError, + milestones, + lifecycle, + firstFailure, + pageError + }; + } finally { + try { await browser.close(); } catch (_e) {} + try { harness.child.kill('SIGTERM'); } catch (_e) {} + } + + // Persist the captured browser log alongside the structured report + // so a CI consumer can dig in without reproducing the run locally. + try { + fs.copyFileSync(logFile, path.join(REPORT_DIR, `${name}.browser.log`)); + } catch (_e) {} + return result; +} + +/** + * Poll the page for ``cn1Initialized`` and ``cn1Started`` flags + * (set by ParparVMBootstrap.setInitialized / setStarted at the end + * of ``Lifecycle.init`` and ``Lifecycle.start`` respectively). A + * ``__parparError`` short-circuits the wait so a runtime exception + * is reported promptly instead of running out the full timeout. + */ +async function pollLifecycle(page, timeoutSeconds) { + const deadline = Date.now() + timeoutSeconds * 1000; + let cn1Initialized = false; + let cn1Started = false; + let parparError = null; + + while (Date.now() < deadline) { + const state = await page.evaluate(() => ({ + initialized: !!window.cn1Initialized, + started: !!window.cn1Started, + error: window.__parparError ? JSON.stringify(window.__parparError) : '' + })); + if (state.initialized) cn1Initialized = true; + if (state.started) cn1Started = true; + if (state.error) parparError = state.error; + if (cn1Started || parparError) { + break; + } + await new Promise(r => setTimeout(r, 500)); + } + return { cn1Initialized, cn1Started, parparError }; +} + +function summarise(results) { + console.log(''); + console.log('==== Lifecycle test results ===='); + let failed = 0; + for (const r of results) { + const status = r.ok ? 'OK' : 'FAIL'; + console.log(`[${status}] ${r.name} (${path.basename(r.bundle)})`); + if (!r.ok) { + failed++; + if (r.reason) { + console.log(` reason: ${r.reason}`); + } + if (r.milestones) { + console.log(` milestones: cn1Initialized=${r.milestones.cn1Initialized} cn1Started=${r.milestones.cn1Started}`); + if (r.milestones.parparError) { + console.log(` __parparError: ${r.milestones.parparError.substring(0, 300)}`); + } + } + if (r.firstFailure) { + console.log(` FIRST_FAILURE: ${JSON.stringify(r.firstFailure)}`); + } + if (r.pageError) { + console.log(` pageerror: ${r.pageError.substring(0, 300)}`); + } + if (r.lifecycle && r.lifecycle.length) { + console.log(` last lifecycle markers:`); + for (const line of r.lifecycle.slice(-6)) { + console.log(` ${line}`); + } + } else { + console.log(` (no PARPAR-LIFECYCLE markers — runtime never produced one)`); + } + } + } + return failed; +} + +async function main() { + const bundles = parseArgs(process.argv.slice(2)); + const results = []; + for (const b of bundles) { + if (!fs.existsSync(b.bundle)) { + results.push({ name: b.name, bundle: b.bundle, ok: false, milestones: {}, reason: `bundle does not exist: ${b.bundle}` }); + continue; + } + try { + results.push(await runBundle(b)); + } catch (err) { + results.push({ + name: b.name, + bundle: b.bundle, + ok: false, + milestones: {}, + reason: `infrastructure error: ${err && err.stack || err}` + }); + } + } + + fs.writeFileSync(path.join(REPORT_DIR, 'report.json'), JSON.stringify(results, null, 2)); + const failed = summarise(results); + if (failed > 0) { + console.log(''); + console.log(`${failed}/${results.length} bundle(s) failed lifecycle test`); + process.exit(1); + } +} + +await main(); diff --git a/scripts/run-javascript-lifecycle-tests.sh b/scripts/run-javascript-lifecycle-tests.sh new file mode 100755 index 0000000000..77a60afed3 --- /dev/null +++ b/scripts/run-javascript-lifecycle-tests.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Convenience wrapper for run-javascript-lifecycle-tests.mjs. Builds +# the HelloCodenameOne and Initializr JavaScript-port bundles if +# they're missing, then drives them through the playwright-based +# lifecycle test. +# +# Usage: +# scripts/run-javascript-lifecycle-tests.sh [extra-bundle.zip ...] +# +# Environment: +# CN1_LIFECYCLE_TIMEOUT_SECONDS per-bundle timeout (default 90) +# CN1_LIFECYCLE_REPORT_DIR artifacts directory +# CN1_LIFECYCLE_SKIP_BUILD skip the mvn build step (1=skip) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +HELLO_BUNDLE="$REPO_ROOT/scripts/hellocodenameone/parparvm/target/hellocodenameone-javascript-port.zip" +INIT_BUNDLE="$REPO_ROOT/scripts/initializr/javascript/target/initializr-javascript-port.zip" + +build_if_missing() { + local bundle="$1" + local module_dir="$2" + if [ -f "$bundle" ]; then + return 0 + fi + if [ "${CN1_LIFECYCLE_SKIP_BUILD:-0}" = "1" ]; then + echo "[lifecycle] $bundle missing and CN1_LIFECYCLE_SKIP_BUILD=1; skipping build" >&2 + return 1 + fi + echo "[lifecycle] building bundle in $module_dir" >&2 + ( cd "$module_dir" && mvn -B -DskipTests package -Pjavascript-build ) >&2 +} + +bundles=() +if build_if_missing "$HELLO_BUNDLE" "$REPO_ROOT/scripts/hellocodenameone/parparvm"; then + bundles+=( "$HELLO_BUNDLE" ) +fi +if build_if_missing "$INIT_BUNDLE" "$REPO_ROOT/scripts/initializr/javascript"; then + bundles+=( "$INIT_BUNDLE" ) +fi +# Allow callers to add ad-hoc bundles after the defaults. +bundles+=( "$@" ) + +if [ ${#bundles[@]} -eq 0 ]; then + echo "[lifecycle] no bundles available — set CN1_LIFECYCLE_SKIP_BUILD=0 or pass paths explicitly" >&2 + exit 2 +fi + +exec node "$SCRIPT_DIR/run-javascript-lifecycle-tests.mjs" "${bundles[@]}" diff --git a/scripts/strip-dead-code-after-return.py b/scripts/strip-dead-code-after-return.py new file mode 100755 index 0000000000..76602be246 --- /dev/null +++ b/scripts/strip-dead-code-after-return.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Strip dead ``{ ; break; }`` blocks that follow a +``return`` or ``throw`` in worker-side translated JS. + +The bytecode translator already strips these in its per-method post-emit +pipeline (see ``stripDeadCodeAfterTerminator`` in +``JavascriptMethodGenerator.java``), but ``esbuild --minify-syntax`` +reorders and merges adjacent blocks inside switch cases without realising +that a trailing ``{pc=N;break}`` block becomes unreachable after the +preceding statements collapse into a return / throw. Esbuild's own +dead-code elimination is conservative inside switch cases and leaves +these blocks intact -- so we strip them here, post-esbuild. + +Conservative: only drops a balanced ``{...}`` block whose contents are +exactly `` = ; break;``. Anything more complex (function +calls, multiple statements, control flow) is left untouched. + +Usage: + python3 strip-dead-code-after-return.py [...] +""" +from __future__ import annotations + +import os +import sys +from typing import List, Tuple + + +def _skip_string(src: str, i: int) -> int: + """Skip a JS string starting at ``src[i]``. Handles ``"..."``, + ``'...'`` and template literals ```...``` (without ``${...}`` + substitution -- the translator's output uses backticks only for + simple multi-glyph token strings). Returns the index of the closing + quote, or -1 if unterminated.""" + quote = src[i] + n = len(src) + i += 1 + while i < n: + c = src[i] + if c == "\\": + i += 2 + continue + if c == quote: + return i + i += 1 + return -1 + + +def _is_ident_part(c: str) -> bool: + return c.isalnum() or c == "_" or c == "$" + + +def _find_expression_statement_end(src: str, i: int) -> int: + """Find the end of a return/throw expression (top-level ``;``).""" + n = len(src) + paren = brace = bracket = 0 + while i < n: + c = src[i] + if c == '"' or c == "'" or c == "`": + end = _skip_string(src, i) + if end < 0: + return -1 + i = end + 1 + continue + if c == "(": + paren += 1 + elif c == ")": + if paren == 0: + return i + paren -= 1 + elif c == "{": + brace += 1 + elif c == "}": + if brace == 0: + return i + brace -= 1 + elif c == "[": + bracket += 1 + elif c == "]": + bracket -= 1 + elif c == ";" and paren == 0 and brace == 0 and bracket == 0: + return i + i += 1 + return n + + +def _match_brace(src: str, open_idx: int) -> int: + n = len(src) + depth = 0 + i = open_idx + while i < n: + c = src[i] + if c == '"' or c == "'" or c == "`": + end = _skip_string(src, i) + if end < 0: + return -1 + i = end + 1 + continue + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def _find_statement_end_with_comma(src: str, i: int) -> int: + """Find the end of an assignment statement -- stops at top-level + ``;`` (NOT ``,``: ``a = b, c = d`` is two comma-expressions but the + ``break`` follows the top-level ``;``).""" + return _find_expression_statement_end(src, i) + + +def _is_dead_pc_bump_block(inner: str) -> bool: + """True when ``inner`` is exactly `` = ; break;?`` -- + the shape a pc-bump trailer takes in worker-side translated JS.""" + n = len(inner) + i = 0 + while i < n and inner[i].isspace(): + i += 1 + # Identifier (assignment LHS) + ident_start = i + while i < n and _is_ident_part(inner[i]): + i += 1 + if i == ident_start: + return False + while i < n and inner[i].isspace(): + i += 1 + if i >= n or inner[i] != "=": + return False + # Avoid ``==`` / ``=>`` + if i + 1 < n and inner[i + 1] in ("=", ">"): + return False + rhs_end = _find_statement_end_with_comma(inner, i + 1) + if rhs_end < 0 or rhs_end >= n or inner[rhs_end] != ";": + return False + p = rhs_end + 1 + while p < n and inner[p].isspace(): + p += 1 + if not inner.startswith("break", p): + return False + after_break = p + 5 + if after_break < n and _is_ident_part(inner[after_break]): + return False + while after_break < n and inner[after_break].isspace(): + after_break += 1 + if after_break < n and inner[after_break] == ";": + after_break += 1 + while after_break < n and inner[after_break].isspace(): + after_break += 1 + return after_break == n + + +def strip_file(path: str) -> Tuple[int, int]: + """Process a JS file in place. Returns (bytes_before, bytes_after).""" + with open(path, "rb") as f: + raw = f.read() + src = raw.decode("utf-8") + before = len(src) + out: List[str] = [] + n = len(src) + i = 0 + while i < n: + c = src[i] + if c == '"' or c == "'" or c == "`": + end = _skip_string(src, i) + if end < 0: + out.append(src[i:]) + break + out.append(src[i : end + 1]) + i = end + 1 + continue + kw_len = 0 + if c == "r" and src.startswith("return", i): + if (i == 0 or not _is_ident_part(src[i - 1])) and ( + i + 6 >= n or not _is_ident_part(src[i + 6]) + ): + kw_len = 6 + elif c == "t" and src.startswith("throw", i): + if (i == 0 or not _is_ident_part(src[i - 1])) and ( + i + 5 >= n or not _is_ident_part(src[i + 5]) + ): + kw_len = 5 + if kw_len == 0: + out.append(c) + i += 1 + continue + out.append(src[i : i + kw_len]) + i += kw_len + stmt_end = _find_expression_statement_end(src, i) + if stmt_end < 0 or stmt_end >= n: + out.append(src[i:]) + break + out.append(src[i : stmt_end + 1]) + i = stmt_end + 1 + if src[stmt_end] != ";": + continue + peek = i + while peek < n and src[peek].isspace(): + peek += 1 + if peek >= n or src[peek] != "{": + continue + close_brace = _match_brace(src, peek) + if close_brace < 0 or close_brace >= n: + continue + inner = src[peek + 1 : close_brace] + if not _is_dead_pc_bump_block(inner): + continue + i = close_brace + 1 + result = "".join(out) + after = len(result) + if after != before: + with open(path, "wb") as f: + f.write(result.encode("utf-8")) + return before, after + + +def iter_targets(args: List[str]): + for arg in args: + if os.path.isfile(arg): + yield arg + elif os.path.isdir(arg): + for name in sorted(os.listdir(arg)): + if not name.endswith(".js"): + continue + # Skip vendor / loader chunks + if name in ("browser_bridge.js", "port.js", "worker.js", "sw.js"): + continue + if name.endswith("_native_handlers.js"): + continue + yield os.path.join(arg, name) + + +def main(argv: List[str]) -> int: + if len(argv) < 2: + print("usage: strip-dead-code-after-return.py ...", file=sys.stderr) + return 2 + total_before = 0 + total_after = 0 + for path in iter_targets(argv[1:]): + before, after = strip_file(path) + total_before += before + total_after += after + if before != after: + print( + f"[strip-dead-code] {os.path.basename(path)}: " + f"{before} -> {after} (-{before - after} bytes)" + ) + if total_before: + saved = total_before - total_after + print( + f"[strip-dead-code] total saved: {saved} bytes " + f"({100.0 * saved / total_before:.2f}%)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/test-blackbar-textfield.mjs b/scripts/test-blackbar-textfield.mjs new file mode 100644 index 0000000000..68b6507470 --- /dev/null +++ b/scripts/test-blackbar-textfield.mjs @@ -0,0 +1,460 @@ +// Deep integration test for the JS-port "black bar" / "label-goes-transparent" +// regression: when the user clicks the first TextField on the Initializr +// landing page (Essentials -> "Main class" / "MyAppName") the native HTML +// editor launches and the canvas region above the field stops rendering, +// leaving the page background visible. Reproduces in headless Chrome. +// +// What this test does: +// 1. Serves the freshly-built Initializr-js bundle on a local HTTP port. +// 2. Loads it in headless Chromium with deviceScaleFactor=2 (the user +// hits the bug at retina DPR; some clip-rect / paint paths only get +// exercised when DPR != 1). +// 3. Captures every browser console message and pageerror -- including +// "unreachable code after return statement" warnings, which the user +// noticed while reproducing and suspects are diagnostic. +// 4. Waits for the main thread to fully settle, snapshots the canvas +// region directly above the MyAppName text field. +// 5. Clicks the MyAppName text field at its known position, waits for +// the native editor overlay to be created ( +// attached to the document). +// 6. Snapshots the same region and computes: +// - transparentFrac: pixel fraction with alpha == 0 (clearRect with +// no follow-up fill -- the failure mode the user described). +// - colorDeltaFrac: pixel fraction whose color changed by more than +// a small threshold from before-click. +// A clean repaint should leave the label area essentially unchanged +// (delta near zero, transparent near zero). The bug surface: high +// transparent fraction, high color delta. +// 7. Captures the canvas screenshot to a deterministic output path so a +// human reviewer can see the artifact for any failing run. +// 8. Counts and reports any "unreachable code" warnings the bundle +// emitted during boot or after the click. +// +// Exit code is 0 on PASS, non-zero with diagnostic output on FAIL. +// +// Usage: node scripts/test-blackbar-textfield.mjs [bundle.zip] + +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn, execSync } from 'node:child_process'; + +const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const DEFAULT_BUNDLE = path.join(REPO_ROOT, 'scripts/initializr/javascript/target/initializr-javascript-port.zip'); +const bundle = process.argv[2] || DEFAULT_BUNDLE; +if (!fs.existsSync(bundle)) { + console.error('bundle not found:', bundle); + process.exit(2); +} + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'blackbar-test-')); +const bundleDir = path.join(tmpDir, 'bundle'); +fs.mkdirSync(bundleDir); +execSync(`unzip -q "${bundle}" -d "${bundleDir}"`); +const distEntry = fs.readdirSync(bundleDir) + .filter(n => fs.statSync(path.join(bundleDir, n)).isDirectory())[0]; +const distDir = path.join(bundleDir, distEntry); + +const PORT = 8773; +const server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', distDir], + { stdio: ['ignore', 'ignore', 'pipe'] }); +await new Promise(r => setTimeout(r, 800)); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 2, +}); + +const messages = []; +const unreachableWarnings = []; +const pageErrors = []; +page.on('console', msg => { + const text = msg.text(); + const type = msg.type(); + messages.push(`[${type}] ${text}`); + // Firefox-style and Chrome-style warning text varies. Match both. + if (/unreachable code|Unreachable code/.test(text)) { + unreachableWarnings.push({ type, text, location: msg.location() }); + } +}); +page.on('pageerror', err => { + pageErrors.push(err.message); + messages.push(`[pageerror] ${err.message}`); + if (/unreachable code|Unreachable code/.test(err.message)) { + unreachableWarnings.push({ type: 'error', text: err.message, location: null }); + } +}); +page.on('worker', worker => { + worker.on('console', msg => { + const text = msg.text(); + messages.push(`[worker:${msg.type()}] ${text}`); + if (/unreachable code|Unreachable code/.test(text)) { + unreachableWarnings.push({ type: 'worker:' + msg.type(), text, location: msg.location() }); + } + }); +}); + +// Wrap canvas 2d-context primitives so we can capture every clearRect / +// fillRect / drawImage that hits the rect range we care about. This is +// strictly diagnostic: when the test fails, the dump tells us *which* +// op cleared the label region without a follow-up paint. +const opCaptureScript = ` +window.__cn1OpLog = []; +const __startCapture = function() { + const c = document.querySelector('canvas'); + if (!c || c.__captured) return; + c.__captured = true; + const ctx = c.getContext('2d'); + const labelYTop = ${`${0/* placeholder */}`}; + const wrap = (name) => { + const orig = ctx[name].bind(ctx); + ctx[name] = function() { + const a = arguments; + try { + if (window.__cn1OpLog.length < 8000) { + // For drawImage variants, the destination rect lives at a + // different arg index depending on overload (3-arg, 5-arg, + // 9-arg). Pull the dest rect for each shape so we record + // *what got drawn where* rather than the source crop. + let x = null, y = null, w = null, h = null; + if (name === 'drawImage') { + if (a.length === 3) { x = a[1]; y = a[2]; } + else if (a.length === 5) { x = a[1]; y = a[2]; w = a[3]; h = a[4]; } + else if (a.length === 9) { x = a[5]; y = a[6]; w = a[7]; h = a[8]; } + } else if (name === 'putImageData') { + x = a[1]; y = a[2]; + // Args 3-6 are dirty-rect (sx,sy,sw,sh) within the imageData. + } else if (name === 'fillText' || name === 'strokeText') { + x = a[1]; y = a[2]; + } else { + // clearRect, fillRect, strokeRect, rect: (x, y, w, h) + x = a[0]; y = a[1]; w = a[2]; h = a[3]; + } + window.__cn1OpLog.push({ op: name, + x: typeof x === 'number' ? x : null, + y: typeof y === 'number' ? y : null, + w: typeof w === 'number' ? w : null, + h: typeof h === 'number' ? h : null, + t: performance.now() }); + } + } catch (_) {} + return orig.apply(ctx, a); + }; + }; + ['clearRect', 'fillRect', 'strokeRect', 'rect', + 'drawImage', 'putImageData', 'fillText', 'strokeText'].forEach(wrap); +}; +// Start capture as soon as the canvas exists. +const __captureInterval = setInterval(() => { + if (document.querySelector('canvas')) { + __startCapture(); + clearInterval(__captureInterval); + } +}, 50); +`; +await page.addInitScript(opCaptureScript); + +console.log(`[blackbar] serving ${distDir} on :${PORT}`); +await page.goto(`http://127.0.0.1:${PORT}/`); + +// Wait for the main-thread-completed lifecycle marker (or 60s timeout). +const bootStart = Date.now(); +while (Date.now() - bootStart < 60_000) { + if (messages.some(m => m.includes('main-thread-completed'))) break; + await new Promise(r => setTimeout(r, 250)); +} +// A few extra seconds for late paints (theme loaded -> form re-laid-out). +await new Promise(r => setTimeout(r, 3000)); + +// Resolve the canvas's position+size in page CSS pixels so the test is +// independent of any chrome around the canvas (header bar, padding, etc.). +const canvasBox = await page.evaluate(() => { + const c = document.querySelector('canvas'); + if (!c) return null; + const r = c.getBoundingClientRect(); + return { x: r.left, y: r.top, w: r.width, h: r.height, + pxW: c.width, pxH: c.height, + dpr: window.devicePixelRatio || 1 }; +}); +console.log('[blackbar] canvas box:', canvasBox); + +// Layout in CANVAS-LOCAL CSS pixels (verified against /tmp/blackbar-before.png): +// y ~ 78 "Initializr - Scaffold a Project in Seconds" header +// y ~ 120 subtitle +// y ~ 195 "Essentials" subheader +// y ~ 228 "Main Class" LABEL <-- this is what disappears +// y ~ 255 MyAppName TEXT FIELD <-- the click target +const LABEL_X_LOCAL = 60, LABEL_W_LOCAL = 540; +const LABEL_Y_TOP_LOCAL = 215, LABEL_Y_BOT_LOCAL = 245; +const HEADER_Y_TOP_LOCAL = 175, HEADER_Y_BOT_LOCAL = 205; +const FIELD_X_LOCAL = 220, FIELD_Y_LOCAL = 258; +// Convert to page coords (the click is dispatched against the page, not +// the canvas), and to canvas-buffer pixels (where getImageData operates, +// scaled by deviceScaleFactor). +const PROBE_X = canvasBox.x + LABEL_X_LOCAL; +const PROBE_W = LABEL_W_LOCAL; +const LABEL_Y_TOP = canvasBox.y + LABEL_Y_TOP_LOCAL; +const LABEL_Y_BOT = canvasBox.y + LABEL_Y_BOT_LOCAL; +const HEADER_Y_TOP = canvasBox.y + HEADER_Y_TOP_LOCAL; +const HEADER_Y_BOT = canvasBox.y + HEADER_Y_BOT_LOCAL; +const FIELD_X = canvasBox.x + FIELD_X_LOCAL; +const FIELD_Y = canvasBox.y + FIELD_Y_LOCAL; + +async function sampleStrip(yTop, yBot) { + return await page.evaluate(({ x, w, y0, y1 }) => { + const c = document.querySelector('canvas'); + if (!c) return null; + // Map page-CSS coords to canvas-buffer coords. The canvas's + // bounding-rect width/height tell us the CSS size; c.width/c.height + // tell us the buffer size. Their ratio is what we need to scale by -- + // *not* devicePixelRatio, because the JS port doesn't size its + // backing buffer to match DPR (the canvas declares fixed + // ``width=375 height=667`` in the HTML and is then resized by the + // port logic to css-pixel dimensions). + const r = c.getBoundingClientRect(); + const sx = c.width / r.width; + const sy = c.height / r.height; + const ctx = c.getContext('2d'); + const px = Math.max(0, Math.floor((x - r.left) * sx)); + const py0 = Math.max(0, Math.floor((y0 - r.top) * sy)); + const py1 = Math.min(c.height, Math.floor((y1 - r.top) * sy)); + const pw = Math.min(c.width - px, Math.floor(w * sx)); + const ph = py1 - py0; + if (pw <= 0 || ph <= 0) return null; + const img = ctx.getImageData(px, py0, pw, ph).data; + let total = 0, transparent = 0, opaqueBlack = 0; + let rSum = 0, gSum = 0, bSum = 0; + // Build a 64-bin grayscale histogram so two snapshots can be diffed + // without keeping the pixel buffer around. + const hist = new Array(64).fill(0); + for (let p = 0; p < img.length; p += 4) { + total++; + const r = img[p], g = img[p + 1], b = img[p + 2], a = img[p + 3]; + if (a === 0) transparent++; + else { + const lum = (r + g + b) / 3 | 0; + if (lum < 8) opaqueBlack++; + rSum += r; gSum += g; bSum += b; + hist[(lum >> 2) & 63]++; + } + } + const opaque = total - transparent; + return { + total, + transparentFrac: transparent / total, + opaqueBlackFrac: opaqueBlack / total, + meanR: opaque ? rSum / opaque : 0, + meanG: opaque ? gSum / opaque : 0, + meanB: opaque ? bSum / opaque : 0, + hist, + }; + }, { x: PROBE_X, w: PROBE_W, y0: yTop, y1: yBot }); +} +function histDistance(a, b) { + if (!a || !b) return -1; + let d = 0; + for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]); + return d; +} + +await page.locator('canvas').screenshot({ path: '/tmp/blackbar-before.png' }).catch(() => {}); +const labelBefore = await sampleStrip(LABEL_Y_TOP, LABEL_Y_BOT); +const headerBefore = await sampleStrip(HEADER_Y_TOP, HEADER_Y_BOT); + +// Snapshot the canvas-op log RIGHT NOW so we have the boot/initial-paint +// trace separate from the post-click trace below. The 8000-entry buffer +// rolls; without this we'd lose the initial label paint by the time we +// look at the after-click region. +const opsBeforeClick = await page.evaluate(() => { + const out = (window.__cn1OpLog || []).slice(); + window.__cn1OpLog.length = 0; + return out; +}); +console.log(`[blackbar] captured ${opsBeforeClick.length} ops during boot/idle`); + +console.log('[blackbar] label strip pre-click:', + labelBefore && JSON.stringify({ + transparent: labelBefore.transparentFrac.toFixed(3), + meanR: labelBefore.meanR.toFixed(0), + meanG: labelBefore.meanG.toFixed(0), + meanB: labelBefore.meanB.toFixed(0), + })); + +// Click the MyAppName text field. Single down/up at native input position. +await page.mouse.click(FIELD_X, FIELD_Y); + +// Wait for the native edit overlay to be created. The cleanup path hides +// the input via display:none rather than removing it, so the first click +// should both create and show it. Poll for an attached with the +// cn1-edit-string class. +let nativeEditorAppeared = false; +let nativeEditorBox = null; +for (let i = 0; i < 60; i++) { + await new Promise(r => setTimeout(r, 100)); + const result = await page.evaluate(() => { + const inp = document.querySelector('input.cn1-edit-string'); + if (!(inp && inp.style.display !== 'none' && inp.parentNode)) return null; + const r = inp.getBoundingClientRect(); + return { x: r.left, y: r.top, w: r.width, h: r.height, + cssTop: inp.style.top, cssLeft: inp.style.left, + cssWidth: inp.style.width, cssHeight: inp.style.height }; + }); + if (result) { nativeEditorAppeared = true; nativeEditorBox = result; break; } +} +console.log('[blackbar] native input overlay appeared:', nativeEditorAppeared); +console.log('[blackbar] native input box (page CSS px):', nativeEditorBox); + +// Give the worker a few extra paint frames in case the label clears as +// part of an *async* repaint after the editor is attached. +await new Promise(r => setTimeout(r, 1500)); + +await page.locator('canvas').screenshot({ path: '/tmp/blackbar-after.png' }).catch(() => {}); +const labelAfter = await sampleStrip(LABEL_Y_TOP, LABEL_Y_BOT); +const headerAfter = await sampleStrip(HEADER_Y_TOP, HEADER_Y_BOT); + +// Also click somewhere ELSE (a non-input area) to take focus off the +// textfield. If the label re-renders, this is a live focus-tracked +// rendering issue. If it stays transparent, the canvas is missing the +// label paint forever, not just during the editing window. +await page.keyboard.press('Tab'); +await new Promise(r => setTimeout(r, 250)); +await page.mouse.click(canvasBox.x + 950, canvasBox.y + 870); // generate-button bottom bar +await new Promise(r => setTimeout(r, 500)); +await page.mouse.click(canvasBox.x + 1000, canvasBox.y + 400); // empty area in iphone preview +await new Promise(r => setTimeout(r, 1500)); +await page.locator('canvas').screenshot({ path: '/tmp/blackbar-after-defocus.png' }).catch(() => {}); +const labelAfterDefocus = await sampleStrip(LABEL_Y_TOP, LABEL_Y_BOT); +console.log('[blackbar] label strip post-defocus:', + labelAfterDefocus && JSON.stringify({ + transparent: labelAfterDefocus.transparentFrac.toFixed(3), + meanR: labelAfterDefocus.meanR.toFixed(0), + })); + +console.log('[blackbar] label strip post-click:', + labelAfter && JSON.stringify({ + transparent: labelAfter.transparentFrac.toFixed(3), + meanR: labelAfter.meanR.toFixed(0), + meanG: labelAfter.meanG.toFixed(0), + meanB: labelAfter.meanB.toFixed(0), + })); +const headerHistDelta = histDistance(headerBefore && headerBefore.hist, headerAfter && headerAfter.hist); +const labelHistDelta = histDistance(labelBefore && labelBefore.hist, labelAfter && labelAfter.hist); +console.log('[blackbar] header-strip hist delta (control):', headerHistDelta); +console.log('[blackbar] label-strip hist delta (suspect):', labelHistDelta); + +// Pass criteria: +// - native editor opened (precondition, otherwise we didn't repro) +// - label-strip transparent fraction stays low (< 0.02) +// - label-strip histogram delta is comparable to control header strip +// (within 4x). On the bug, the label strip becomes mostly transparent +// while the header is unchanged, so this ratio blows up. +// - no "unreachable code" warnings emitted from the bundle. +const failures = []; +if (!nativeEditorAppeared) { + failures.push('precondition: native input overlay never appeared after click'); +} +if (labelAfter && labelAfter.transparentFrac > 0.02) { + failures.push(`label strip is ${(labelAfter.transparentFrac * 100).toFixed(1)}% transparent post-click ` + + `(was ${labelBefore ? (labelBefore.transparentFrac * 100).toFixed(1) : '?'}% before)`); +} +if (labelHistDelta >= 0 && headerHistDelta >= 0 + && headerHistDelta < 200 && labelHistDelta > headerHistDelta * 4) { + failures.push(`label strip changed ${labelHistDelta} histogram bins vs ` + + `${headerHistDelta} for the static header strip -- the label area was repainted ` + + `or cleared while the surrounding form held still`); +} +if (unreachableWarnings.length > 0) { + failures.push(`bundle emitted ${unreachableWarnings.length} ` + + `"unreachable code" console warnings -- translator likely emits ` + + `dead code after a return/throw and the JS engine flags it`); +} + +console.log('[blackbar] unreachable-code warnings:', unreachableWarnings.length); +unreachableWarnings.slice(0, 5).forEach(w => { + console.log(` - ${w.type} ${w.text.slice(0, 200)}`); + if (w.location && w.location.url) { + console.log(` at ${w.location.url}:${w.location.lineNumber}:${w.location.columnNumber}`); + } +}); +console.log('[blackbar] pageerror count:', pageErrors.length); +pageErrors.slice(0, 5).forEach(e => console.log(' -', e)); + +// Pull the captured canvas-op log. Filter to ops whose y range lands +// inside the label strip we sampled, so we can see the offending clear / +// fill events directly. +function filterToLabelBand(log, y0, y1) { + return log.filter(o => { + if (o.y == null) return false; + if (o.y > y1 + 200) return false; + if (o.h != null && o.y + o.h < Math.max(0, y0 - 100)) return false; + if (o.h == null && o.y < Math.max(0, y0 - 100)) return false; + return true; + }); +} +// Print the BEFORE-click label-band fillText/drawImage ops -- those are +// what painted "Main Class" originally. If after-click is missing one of +// them, we know which one isn't firing. +const beforeBand = filterToLabelBand(opsBeforeClick, LABEL_Y_TOP, LABEL_Y_BOT); +console.log('[blackbar] BEFORE-click ops in label band (' + beforeBand.length + ', filtered to fillText/drawImage):'); +beforeBand.filter(o => o.op === 'fillText' || o.op === 'drawImage') + .forEach(o => console.log(` PRE ${o.op}(${(o.x|0)}, ${(o.y|0)}, ${o.w|0}, ${o.h|0}) t+${(o.t|0)}`)); + +// Show the FULL BEFORE-click trace immediately around the label-text +// fillText (56, 222), so we can see what clip / save / restore brackets +// the label's text-paint and compare it to the after-click sequence. +const labelFillIdx = beforeBand.findIndex(o => o.op === 'fillText' + && Math.abs(o.x - 56) < 6 + && Math.abs(o.y - 222) < 6); +if (labelFillIdx >= 0) { + const start = Math.max(0, labelFillIdx - 12); + const end = Math.min(beforeBand.length, labelFillIdx + 4); + console.log(`[blackbar] PRE context around the "Main Class" fillText (idx ${labelFillIdx}):`); + for (let i = start; i < end; i++) { + const o = beforeBand[i]; + const marker = i === labelFillIdx ? '>>>' : ' '; + console.log(` ${marker} ${o.op}(${(o.x|0)}, ${(o.y|0)}, ${o.w|0}, ${o.h|0}) t+${(o.t|0)}`); + } +} +const labelOps = await page.evaluate(({ y0Css, y1Css, fieldYCss }) => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + const sy = c.height / r.height; + const y0 = (y0Css - r.top) * sy; + const y1 = (y1Css - r.top) * sy; + const log = (window.__cn1OpLog || []); + // Filter: ops whose y is within 100px of the label band (or whose + // y+h overlaps it for ops that record a height). Includes fillText + // (no h) so we can see whether any text was actually drawn after + // the label clear. + return log.filter(o => { + if (o.y == null) return false; + if (o.y > y1 + 200) return false; + if (o.h != null && o.y + o.h < Math.max(0, y0 - 100)) return false; + if (o.h == null && o.y < Math.max(0, y0 - 100)) return false; + return true; + }) + .sort((a, b) => a.t - b.t) + .slice(-300); +}, { y0Css: LABEL_Y_TOP, y1Css: LABEL_Y_BOT, fieldYCss: FIELD_Y }); +console.log('[blackbar] last canvas ops touching label band (' + labelOps.length + '):'); +labelOps.forEach(o => console.log(` ${o.op}(${o.x | 0}, ${o.y | 0}, ${o.w | 0}, ${o.h | 0}) t+${(o.t | 0)}`)); + +// Also write the full message log so a human can grep for context. +fs.writeFileSync('/tmp/blackbar-messages.log', messages.join('\n')); +fs.writeFileSync('/tmp/blackbar-labelops.json', JSON.stringify(labelOps, null, 2)); +console.log('[blackbar] full log -> /tmp/blackbar-messages.log'); +console.log('[blackbar] before screenshot -> /tmp/blackbar-before.png'); +console.log('[blackbar] after screenshot -> /tmp/blackbar-after.png'); + +await browser.close(); +server.kill(); + +if (failures.length === 0) { + console.log('[blackbar] PASS'); + process.exit(0); +} +console.log('[blackbar] FAIL:'); +failures.forEach(f => console.log(' -', f)); +process.exit(1); diff --git a/scripts/test-boot-only.mjs b/scripts/test-boot-only.mjs new file mode 100644 index 0000000000..6e8537a786 --- /dev/null +++ b/scripts/test-boot-only.mjs @@ -0,0 +1,63 @@ +// Boot-only test: serve the bundle, open the page, wait, check whether +// PARPAR-LIFECYCLE:main-thread-completed fires WITHOUT any user interaction. +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn, execSync } from 'node:child_process'; + +const REPO_ROOT = '/Users/shai/dev/cn1'; +const bundle = path.join(REPO_ROOT, 'scripts/initializr/javascript/target/initializr-javascript-port.zip'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boot-only-')); +const bundleDir = path.join(tmpDir, 'bundle'); +fs.mkdirSync(bundleDir); +execSync(`unzip -q "${bundle}" -d "${bundleDir}"`); + +const serveDir = path.join(bundleDir, 'Initializr-js'); +const port = 7799; +const server = spawn('python3', ['-m', 'http.server', String(port)], { + cwd: serveDir, + stdio: ['ignore', 'ignore', 'pipe'] +}); +await new Promise(r => setTimeout(r, 700)); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); +const page = await ctx.newPage(); + +const messages = []; +page.on('console', msg => messages.push(`[${msg.type()}] ${msg.text()}`)); +page.on('pageerror', err => messages.push(`[pageerror] ${err.message}`)); +page.on('worker', worker => { + worker.on('console', msg => messages.push(`[worker:${msg.type()}] ${msg.text()}`)); +}); + +await page.goto(`http://127.0.0.1:${port}/`); +console.log('Page loaded. Waiting 30s without any interaction...'); + +await new Promise(r => setTimeout(r, 90000)); + +console.log('---SUMMARY---'); +const completed = messages.filter(m => m.includes('main-thread-completed')); +const formChanged = messages.filter(m => m.includes('currentForm CHANGED')); +const lastCallback = messages.filter(m => m.includes('main-host-callback')).slice(-1)[0] || '(none)'; +console.log('main-thread-completed events:', completed.length); +console.log('currentForm CHANGED events:', formChanged.length); +console.log('Last main-host-callback:', lastCallback); +console.log('Total messages:', messages.length); +const errors = messages.filter(m => m.includes('Exception') || m.includes('[error]') || m.includes('[pageerror]')); +console.log('Error messages:', errors.length); +errors.slice(0, 5).forEach(e => console.log(' ', e)); + +if (completed.length > 0) { + console.log('--- BOOT COMPLETES NATURALLY ---'); +} else { + console.log('--- BOOT NEVER COMPLETES ---'); +} +console.log('--- ALL MESSAGES ---'); +fs.writeFileSync('/tmp/boot-all-messages.log', messages.join('\n')); +console.log('messages dumped to /tmp/boot-all-messages.log'); + +await browser.close(); +server.kill(); diff --git a/scripts/test-deployed-initializr.mjs b/scripts/test-deployed-initializr.mjs new file mode 100644 index 0000000000..32bdea6035 --- /dev/null +++ b/scripts/test-deployed-initializr.mjs @@ -0,0 +1,86 @@ +// Reproduces the user's report by driving the deployed Initializr in +// its actual iframe context. The local bundle (white body bg) masks the +// black-square pattern because the iframe parent supplies the dark bg +// only in production. +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, +}); +const page = await context.newPage(); +const messages = []; +page.on('console', m => messages.push(`[${m.type()}] ${m.text()}`)); +page.on('pageerror', e => messages.push(`[error] ${e.message}`)); + +await page.goto('https://pr-4795-website-preview.codenameone.pages.dev/initializr/', { waitUntil: 'networkidle' }); +console.log('loaded outer page'); +await page.waitForSelector('#cn1-initializr-frame'); +const frameElement = await page.$('#cn1-initializr-frame'); +const frame = await frameElement.contentFrame(); +// Wait until the loader hides (cn1-initializr-ui-ready postMessage fires) +// or until the canvas has been resized away from its initial 320x480. +await page.waitForFunction(() => { + const loader = document.getElementById('cn1-initializr-loader'); + return loader && loader.classList.contains('done'); +}, { timeout: 180000 }).catch(() => console.log('loader-done timeout')); +// Give the form an extra few seconds to lay out / finish first paint +await new Promise(r => setTimeout(r, 8000)); + +await page.screenshot({ path: '/tmp/deployed-before-edit.png', fullPage: true }); +console.log('saved /tmp/deployed-before-edit.png'); + +// Click somewhere into the Main Class textfield area inside the iframe. +const box = await frameElement.boundingBox(); +console.log('iframe box:', box); +// User report: clicking the Main Class field (form left side, near top of essentials panel). +// Use page.mouse coordinates — these are page-relative; the iframe is positioned with absolute +// top so the form fields end up around y=300-350 in viewport coords for typical layouts. +const candidates = [ + { x: box.x + 200, y: box.y + 240, label: 'mainclass-A' }, + { x: box.x + 200, y: box.y + 290, label: 'mainclass-B' }, + { x: box.x + 200, y: box.y + 340, label: 'mainclass-C' }, + { x: box.x + 200, y: box.y + 410, label: 'package-A' }, +]; +for (const c of candidates) { + console.log(`click ${c.label} at`, c.x, c.y); + await page.mouse.click(c.x, c.y); + await new Promise(r => setTimeout(r, 600)); + await page.keyboard.type('Hello', { delay: 80 }); + await new Promise(r => setTimeout(r, 600)); + await page.screenshot({ path: `/tmp/deployed-after-${c.label}.png`, fullPage: false }); + console.log(`saved /tmp/deployed-after-${c.label}.png`); + // Inspect the canvas inside the iframe for transparent / pure-black regions + const stats = await frame.evaluate(({ cx, cy }) => { + const canvas = document.querySelector('#codenameone-canvas'); + if (!canvas) return { err: 'no canvas' }; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + // Map page coord (cx, cy) to canvas-pixel coord + const rect = canvas.getBoundingClientRect(); + const px = Math.floor((cx - rect.left) * dpr); + const py = Math.floor((cy - rect.top) * dpr); + const stripH = Math.floor(80 * dpr); + const stripW = Math.floor(180 * dpr); + let blackPx = 0, transparentPx = 0, total = 0; + try { + const data = ctx.getImageData(Math.max(0, px - stripW/2), Math.max(0, py - stripH), stripW, stripH).data; + for (let p = 0; p < data.length; p += 4) { + total++; + const lum = (data[p] + data[p+1] + data[p+2]) / 3 | 0; + if (data[p+3] === 0) transparentPx++; + if (lum < 8 && data[p+3] > 0) blackPx++; + } + } catch (e) { return { err: String(e) }; } + return { total, blackPx, transparentPx, blackFrac: blackPx/total, transparentFrac: transparentPx/total, canvasW: canvas.width, canvasH: canvas.height }; + }, { cx: c.x, cy: c.y }); + console.log(` strip-stats:`, JSON.stringify(stats)); + // dismiss editor before next click + await page.keyboard.press('Escape'); + await page.keyboard.press('Tab'); + await new Promise(r => setTimeout(r, 300)); +} + +await browser.close(); +console.log('done'); diff --git a/scripts/test-initializr-features.mjs b/scripts/test-initializr-features.mjs new file mode 100644 index 0000000000..8137ff23c3 --- /dev/null +++ b/scripts/test-initializr-features.mjs @@ -0,0 +1,625 @@ +// Comprehensive feature test for the Initializr JS-port bundle. Each +// scenario reloads the page so leftover modal/menu state from one +// scenario does not pollute the next. +// +// Scenarios: +// 1. textfield: click MyAppName, expect "Main Class" label stays +// visible (the d91a4f975 fix) +// 2. dialog: click Hello-World button, dialog body should fill +// with the dialog's white bg +// 3. side-menu: hamburger animation should not flicker through +// many distinct states +// 4. template-buttons: each radio button click should swap +// selection -- previously-selected button transitions away +// from the bright-blue selected color, the clicked one +// transitions toward it +// 5. toggle-mashing: rapidly click toggle buttons; worker should +// remain alive afterwards +// +// Run: node scripts/test-initializr-features.mjs [bundle.zip] + +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn, execSync } from 'node:child_process'; + +const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const DEFAULT_BUNDLE = path.join(REPO_ROOT, 'scripts/initializr/javascript/target/initializr-javascript-port.zip'); + +// Two run modes: +// +// 1. Local bundle (default). ``node scripts/test-initializr-features.mjs [path/to/bundle.zip]`` +// Unzips the bundle, serves it from a local python3 http server, and +// runs every scenario against ``http://127.0.0.1:PORT/``. This is what +// CI runs after each translator build to validate the artefact before +// the deploy step. +// +// 2. URL. ``node scripts/test-initializr-features.mjs --url=https://...`` +// Skips local serving and runs every scenario directly against the +// supplied URL. Use this to smoke-test the deploy preview after CI has +// finished -- the browser-level loader hides 8 s after the iframe +// loads even if the canvas is still blank, so the user-facing page +// looks "ready" while the worker is actually wedged. Pointing the +// feature test at the live URL catches that regression directly +// instead of relying on a separate "is the deploy alive" probe. +const argUrl = process.argv.find(a => a.startsWith('--url=')); +const TEST_URL = argUrl ? argUrl.slice('--url='.length) : null; + +let server = null; +let TEST_BASE_URL; +if (TEST_URL) { + TEST_BASE_URL = TEST_URL; + console.log(`[features] running against URL: ${TEST_BASE_URL}`); +} else { + const bundle = process.argv[2] && !process.argv[2].startsWith('--') + ? process.argv[2] : DEFAULT_BUNDLE; + if (!fs.existsSync(bundle)) { + console.error('bundle not found:', bundle); + process.exit(2); + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'init-features-')); + const bundleDir = path.join(tmpDir, 'bundle'); + fs.mkdirSync(bundleDir); + execSync(`unzip -q "${bundle}" -d "${bundleDir}"`); + const distEntry = fs.readdirSync(bundleDir) + .filter(n => fs.statSync(path.join(bundleDir, n)).isDirectory())[0]; + const distDir = path.join(bundleDir, distEntry); + const PORT = 8775; + server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', distDir], + { stdio: ['ignore', 'ignore', 'pipe'] }); + await new Promise(r => setTimeout(r, 800)); + TEST_BASE_URL = `http://127.0.0.1:${PORT}/`; + console.log(`[features] serving local bundle: ${distDir} -> ${TEST_BASE_URL}`); +} + +const browser = await chromium.launch({ headless: true }); + +const ART_DIR = '/tmp/init-features-artifacts'; +fs.mkdirSync(ART_DIR, { recursive: true }); +// Wipe stale screenshots from previous runs so reading them after a +// failure can't surface a misleading older state. +for (const f of fs.readdirSync(ART_DIR)) fs.unlinkSync(path.join(ART_DIR, f)); + +const failures = []; +function fail(label, msg) { + console.log(`[features] FAIL ${label}: ${msg}`); + failures.push(`${label}: ${msg}`); +} + +// Boot deadlines. The first version of this test waited 60 s for +// ``main-thread-completed`` and then proceeded REGARDLESS -- which +// meant a regression that made boot take 90 s+ silently passed boot +// and started clicking on a half-loaded page, where every assertion +// failed on a transparent canvas with confusing "click was dropped" +// messages. The pre-fix bundle finished in ~2 s; anything substantially +// longer is a regression worth flagging immediately. +// +// ``BOOT_COMPLETE_BUDGET_MS`` is the soft upper bound for +// ``main-thread-completed`` -- exceeding it fails the scenario hard +// instead of silently letting the rest of the test run on a wedged +// page. ``FIRST_PAINT_BUDGET_MS`` enforces that the canvas actually +// shows non-trivial content (the rendered Initializr UI) before any +// scenario starts measuring colours; the boot lifecycle marker can +// fire while the canvas is still all-white if the post-boot paint +// pipeline has stalled. +const BOOT_COMPLETE_BUDGET_MS = Number(process.env.CN1_BOOT_BUDGET_MS) || 15_000; +const FIRST_PAINT_BUDGET_MS = Number(process.env.CN1_FIRST_PAINT_BUDGET_MS) || 20_000; + +// Each scenario opens its own fresh page so menu / dialog state from a +// previous scenario can't bleed in. Returns { page, messages, pageErrors, +// canvasBox, snap(label), bootMs, firstPaintMs }. +async function bootScenario(scenarioLabel) { + const page = await browser.newPage({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 2, + }); + const messages = []; + const pageErrors = []; + page.on('console', msg => messages.push(`[${msg.type()}] ${msg.text()}`)); + page.on('pageerror', err => { + pageErrors.push(err.message); + messages.push(`[pageerror] ${err.message}`); + }); + page.on('worker', worker => { + worker.on('console', msg => messages.push(`[worker:${msg.type()}] ${msg.text()}`)); + }); + + const bootStart = Date.now(); + await page.goto(TEST_BASE_URL); + + // Phase 1: wait for the worker to finish its lifecycle.start chain. + let bootMs = null; + while (Date.now() - bootStart < BOOT_COMPLETE_BUDGET_MS) { + if (messages.some(m => m.includes('main-thread-completed'))) { + bootMs = Date.now() - bootStart; + break; + } + await new Promise(r => setTimeout(r, 100)); + } + if (bootMs === null) { + const out = path.join(ART_DIR, `${scenarioLabel}-boot-timeout.png`); + await page.locator('canvas').screenshot({ path: out }).catch(() => {}); + throw new Error( + `boot did not reach main-thread-completed in ${BOOT_COMPLETE_BUDGET_MS} ms ` + + `(this is the regression that surfaced as the deployed UI being stuck ` + + `on the Loading... splash). Tail of console:\n ` + + messages.slice(-5).map(m => m.slice(0, 200)).join('\n ')); + } + + // Phase 2: wait for non-trivial canvas content. ``main-thread-completed`` + // means the EDT lifecycle finished, but the FIRST paint cycle (which + // queues ops to ``pendingDisplay`` and posts a requestAnimationFrame) + // has its own latency. If the rAF reply from the main thread never + // comes back -- or comes back into a worker still bottle-necked on + // its own queue -- the canvas stays white. Hard-fail rather than + // proceed to click on a still-blank page. + // + // ``--url=`` mode might be pointed at an iframe-parent page (e.g. + // ``/initializr/`` on the deploy preview, where the bundle is loaded + // inside ``