From 2c45875fc389cb881d7e3de3f60e70e531b91734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 19:50:18 +0200 Subject: [PATCH 1/3] ci: keep Bundle Size job green on transient GitHub comment failures The size measurement and job summary had already succeeded on PR #1789 (run 32050847506) when the PR comment write got a 503 during a GitHub incident and failed the whole lane. --post-comment now retries 5xx / 429 / network errors (4 attempts, 1s/2s/4s backoff) on both the list and write calls. If it still fails, it prints a ::warning::, appends a note to $GITHUB_STEP_SUMMARY, and exits 0. Other 4xx (bad token, missing permissions) stay fatal. --- scripts/size-report.mjs | 80 +++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs index 42e3a522a5..8f7ccbe40a 100644 --- a/scripts/size-report.mjs +++ b/scripts/size-report.mjs @@ -6,6 +6,9 @@ import { performance } from 'node:perf_hooks'; import { gzipSync } from 'node:zlib'; const COMMENT_MARKER = ''; +const GITHUB_REQUEST_ATTEMPTS = 4; +const GITHUB_RETRY_BASE_MS = 1000; +class TransientGitHubError extends Error {} const VALUE_ARGS = new Map([ ['--cwd', 'cwd'], ['--json', 'json'], @@ -25,7 +28,7 @@ const args = parseArgs(process.argv.slice(2)); const cwd = path.resolve(args.cwd ?? process.cwd()); if (args.postComment) { - await postGitHubComment(args.postComment, args.pr); + await postGitHubCommentBestEffort(args.postComment, args.pr); process.exit(0); } @@ -361,6 +364,25 @@ function writeFile(filePath, contents) { fs.writeFileSync(filePath, contents); } +// The PR comment is a convenience surface: the same markdown is already in the +// job summary. A GitHub outage (5xx / 429 / network error) must not fail the +// job, but a real misconfiguration (bad token, missing permissions) still does. +async function postGitHubCommentBestEffort(markdownPath, explicitPrNumber) { + try { + await postGitHubComment(markdownPath, explicitPrNumber); + } catch (error) { + if (!(error instanceof TransientGitHubError)) throw error; + const message = `Skipping PR size comment after transient GitHub failure: ${error.message}`; + process.stdout.write(`::warning::${message}\n`); + appendStepSummary(`> ⚠️ ${message} The size report above is authoritative.\n`); + } +} + +function appendStepSummary(text) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) fs.appendFileSync(summaryPath, text); +} + async function postGitHubComment(markdownPath, explicitPrNumber) { const config = readGitHubCommentConfig(explicitPrNumber); const body = fs.readFileSync(markdownPath, 'utf8'); @@ -407,21 +429,21 @@ function buildCommentsUrl(repository, prNumber) { } async function listGitHubComments(commentsUrl, headers) { - const response = await fetch(`${commentsUrl}?per_page=100`, { headers }); - if (!response.ok) { - throw new Error(`Failed to list PR comments: ${response.status} ${await response.text()}`); - } + const response = await githubRequest( + `${commentsUrl}?per_page=100`, + { headers }, + 'list PR comments', + ); return await response.json(); } async function writeGitHubComment(commentsUrl, headers, body, existingUrl) { const target = commentWriteTarget(commentsUrl, existingUrl); - const response = await fetch(target.url, { - method: target.method, - headers, - body: JSON.stringify({ body }), - }); - await assertGitHubWriteResponse(response, target.action); + await githubRequest( + target.url, + { method: target.method, headers, body: JSON.stringify({ body }) }, + `${target.action} PR comment`, + ); } function commentWriteTarget(commentsUrl, existingUrl) { @@ -431,8 +453,38 @@ function commentWriteTarget(commentsUrl, existingUrl) { return { url: commentsUrl, method: 'POST', action: 'create' }; } -async function assertGitHubWriteResponse(response, action) { - if (!response.ok) { - throw new Error(`Failed to ${action} PR comment: ${response.status} ${await response.text()}`); +// Retries 5xx / 429 / network errors with exponential backoff; any other +// non-OK status is a configuration problem and throws a plain (fatal) Error. +async function githubRequest(url, init, action) { + let failure = ''; + for (let attempt = 1; attempt <= GITHUB_REQUEST_ATTEMPTS; attempt += 1) { + const result = await attemptGitHubRequest(url, init, action); + if (result.response) return result.response; + failure = result.failure; + if (attempt < GITHUB_REQUEST_ATTEMPTS) { + const delayMs = GITHUB_RETRY_BASE_MS * 2 ** (attempt - 1); + process.stderr.write(`${failure} (retrying in ${delayMs}ms)\n`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } + throw new TransientGitHubError(`${failure} after ${GITHUB_REQUEST_ATTEMPTS} attempts`); +} + +// Resolves to { response } on success or { failure } on a transient failure; +// throws a plain Error on a non-transient one. +async function attemptGitHubRequest(url, init, action) { + let response; + try { + response = await fetch(url, init); + } catch (error) { + return { failure: `Failed to ${action}: ${error?.message ?? error}` }; + } + if (response.ok) return { response }; + const failure = `Failed to ${action}: ${response.status} ${await response.text()}`; + if (isTransientGitHubStatus(response.status)) return { failure }; + throw new Error(failure); +} + +function isTransientGitHubStatus(status) { + return status === 429 || status >= 500; } From 2baae332230fa5c20dfa1cab171abfac02b9c6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 07:43:18 +0200 Subject: [PATCH 2/3] refactor: split GitHub response classification to satisfy fallow complexity gate --- .../maestro-conformance/corpus/authored/doubletap.yaml | 2 +- .../corpus/authored/extended-wait.yaml | 4 ++-- scripts/maestro-conformance/corpus/authored/repeat.yaml | 2 +- .../corpus/authored/runflow-child.yaml | 2 +- .../maestro-conformance/corpus/authored/runflow-main.yaml | 4 ++-- .../corpus/authored/scroll-until-visible.yaml | 2 +- .../corpus/bug-classes/percent-decimal-swipe.yaml | 4 ++-- .../corpus/bug-classes/retry-over-cap.yaml | 2 +- .../corpus/bug-classes/settle-after-tap.yaml | 2 +- .../bug-classes/target-swipe-missing-direction.yaml | 2 +- .../corpus/invalid/commands-not-a-list.yaml | 2 +- .../corpus/invalid/malformed-selector.yaml | 2 +- .../corpus/invalid/unknown-command.yaml | 2 +- .../corpus/invalid/unknown-selector-field.yaml | 2 +- .../corpus/upstream/001_assert_visible_by_id.yaml | 2 +- .../corpus/upstream/002_assert_visible_by_text.yaml | 2 +- .../corpus/upstream/008_tap_on_element.yaml | 2 +- .../corpus/upstream/009_skip_optional_elements.yaml | 6 +++--- .../maestro-conformance/corpus/upstream/010_scroll.yaml | 2 +- .../corpus/upstream/011_back_press.yaml | 2 +- .../corpus/upstream/012_input_text.yaml | 4 ++-- .../corpus/upstream/013_launch_app.yaml | 2 +- .../corpus/upstream/014_tap_on_point.yaml | 2 +- .../maestro-conformance/corpus/upstream/017_swipe.yaml | 2 +- .../corpus/upstream/021_launch_app_with_clear_state.yaml | 2 +- .../corpus/upstream/026_assert_not_visible.yaml | 2 +- .../corpus/upstream/027_open_link.yaml | 2 +- .../corpus/upstream/029_long_press_on_element.yaml | 2 +- .../corpus/upstream/032_element_index.yaml | 2 +- .../corpus/upstream/034_press_key.yaml | 1 - .../corpus/upstream/039_hide_keyboard.yaml | 2 +- .../corpus/upstream/042_extended_wait.yaml | 2 +- .../corpus/upstream/053_repeat_times.yaml | 4 ++-- .../corpus/upstream/059_directional_swipe_command.yaml | 2 +- .../corpus/upstream/061_launchApp_withoutStopping.yaml | 2 +- .../corpus/upstream/062_copy_paste_text.yaml | 2 +- .../corpus/upstream/067_assertTrue_pass.yaml | 2 +- .../corpus/upstream/069_wait_for_animation_to_end.yaml | 2 +- .../corpus/upstream/074_directional_swipe_element.yaml | 2 +- .../corpus/upstream/076_optional_assertion.yaml | 8 ++++---- .../corpus/upstream/078_swipe_relative.yaml | 6 +++--- .../corpus/upstream/079_scroll_until_visible.yaml | 4 ++-- .../corpus/upstream/114_child_of_selector.yaml | 8 ++++---- .../upstream/120_tap_on_element_retryTapIfNoChange.yaml | 4 ++-- .../corpus/upstream/131_setPermissions.yaml | 2 +- scripts/size-report.mjs | 4 ++++ 46 files changed, 65 insertions(+), 62 deletions(-) diff --git a/scripts/maestro-conformance/corpus/authored/doubletap.yaml b/scripts/maestro-conformance/corpus/authored/doubletap.yaml index 4e5c0e1ec6..c587fe7686 100644 --- a/scripts/maestro-conformance/corpus/authored/doubletap.yaml +++ b/scripts/maestro-conformance/corpus/authored/doubletap.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- doubleTapOn: "Button" +- doubleTapOn: 'Button' diff --git a/scripts/maestro-conformance/corpus/authored/extended-wait.yaml b/scripts/maestro-conformance/corpus/authored/extended-wait.yaml index 9008d12961..f548f1a367 100644 --- a/scripts/maestro-conformance/corpus/authored/extended-wait.yaml +++ b/scripts/maestro-conformance/corpus/authored/extended-wait.yaml @@ -2,9 +2,9 @@ appId: com.example.app --- - extendedWaitUntil: visible: - id: "Item" + id: 'Item' timeout: 1000 - extendedWaitUntil: notVisible: - id: "Another" + id: 'Another' timeout: 1000 diff --git a/scripts/maestro-conformance/corpus/authored/repeat.yaml b/scripts/maestro-conformance/corpus/authored/repeat.yaml index a94ff58426..02a7ba32d8 100644 --- a/scripts/maestro-conformance/corpus/authored/repeat.yaml +++ b/scripts/maestro-conformance/corpus/authored/repeat.yaml @@ -3,4 +3,4 @@ appId: com.example.app - repeat: times: 3 commands: - - tapOn: "Button" + - tapOn: 'Button' diff --git a/scripts/maestro-conformance/corpus/authored/runflow-child.yaml b/scripts/maestro-conformance/corpus/authored/runflow-child.yaml index 9e122a1669..988afa98e5 100644 --- a/scripts/maestro-conformance/corpus/authored/runflow-child.yaml +++ b/scripts/maestro-conformance/corpus/authored/runflow-child.yaml @@ -2,4 +2,4 @@ appId: com.example.include --- - launchApp - tapOn: - id: "included-button" + id: 'included-button' diff --git a/scripts/maestro-conformance/corpus/authored/runflow-main.yaml b/scripts/maestro-conformance/corpus/authored/runflow-main.yaml index 85f2abe856..3a4706b79f 100644 --- a/scripts/maestro-conformance/corpus/authored/runflow-main.yaml +++ b/scripts/maestro-conformance/corpus/authored/runflow-main.yaml @@ -1,5 +1,5 @@ appId: com.example.app --- -- tapOn: "Before" +- tapOn: 'Before' - runFlow: runflow-child.yaml -- tapOn: "After" +- tapOn: 'After' diff --git a/scripts/maestro-conformance/corpus/authored/scroll-until-visible.yaml b/scripts/maestro-conformance/corpus/authored/scroll-until-visible.yaml index c578a3b9d6..0d3a50db2b 100644 --- a/scripts/maestro-conformance/corpus/authored/scroll-until-visible.yaml +++ b/scripts/maestro-conformance/corpus/authored/scroll-until-visible.yaml @@ -2,6 +2,6 @@ appId: com.example.app --- - scrollUntilVisible: element: - text: "Test" + text: 'Test' direction: DOWN timeout: 10000 diff --git a/scripts/maestro-conformance/corpus/bug-classes/percent-decimal-swipe.yaml b/scripts/maestro-conformance/corpus/bug-classes/percent-decimal-swipe.yaml index b02bd06d8b..10bbbb6e69 100644 --- a/scripts/maestro-conformance/corpus/bug-classes/percent-decimal-swipe.yaml +++ b/scripts/maestro-conformance/corpus/bug-classes/percent-decimal-swipe.yaml @@ -3,5 +3,5 @@ appId: com.example.app --- - swipe: - start: "50.5%, 50%" - end: "10%, 50%" + start: '50.5%, 50%' + end: '10%, 50%' diff --git a/scripts/maestro-conformance/corpus/bug-classes/retry-over-cap.yaml b/scripts/maestro-conformance/corpus/bug-classes/retry-over-cap.yaml index 1fe6fa092d..d15a686610 100644 --- a/scripts/maestro-conformance/corpus/bug-classes/retry-over-cap.yaml +++ b/scripts/maestro-conformance/corpus/bug-classes/retry-over-cap.yaml @@ -6,4 +6,4 @@ appId: com.example.app - retry: maxRetries: 99 commands: - - tapOn: "Retry" + - tapOn: 'Retry' diff --git a/scripts/maestro-conformance/corpus/bug-classes/settle-after-tap.yaml b/scripts/maestro-conformance/corpus/bug-classes/settle-after-tap.yaml index 05650fa666..2bbc1e15a8 100644 --- a/scripts/maestro-conformance/corpus/bug-classes/settle-after-tap.yaml +++ b/scripts/maestro-conformance/corpus/bug-classes/settle-after-tap.yaml @@ -4,4 +4,4 @@ # same name (no reflectable upstream constant exists). appId: com.example.app --- -- tapOn: "Submit" +- tapOn: 'Submit' diff --git a/scripts/maestro-conformance/corpus/bug-classes/target-swipe-missing-direction.yaml b/scripts/maestro-conformance/corpus/bug-classes/target-swipe-missing-direction.yaml index a7398af0bb..b19fccc0f4 100644 --- a/scripts/maestro-conformance/corpus/bug-classes/target-swipe-missing-direction.yaml +++ b/scripts/maestro-conformance/corpus/bug-classes/target-swipe-missing-direction.yaml @@ -4,4 +4,4 @@ appId: com.example.app --- - swipe: from: - id: "row" + id: 'row' diff --git a/scripts/maestro-conformance/corpus/invalid/commands-not-a-list.yaml b/scripts/maestro-conformance/corpus/invalid/commands-not-a-list.yaml index ae3b78c13c..709775b991 100644 --- a/scripts/maestro-conformance/corpus/invalid/commands-not-a-list.yaml +++ b/scripts/maestro-conformance/corpus/invalid/commands-not-a-list.yaml @@ -1,4 +1,4 @@ # The command document must be a sequence. appId: com.example.app --- -tapOn: "Button" +tapOn: 'Button' diff --git a/scripts/maestro-conformance/corpus/invalid/malformed-selector.yaml b/scripts/maestro-conformance/corpus/invalid/malformed-selector.yaml index ecf2d9aa21..aeac7d86fa 100644 --- a/scripts/maestro-conformance/corpus/invalid/malformed-selector.yaml +++ b/scripts/maestro-conformance/corpus/invalid/malformed-selector.yaml @@ -2,4 +2,4 @@ appId: com.example.app --- - tapOn: - - text: "Button" + - text: 'Button' diff --git a/scripts/maestro-conformance/corpus/invalid/unknown-command.yaml b/scripts/maestro-conformance/corpus/invalid/unknown-command.yaml index 2b6b7c1a13..9affff9131 100644 --- a/scripts/maestro-conformance/corpus/invalid/unknown-command.yaml +++ b/scripts/maestro-conformance/corpus/invalid/unknown-command.yaml @@ -1,4 +1,4 @@ # Upstream rejects an unknown command name (typo of tapOn). appId: com.example.app --- -- tapOnn: "Button" +- tapOnn: 'Button' diff --git a/scripts/maestro-conformance/corpus/invalid/unknown-selector-field.yaml b/scripts/maestro-conformance/corpus/invalid/unknown-selector-field.yaml index e81afabd6f..365a16ed29 100644 --- a/scripts/maestro-conformance/corpus/invalid/unknown-selector-field.yaml +++ b/scripts/maestro-conformance/corpus/invalid/unknown-selector-field.yaml @@ -2,5 +2,5 @@ appId: com.example.app --- - tapOn: - text: "Button" + text: 'Button' bogusField: true diff --git a/scripts/maestro-conformance/corpus/upstream/001_assert_visible_by_id.yaml b/scripts/maestro-conformance/corpus/upstream/001_assert_visible_by_id.yaml index 5552e4dbaf..bfc66a8acf 100644 --- a/scripts/maestro-conformance/corpus/upstream/001_assert_visible_by_id.yaml +++ b/scripts/maestro-conformance/corpus/upstream/001_assert_visible_by_id.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - assertVisible: - id: "element_id" \ No newline at end of file + id: 'element_id' diff --git a/scripts/maestro-conformance/corpus/upstream/002_assert_visible_by_text.yaml b/scripts/maestro-conformance/corpus/upstream/002_assert_visible_by_text.yaml index 4d05bf0588..d7cf9a2b1b 100644 --- a/scripts/maestro-conformance/corpus/upstream/002_assert_visible_by_text.yaml +++ b/scripts/maestro-conformance/corpus/upstream/002_assert_visible_by_text.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - assertVisible: - text: "Element Text" \ No newline at end of file + text: 'Element Text' diff --git a/scripts/maestro-conformance/corpus/upstream/008_tap_on_element.yaml b/scripts/maestro-conformance/corpus/upstream/008_tap_on_element.yaml index 2590342340..33aa62537a 100644 --- a/scripts/maestro-conformance/corpus/upstream/008_tap_on_element.yaml +++ b/scripts/maestro-conformance/corpus/upstream/008_tap_on_element.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - tapOn: - text: ".*button.*" \ No newline at end of file + text: '.*button.*' diff --git a/scripts/maestro-conformance/corpus/upstream/009_skip_optional_elements.yaml b/scripts/maestro-conformance/corpus/upstream/009_skip_optional_elements.yaml index 66ef9c30d7..9d01c737cd 100644 --- a/scripts/maestro-conformance/corpus/upstream/009_skip_optional_elements.yaml +++ b/scripts/maestro-conformance/corpus/upstream/009_skip_optional_elements.yaml @@ -1,8 +1,8 @@ appId: com.example.app --- - tapOn: - text: "Optional Element" + text: 'Optional Element' optional: true - assertVisible: - text: "Non Optional" - optional: false \ No newline at end of file + text: 'Non Optional' + optional: false diff --git a/scripts/maestro-conformance/corpus/upstream/010_scroll.yaml b/scripts/maestro-conformance/corpus/upstream/010_scroll.yaml index bd91ecc5c6..92f0b14cc0 100644 --- a/scripts/maestro-conformance/corpus/upstream/010_scroll.yaml +++ b/scripts/maestro-conformance/corpus/upstream/010_scroll.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- scroll \ No newline at end of file +- scroll diff --git a/scripts/maestro-conformance/corpus/upstream/011_back_press.yaml b/scripts/maestro-conformance/corpus/upstream/011_back_press.yaml index cd7d0c53d2..26ee785a0d 100644 --- a/scripts/maestro-conformance/corpus/upstream/011_back_press.yaml +++ b/scripts/maestro-conformance/corpus/upstream/011_back_press.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- back \ No newline at end of file +- back diff --git a/scripts/maestro-conformance/corpus/upstream/012_input_text.yaml b/scripts/maestro-conformance/corpus/upstream/012_input_text.yaml index 2ba2ee5e55..0c44ea92d8 100644 --- a/scripts/maestro-conformance/corpus/upstream/012_input_text.yaml +++ b/scripts/maestro-conformance/corpus/upstream/012_input_text.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- -- inputText: "Hello World" -- inputText: user@example.com \ No newline at end of file +- inputText: 'Hello World' +- inputText: user@example.com diff --git a/scripts/maestro-conformance/corpus/upstream/013_launch_app.yaml b/scripts/maestro-conformance/corpus/upstream/013_launch_app.yaml index e98c0c42bf..4a888fc014 100644 --- a/scripts/maestro-conformance/corpus/upstream/013_launch_app.yaml +++ b/scripts/maestro-conformance/corpus/upstream/013_launch_app.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- launchApp \ No newline at end of file +- launchApp diff --git a/scripts/maestro-conformance/corpus/upstream/014_tap_on_point.yaml b/scripts/maestro-conformance/corpus/upstream/014_tap_on_point.yaml index 061a370abd..c75fb92de1 100644 --- a/scripts/maestro-conformance/corpus/upstream/014_tap_on_point.yaml +++ b/scripts/maestro-conformance/corpus/upstream/014_tap_on_point.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - tapOn: - point: 100,200 \ No newline at end of file + point: 100,200 diff --git a/scripts/maestro-conformance/corpus/upstream/017_swipe.yaml b/scripts/maestro-conformance/corpus/upstream/017_swipe.yaml index c358ecb692..dc1063320a 100644 --- a/scripts/maestro-conformance/corpus/upstream/017_swipe.yaml +++ b/scripts/maestro-conformance/corpus/upstream/017_swipe.yaml @@ -3,4 +3,4 @@ appId: com.example.app - swipe: start: 100,500 end: 100,200 - duration: 3000 \ No newline at end of file + duration: 3000 diff --git a/scripts/maestro-conformance/corpus/upstream/021_launch_app_with_clear_state.yaml b/scripts/maestro-conformance/corpus/upstream/021_launch_app_with_clear_state.yaml index 46ce97e75d..f849b74aa1 100644 --- a/scripts/maestro-conformance/corpus/upstream/021_launch_app_with_clear_state.yaml +++ b/scripts/maestro-conformance/corpus/upstream/021_launch_app_with_clear_state.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - launchApp: - clearState: true \ No newline at end of file + clearState: true diff --git a/scripts/maestro-conformance/corpus/upstream/026_assert_not_visible.yaml b/scripts/maestro-conformance/corpus/upstream/026_assert_not_visible.yaml index 42cc239707..ecff4d90be 100644 --- a/scripts/maestro-conformance/corpus/upstream/026_assert_not_visible.yaml +++ b/scripts/maestro-conformance/corpus/upstream/026_assert_not_visible.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - assertNotVisible: - id: "element_id" \ No newline at end of file + id: 'element_id' diff --git a/scripts/maestro-conformance/corpus/upstream/027_open_link.yaml b/scripts/maestro-conformance/corpus/upstream/027_open_link.yaml index b5c8d1b6b8..28b84ef717 100644 --- a/scripts/maestro-conformance/corpus/upstream/027_open_link.yaml +++ b/scripts/maestro-conformance/corpus/upstream/027_open_link.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- openLink: https://example.com \ No newline at end of file +- openLink: https://example.com diff --git a/scripts/maestro-conformance/corpus/upstream/029_long_press_on_element.yaml b/scripts/maestro-conformance/corpus/upstream/029_long_press_on_element.yaml index 440f73d7f9..54de0fa453 100644 --- a/scripts/maestro-conformance/corpus/upstream/029_long_press_on_element.yaml +++ b/scripts/maestro-conformance/corpus/upstream/029_long_press_on_element.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - longPressOn: - text: ".*button.*" \ No newline at end of file + text: '.*button.*' diff --git a/scripts/maestro-conformance/corpus/upstream/032_element_index.yaml b/scripts/maestro-conformance/corpus/upstream/032_element_index.yaml index 7e7b2abf32..ac0543bbff 100644 --- a/scripts/maestro-conformance/corpus/upstream/032_element_index.yaml +++ b/scripts/maestro-conformance/corpus/upstream/032_element_index.yaml @@ -7,4 +7,4 @@ appId: com.example.app - tapOn: text: Item.* index: ${0 + 1} - retryTapIfNoChange: false \ No newline at end of file + retryTapIfNoChange: false diff --git a/scripts/maestro-conformance/corpus/upstream/034_press_key.yaml b/scripts/maestro-conformance/corpus/upstream/034_press_key.yaml index 8d72550e76..b2f50f7e01 100644 --- a/scripts/maestro-conformance/corpus/upstream/034_press_key.yaml +++ b/scripts/maestro-conformance/corpus/upstream/034_press_key.yaml @@ -29,4 +29,3 @@ appId: com.example.app - pressKey: TV Input HDMI 1 - pressKey: TV Input HDMI 2 - pressKey: TV Input HDMI 3 - diff --git a/scripts/maestro-conformance/corpus/upstream/039_hide_keyboard.yaml b/scripts/maestro-conformance/corpus/upstream/039_hide_keyboard.yaml index 93ab4f21ed..b8da72f9f3 100644 --- a/scripts/maestro-conformance/corpus/upstream/039_hide_keyboard.yaml +++ b/scripts/maestro-conformance/corpus/upstream/039_hide_keyboard.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- hideKeyboard \ No newline at end of file +- hideKeyboard diff --git a/scripts/maestro-conformance/corpus/upstream/042_extended_wait.yaml b/scripts/maestro-conformance/corpus/upstream/042_extended_wait.yaml index dd58226fa4..debd135452 100644 --- a/scripts/maestro-conformance/corpus/upstream/042_extended_wait.yaml +++ b/scripts/maestro-conformance/corpus/upstream/042_extended_wait.yaml @@ -1,6 +1,6 @@ appId: com.example.app env: - TIMEOUT: 1000 + TIMEOUT: 1000 --- - extendedWaitUntil: visible: Item diff --git a/scripts/maestro-conformance/corpus/upstream/053_repeat_times.yaml b/scripts/maestro-conformance/corpus/upstream/053_repeat_times.yaml index e2859cb86a..872b772d7e 100644 --- a/scripts/maestro-conformance/corpus/upstream/053_repeat_times.yaml +++ b/scripts/maestro-conformance/corpus/upstream/053_repeat_times.yaml @@ -4,10 +4,10 @@ appId: com.other.app times: 3 commands: - tapOn: Button -- assertVisible: "3" +- assertVisible: '3' - evalScript: ${output.list = [1, 2, 3]} - repeat: times: ${output.list.length} commands: - tapOn: Button -- assertVisible: "6" \ No newline at end of file +- assertVisible: '6' diff --git a/scripts/maestro-conformance/corpus/upstream/059_directional_swipe_command.yaml b/scripts/maestro-conformance/corpus/upstream/059_directional_swipe_command.yaml index c548e5468b..0766cd4749 100644 --- a/scripts/maestro-conformance/corpus/upstream/059_directional_swipe_command.yaml +++ b/scripts/maestro-conformance/corpus/upstream/059_directional_swipe_command.yaml @@ -2,4 +2,4 @@ appId: com.example.app --- - swipe: direction: RIGHT - duration: 500 \ No newline at end of file + duration: 500 diff --git a/scripts/maestro-conformance/corpus/upstream/061_launchApp_withoutStopping.yaml b/scripts/maestro-conformance/corpus/upstream/061_launchApp_withoutStopping.yaml index 11e1a83481..0b1fd853ac 100644 --- a/scripts/maestro-conformance/corpus/upstream/061_launchApp_withoutStopping.yaml +++ b/scripts/maestro-conformance/corpus/upstream/061_launchApp_withoutStopping.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - launchApp: - stopApp: false \ No newline at end of file + stopApp: false diff --git a/scripts/maestro-conformance/corpus/upstream/062_copy_paste_text.yaml b/scripts/maestro-conformance/corpus/upstream/062_copy_paste_text.yaml index 4c05431f73..c13139b734 100644 --- a/scripts/maestro-conformance/corpus/upstream/062_copy_paste_text.yaml +++ b/scripts/maestro-conformance/corpus/upstream/062_copy_paste_text.yaml @@ -1,5 +1,5 @@ appId: com.example.app --- - copyTextFrom: - id: "myId" + id: 'myId' - pasteText diff --git a/scripts/maestro-conformance/corpus/upstream/067_assertTrue_pass.yaml b/scripts/maestro-conformance/corpus/upstream/067_assertTrue_pass.yaml index abad0c5099..ee6d31a413 100644 --- a/scripts/maestro-conformance/corpus/upstream/067_assertTrue_pass.yaml +++ b/scripts/maestro-conformance/corpus/upstream/067_assertTrue_pass.yaml @@ -1,3 +1,3 @@ appId: com.example.app --- -- assertTrue: ${1+1} \ No newline at end of file +- assertTrue: ${1+1} diff --git a/scripts/maestro-conformance/corpus/upstream/069_wait_for_animation_to_end.yaml b/scripts/maestro-conformance/corpus/upstream/069_wait_for_animation_to_end.yaml index e8832c37ad..ec51736203 100644 --- a/scripts/maestro-conformance/corpus/upstream/069_wait_for_animation_to_end.yaml +++ b/scripts/maestro-conformance/corpus/upstream/069_wait_for_animation_to_end.yaml @@ -1,4 +1,4 @@ appId: com.example.app --- - waitForAnimationToEnd: - timeout: 500 \ No newline at end of file + timeout: 500 diff --git a/scripts/maestro-conformance/corpus/upstream/074_directional_swipe_element.yaml b/scripts/maestro-conformance/corpus/upstream/074_directional_swipe_element.yaml index 604241ce93..0756e9c43e 100644 --- a/scripts/maestro-conformance/corpus/upstream/074_directional_swipe_element.yaml +++ b/scripts/maestro-conformance/corpus/upstream/074_directional_swipe_element.yaml @@ -3,4 +3,4 @@ appId: com.example.app - swipe: direction: RIGHT from: - text: "swiping element" + text: 'swiping element' diff --git a/scripts/maestro-conformance/corpus/upstream/076_optional_assertion.yaml b/scripts/maestro-conformance/corpus/upstream/076_optional_assertion.yaml index 50329a9223..e6130d6791 100644 --- a/scripts/maestro-conformance/corpus/upstream/076_optional_assertion.yaml +++ b/scripts/maestro-conformance/corpus/upstream/076_optional_assertion.yaml @@ -3,16 +3,16 @@ appId: com.example.app - scrollUntilVisible: timeout: 1 element: - id: "not_found" + id: 'not_found' optional: true - assertTrue: - condition: "false" + condition: 'false' optional: true - extendedWaitUntil: visible: - id: "not_found" + id: 'not_found' timeout: 1 optional: true - assertVisible: - text: "Button" + text: 'Button' optional: true diff --git a/scripts/maestro-conformance/corpus/upstream/078_swipe_relative.yaml b/scripts/maestro-conformance/corpus/upstream/078_swipe_relative.yaml index 18d45d3f8c..8733876cb6 100644 --- a/scripts/maestro-conformance/corpus/upstream/078_swipe_relative.yaml +++ b/scripts/maestro-conformance/corpus/upstream/078_swipe_relative.yaml @@ -1,6 +1,6 @@ appId: com.example.app --- - swipe: - start: "50%,30%" - end: "50%,60%" - duration: 3000 \ No newline at end of file + start: '50%,30%' + end: '50%,60%' + duration: 3000 diff --git a/scripts/maestro-conformance/corpus/upstream/079_scroll_until_visible.yaml b/scripts/maestro-conformance/corpus/upstream/079_scroll_until_visible.yaml index 539b380e39..5042b30547 100644 --- a/scripts/maestro-conformance/corpus/upstream/079_scroll_until_visible.yaml +++ b/scripts/maestro-conformance/corpus/upstream/079_scroll_until_visible.yaml @@ -2,8 +2,8 @@ appId: com.example.app --- - scrollUntilVisible: element: - text: "Test" + text: 'Test' speed: 100 visibilityPercentage: 100 direction: DOWN - timeout: 10 \ No newline at end of file + timeout: 10 diff --git a/scripts/maestro-conformance/corpus/upstream/114_child_of_selector.yaml b/scripts/maestro-conformance/corpus/upstream/114_child_of_selector.yaml index 3ab7d1906c..049d22bb44 100644 --- a/scripts/maestro-conformance/corpus/upstream/114_child_of_selector.yaml +++ b/scripts/maestro-conformance/corpus/upstream/114_child_of_selector.yaml @@ -1,10 +1,10 @@ appId: com.example.app --- - assertVisible: - text: "child_id" + text: 'child_id' childOf: - text: "parent_id_1" + text: 'parent_id_1' - assertNotVisible: - text: "child_id" + text: 'child_id' childOf: - text: "parent_id_3" \ No newline at end of file + text: 'parent_id_3' diff --git a/scripts/maestro-conformance/corpus/upstream/120_tap_on_element_retryTapIfNoChange.yaml b/scripts/maestro-conformance/corpus/upstream/120_tap_on_element_retryTapIfNoChange.yaml index b6b57ebec5..a6b6c0ccc7 100644 --- a/scripts/maestro-conformance/corpus/upstream/120_tap_on_element_retryTapIfNoChange.yaml +++ b/scripts/maestro-conformance/corpus/upstream/120_tap_on_element_retryTapIfNoChange.yaml @@ -1,5 +1,5 @@ appId: com.example.app --- - tapOn: - text: ".*button.*" - retryTapIfNoChange: true \ No newline at end of file + text: '.*button.*' + retryTapIfNoChange: true diff --git a/scripts/maestro-conformance/corpus/upstream/131_setPermissions.yaml b/scripts/maestro-conformance/corpus/upstream/131_setPermissions.yaml index 483fc1a5c9..2ac17d6752 100644 --- a/scripts/maestro-conformance/corpus/upstream/131_setPermissions.yaml +++ b/scripts/maestro-conformance/corpus/upstream/131_setPermissions.yaml @@ -3,4 +3,4 @@ appId: com.example.app - setPermissions: permissions: all: deny - notifications: unset \ No newline at end of file + notifications: unset diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs index 8f7ccbe40a..27c39e01c9 100644 --- a/scripts/size-report.mjs +++ b/scripts/size-report.mjs @@ -479,6 +479,10 @@ async function attemptGitHubRequest(url, init, action) { } catch (error) { return { failure: `Failed to ${action}: ${error?.message ?? error}` }; } + return await classifyGitHubResponse(response, action); +} + +async function classifyGitHubResponse(response, action) { if (response.ok) return { response }; const failure = `Failed to ${action}: ${response.status} ${await response.text()}`; if (isTransientGitHubStatus(response.status)) return { failure }; From 303c47e609cd6dab11f6f9ea9af3187adaa926cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 07:47:34 +0200 Subject: [PATCH 3/3] fix: reconcile uncertain comment creates instead of re-POSTing; add regressions Retry now wraps the whole list -> write cycle rather than each request, so a create whose response was lost (network error / 5xx) is re-listed on the next attempt and turned into a PATCH of the marker comment instead of a duplicate POST. Splits the retry/classify helpers under the fallow complexity gate. Adds scripts/__tests__/size-report-post-comment.test.ts (unit-core): spawns the real script against a stubbed fetch and pins uncertain-create reconciliation, transient exhaustion (warn + exit 0), and fatal 4xx (nonzero, no retry). SIZE_REPORT_RETRY_BASE_MS lets the tests skip real backoff. --- .../size-report-post-comment.test.ts | 114 ++++++++++++++++++ scripts/size-report.mjs | 72 ++++++----- vitest.config.ts | 3 + 3 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 scripts/__tests__/size-report-post-comment.test.ts diff --git a/scripts/__tests__/size-report-post-comment.test.ts b/scripts/__tests__/size-report-post-comment.test.ts new file mode 100644 index 0000000000..d5781d817d --- /dev/null +++ b/scripts/__tests__/size-report-post-comment.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { test } from 'vitest'; + +const execFileAsync = promisify(execFile); +const ROOT = join(import.meta.dirname, '..', '..'); +const SCRIPT = join(ROOT, 'scripts', 'size-report.mjs'); +const MARKER = ''; + +// Each entry answers one fetch call, in order. `net` rejects the fetch (a +// dropped connection); a number is the HTTP status; `body` is the response +// text (defaults to `[]` for 200 so a list call sees no comments). +type StubResponse = { status: number | 'net'; body?: string }; + +const FETCH_STUB = ` +import fs from 'node:fs'; +const script = JSON.parse(process.env.SIZE_REPORT_FETCH_SCRIPT); +let index = 0; +globalThis.fetch = async (url, init) => { + const step = script[Math.min(index, script.length - 1)]; + index += 1; + fs.appendFileSync(process.env.SIZE_REPORT_FETCH_LOG, \`\${init?.method ?? 'GET'} \${url}\\n\`); + if (step.status === 'net') throw new Error('ECONNRESET'); + const body = step.body ?? (step.status === 200 ? '[]' : \`{"message":"status \${step.status}"}\`); + return new Response(body, { status: step.status }); +}; +`; + +async function runPostComment(script: StubResponse[]) { + const dir = await mkdtemp(join(tmpdir(), 'size-report-post-comment-')); + const stubPath = join(dir, 'fetch-stub.mjs'); + const reportPath = join(dir, 'report.md'); + const logPath = join(dir, 'fetch.log'); + const summaryPath = join(dir, 'summary.md'); + await Promise.all([ + writeFile(stubPath, FETCH_STUB), + writeFile(reportPath, `${MARKER}\n# report\n`), + writeFile(logPath, ''), + writeFile(summaryPath, ''), + ]); + const env = { + ...process.env, + GITHUB_TOKEN: 'token', + GITHUB_REPOSITORY: 'owner/repo', + GITHUB_PR_NUMBER: '1', + GITHUB_STEP_SUMMARY: summaryPath, + SIZE_REPORT_RETRY_BASE_MS: '1', + SIZE_REPORT_FETCH_SCRIPT: JSON.stringify(script), + SIZE_REPORT_FETCH_LOG: logPath, + }; + let exitCode = 0; + let stdout = ''; + let stderr = ''; + try { + ({ stdout, stderr } = await execFileAsync( + process.execPath, + ['--import', stubPath, SCRIPT, '--post-comment', reportPath], + { env }, + )); + } catch (error) { + const failure = error as { code?: number; stdout?: string; stderr?: string }; + exitCode = failure.code ?? 1; + stdout = failure.stdout ?? ''; + stderr = failure.stderr ?? ''; + } + const calls = (await readFile(logPath, 'utf8')).trim().split('\n').filter(Boolean); + const summary = await readFile(summaryPath, 'utf8'); + return { exitCode, stdout, stderr, calls, summary }; +} + +const COMMENTS_URL = 'https://api.github.com/repos/owner/repo/issues/1/comments'; +const EXISTING_COMMENT_URL = `${COMMENTS_URL}/42`; +const existingMarkerComment = JSON.stringify([ + { url: EXISTING_COMMENT_URL, body: `${MARKER}\nold` }, +]); + +test('a create whose response was lost is reconciled into an update, not a duplicate', async () => { + const result = await runPostComment([ + { status: 200 }, // list: nothing yet + { status: 'net' }, // create: connection dropped, but it landed server-side + { status: 200, body: existingMarkerComment }, // re-list: marker comment now exists + { status: 200 }, // update + ]); + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(result.calls, [ + `GET ${COMMENTS_URL}?per_page=100`, + `POST ${COMMENTS_URL}`, + `GET ${COMMENTS_URL}?per_page=100`, + `PATCH ${EXISTING_COMMENT_URL}`, + ]); +}); + +test('transient failures that outlast the retries warn and exit 0', async () => { + const result = await runPostComment([{ status: 503 }]); + assert.equal(result.exitCode, 0, result.stderr); + assert.match( + result.stdout, + /^::warning::Skipping PR size comment after transient GitHub failure: .*503/m, + ); + assert.match(result.summary, /Skipping PR size comment/); + assert.equal(result.calls.length, 4, 'one list call per attempt'); +}); + +test('a non-transient 4xx is fatal and is not retried', async () => { + const result = await runPostComment([{ status: 200 }, { status: 401 }]); + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /Failed to create PR comment: 401/); + assert.doesNotMatch(result.stdout, /::warning::/); + assert.equal(result.calls.length, 2, 'no retry after a fatal status'); +}); diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs index 27c39e01c9..08e15366af 100644 --- a/scripts/size-report.mjs +++ b/scripts/size-report.mjs @@ -7,7 +7,8 @@ import { gzipSync } from 'node:zlib'; const COMMENT_MARKER = ''; const GITHUB_REQUEST_ATTEMPTS = 4; -const GITHUB_RETRY_BASE_MS = 1000; +// Overridable so the regression tests do not sleep through real backoff. +const GITHUB_RETRY_BASE_MS = Number(process.env.SIZE_REPORT_RETRY_BASE_MS ?? 1000); class TransientGitHubError extends Error {} const VALUE_ARGS = new Map([ ['--cwd', 'cwd'], @@ -387,9 +388,16 @@ async function postGitHubComment(markdownPath, explicitPrNumber) { const config = readGitHubCommentConfig(explicitPrNumber); const body = fs.readFileSync(markdownPath, 'utf8'); const commentsUrl = buildCommentsUrl(config.repository, config.prNumber); - const comments = await listGitHubComments(commentsUrl, config.headers); + await retryTransient(() => syncGitHubComment(commentsUrl, config.headers, body)); +} + +// Every attempt re-lists before writing: a create whose response was lost +// (network error / 5xx) may still have landed server-side, and re-listing turns +// that into an update of the existing marker comment instead of a duplicate. +async function syncGitHubComment(commentsUrl, headers, body) { + const comments = await listGitHubComments(commentsUrl, headers); const existing = comments.find((comment) => comment.body?.includes(COMMENT_MARKER)); - await writeGitHubComment(commentsUrl, config.headers, body, existing?.url); + await writeGitHubComment(commentsUrl, headers, body, existing?.url); } function readGitHubCommentConfig(explicitPrNumber) { @@ -453,40 +461,50 @@ function commentWriteTarget(commentsUrl, existingUrl) { return { url: commentsUrl, method: 'POST', action: 'create' }; } -// Retries 5xx / 429 / network errors with exponential backoff; any other -// non-OK status is a configuration problem and throws a plain (fatal) Error. -async function githubRequest(url, init, action) { - let failure = ''; - for (let attempt = 1; attempt <= GITHUB_REQUEST_ATTEMPTS; attempt += 1) { - const result = await attemptGitHubRequest(url, init, action); - if (result.response) return result.response; - failure = result.failure; - if (attempt < GITHUB_REQUEST_ATTEMPTS) { - const delayMs = GITHUB_RETRY_BASE_MS * 2 ** (attempt - 1); - process.stderr.write(`${failure} (retrying in ${delayMs}ms)\n`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); +// Re-runs `operation` with exponential backoff while it throws +// TransientGitHubError; any other error (a non-transient HTTP status, i.e. a +// configuration problem) propagates immediately and fails the job. +async function retryTransient(operation) { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + await backoffOrRethrow(error, attempt); } } - throw new TransientGitHubError(`${failure} after ${GITHUB_REQUEST_ATTEMPTS} attempts`); } -// Resolves to { response } on success or { failure } on a transient failure; -// throws a plain Error on a non-transient one. -async function attemptGitHubRequest(url, init, action) { - let response; +async function backoffOrRethrow(error, attempt) { + if (!(error instanceof TransientGitHubError)) throw error; + if (attempt >= GITHUB_REQUEST_ATTEMPTS) { + throw new TransientGitHubError(`${error.message} after ${attempt} attempts`); + } + const delayMs = GITHUB_RETRY_BASE_MS * 2 ** (attempt - 1); + process.stderr.write(`${error.message} (retrying in ${delayMs}ms)\n`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +// One attempt: network errors and 5xx / 429 throw TransientGitHubError; +// any other non-OK status throws a plain (fatal) Error. +async function githubRequest(url, init, action) { + const response = await fetchOrTransient(url, init, action); + if (response.ok) return response; + throw await githubStatusError(response, action); +} + +async function fetchOrTransient(url, init, action) { try { - response = await fetch(url, init); + return await fetch(url, init); } catch (error) { - return { failure: `Failed to ${action}: ${error?.message ?? error}` }; + throw new TransientGitHubError(`Failed to ${action}: ${error?.message ?? error}`); } - return await classifyGitHubResponse(response, action); } -async function classifyGitHubResponse(response, action) { - if (response.ok) return { response }; +async function githubStatusError(response, action) { const failure = `Failed to ${action}: ${response.status} ${await response.text()}`; - if (isTransientGitHubStatus(response.status)) return { failure }; - throw new Error(failure); + return isTransientGitHubStatus(response.status) + ? new TransientGitHubError(failure) + : new Error(failure); } function isTransientGitHubStatus(status) { diff --git a/vitest.config.ts b/vitest.config.ts index 4c922b100f..b1bb2649b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -84,6 +84,9 @@ export default defineConfig({ // only place the gate's failure direction is exercised at all (the gate itself needs a // real `npm pack`, so CI can only watch a healthy package pass). 'scripts/__tests__/package-closure-audit.test.ts', + // The Bundle Size lane's PR-comment path: spawns the real script against a + // stubbed fetch, so it needs no network; pins retry/reconcile/fatal outcomes. + 'scripts/__tests__/size-report-post-comment.test.ts', // Parses CI configuration only, so this action guard needs no device or subprocess lane. 'test/ci/upload-agent-device-artifacts.test.ts', // The frozen replay-compat corpus (#1417): parse-only, no device or