Skip to content

Commit 88da2c0

Browse files
cmd-obmetamaskbot
andauthored
feat: add script to update default E2E fixture from generated report (#26861)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR improves the reliability and security of the automated E2E fixture update workflow by fixing race conditions, permission issues, and fork-handling logic. Key changes: update-e2e-fixtures.yml - Fix: prevent stale fixture caching on cancelled jobs — adds if: ${{ !cancelled() }} guard to the cache step so partial/failed runs don't overwrite valid cached fixtures - Fix: commit-fixtures job now handles upstream cancellations — adds !cancelled() to the job condition so it can still commit even when update-fixtures was cancelled - Fix: replace ACTIONS_WRITE_TOKEN with GITHUB_TOKEN — uses the built-in token with explicit contents: write and pull-requests: write permissions instead of a PAT, removing the dependency on the org secret - Add explicit permissions to commit-fixtures and check-status jobs ci.yml - Fix: fork exclusion on all E2E jobs — moves !github.event.pull_request.head.repo.fork to the top of each condition so forks are excluded before any build/change checks - Fix: ai_confidence string-to-number comparison — wraps comparison in fromJSON(... || '0') to prevent string comparison bugs - Feat: force_run flag — propagates force_run output from smart-e2e-selection so E2E jobs can be manually forced regardless of file-change detection - Adds smart-e2e-selection as a needs dependency where missing (ios-tests-ready, validate-e2e-fixtures) scripts/update-e2e-fixture.sh - Adds a helper script to update the default fixture from a generated E2E report Default fixture (tests/framework/fixtures/json/default-fixture.json) - Updated fixture state to reflect current expected app state ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes CI gating/conditions for when E2E builds and fixture workflows run, and adjusts GitHub Action permissions/tokens for committing/commenting, which could impact test coverage or workflow execution if misconfigured. > > **Overview** > **Adds a manual override to always run E2E.** The `smart-e2e-selection` composite action now outputs `force_run` when the `skip-smart-e2e-selection` label is present, and `ci.yml` uses this flag to trigger Android/iOS E2E builds/tests (and fixture validation) even when path filters say nothing changed; confidence parsing was also hardened via `fromJSON(... || '0')`. > > **Tightens and streamlines E2E fixture updates/validation.** The fixture-validation Detox spec now throws (failing CI) when the committed fixture is out of date and includes clearer remediation instructions; ignored keys were expanded for several runtime-added network configs, and a new unit test asserts unexpected new keys are detected. The `Update E2E Fixtures` workflow now avoids cache/commit steps when cancelled, sets explicit `contents`/`pull-requests` permissions, and uses `GITHUB_TOKEN` for PR checkout/comments; a new `scripts/update-e2e-fixture.sh` helps copy the generated fixture report into the committed fixture locally. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit afc3f5f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
1 parent 29ba999 commit 88da2c0

7 files changed

Lines changed: 101 additions & 27 deletions

File tree

.github/actions/smart-e2e-selection/action.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ outputs:
4444
ai_performance_test_tags:
4545
description: 'Performance test tags to run (JSON array format, empty [] means no performance tests)'
4646
value: ${{ steps.final-outputs.outputs.ai_performance_test_tags }}
47+
force_run:
48+
description: 'Whether to force E2E builds/tests regardless of path filter (true when skip-smart-e2e-selection label is used)'
49+
value: ${{ steps.final-outputs.outputs.force_run }}
4750

4851
runs:
4952
using: 'composite'
@@ -131,6 +134,7 @@ runs:
131134
echo "ai_performance_test_tags=[]" >> "$GITHUB_OUTPUT"
132135
SHOULD_SKIP=false
133136
SKIP_REASON=""
137+
FORCE_RUN=false
134138
135139
if [[ "$EVENT_NAME" != "pull_request" ]]; then
136140
SHOULD_SKIP=true
@@ -141,11 +145,14 @@ runs:
141145
elif [[ -n "${{ steps.check-skip-label.outputs.SKIP }}" ]] && [[ "${{ steps.check-skip-label.outputs.SKIP }}" == "true" ]]; then
142146
SHOULD_SKIP=true
143147
SKIP_REASON="skip-smart-e2e-selection label found"
148+
FORCE_RUN=true
149+
echo "ai_confidence=100" >> "$GITHUB_OUTPUT"
144150
fi
145151
146-
# Export skip status and reason for the comment step
152+
# Export skip status, reason, and force run flag for downstream jobs
147153
echo "SKIPPED=$SHOULD_SKIP" >> "$GITHUB_OUTPUT"
148154
echo "SKIP_REASON=$SKIP_REASON" >> "$GITHUB_OUTPUT"
155+
echo "FORCE_RUN=$FORCE_RUN" >> "$GITHUB_OUTPUT"
149156
150157
if [[ "$SHOULD_SKIP" == "true" ]]; then
151158
echo "⏭️ Skipping AI analysis - $SKIP_REASON"
@@ -174,6 +181,13 @@ runs:
174181
else
175182
echo 'ai_performance_test_tags=[]' >> "$GITHUB_OUTPUT"
176183
fi
184+
# Force run flag (true when skip-smart-e2e-selection label is used)
185+
FORCE_RUN='${{ steps.ai-analysis.outputs.FORCE_RUN }}'
186+
if [[ "$FORCE_RUN" == "true" ]]; then
187+
echo "force_run=true" >> "$GITHUB_OUTPUT"
188+
else
189+
echo "force_run=false" >> "$GITHUB_OUTPUT"
190+
fi
177191
178192
- name: Display AI Analysis Outputs
179193
if: always()
@@ -184,6 +198,7 @@ runs:
184198
echo "ai_e2e_test_tags: ${{ steps.final-outputs.outputs.ai_e2e_test_tags }}"
185199
echo "ai_confidence: ${{ steps.final-outputs.outputs.ai_confidence }}"
186200
echo "ai_performance_test_tags: ${{ steps.final-outputs.outputs.ai_performance_test_tags }}"
201+
echo "force_run: ${{ steps.final-outputs.outputs.force_run }}"
187202
echo "================================"
188203
189204
- name: Delete previous comments

.github/workflows/ci.yml

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ jobs:
410410
outputs:
411411
ai_e2e_test_tags: ${{ steps.e2e-selection.outputs.ai_e2e_test_tags }}
412412
ai_confidence: ${{ steps.e2e-selection.outputs.ai_confidence }}
413+
force_run: ${{ steps.e2e-selection.outputs.force_run }}
413414
steps:
414415
- name: Checkout for action definition
415416
uses: actions/checkout@v4
@@ -440,9 +441,9 @@ jobs:
440441
if: >-
441442
${{
442443
github.event_name != 'merge_group' &&
443-
needs.needs_e2e_build.outputs.android_changed == 'true' &&
444-
!(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') &&
445-
!github.event.pull_request.head.repo.fork
444+
!github.event.pull_request.head.repo.fork &&
445+
(needs.needs_e2e_build.outputs.android_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') &&
446+
!(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]')
446447
}}
447448
permissions:
448449
contents: read
@@ -457,7 +458,7 @@ jobs:
457458

458459
e2e-smoke-tests-android:
459460
name: 'Android E2E Smoke Tests'
460-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' && !github.event.pull_request.head.repo.fork }}
461+
if: ${{ github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.android_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
461462
permissions:
462463
contents: read
463464
id-token: write
@@ -467,7 +468,7 @@ jobs:
467468
changed_files: ${{ needs.needs_e2e_build.outputs.changed_files }}
468469
selected_tags: >-
469470
${{
470-
(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
471+
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
471472
'["ALL"]'
472473
}}
473474
secrets: inherit
@@ -477,9 +478,9 @@ jobs:
477478
if: >-
478479
${{
479480
github.event_name != 'merge_group' &&
480-
needs.needs_e2e_build.outputs.ios_changed == 'true' &&
481-
!(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') &&
482-
!github.event.pull_request.head.repo.fork
481+
!github.event.pull_request.head.repo.fork &&
482+
(needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') &&
483+
!(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]')
483484
}}
484485
permissions:
485486
contents: read
@@ -491,15 +492,15 @@ jobs:
491492
ios-tests-ready:
492493
name: 'iOS Tests Ready'
493494
runs-on: ubuntu-latest
494-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' }}
495-
needs: [needs_e2e_build, build-ios-apps]
495+
if: ${{ github.event_name != 'merge_group' && (needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
496+
needs: [needs_e2e_build, build-ios-apps, smart-e2e-selection]
496497
steps:
497498
- name: iOS build complete
498499
run: echo "Dummy step to better visualize the Android and iOS E2E tests in Github workflow graph"
499500

500501
e2e-smoke-tests-ios:
501502
name: 'iOS E2E Smoke Tests'
502-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }}
503+
if: ${{ github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
503504
permissions:
504505
contents: read
505506
id-token: write
@@ -509,7 +510,7 @@ jobs:
509510
changed_files: ${{ needs.needs_e2e_build.outputs.changed_files }}
510511
selected_tags: >-
511512
${{
512-
(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
513+
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
513514
'["ALL"]'
514515
}}
515516
secrets: inherit
@@ -518,7 +519,7 @@ jobs:
518519

519520
e2e-smoke-tests-android-flask:
520521
name: 'Android Flask E2E Smoke Tests'
521-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' && !github.event.pull_request.head.repo.fork }}
522+
if: ${{ github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.android_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
522523
permissions:
523524
contents: read
524525
id-token: write
@@ -528,14 +529,14 @@ jobs:
528529
changed_files: ${{ needs.needs_e2e_build.outputs.changed_files }}
529530
selected_tags: >-
530531
${{
531-
(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
532+
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
532533
'["ALL"]'
533534
}}
534535
secrets: inherit
535536

536537
e2e-smoke-tests-ios-flask:
537538
name: 'iOS Flask E2E Smoke Tests'
538-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }}
539+
if: ${{ github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
539540
permissions:
540541
contents: read
541542
id-token: write
@@ -545,7 +546,7 @@ jobs:
545546
changed_files: ${{ needs.needs_e2e_build.outputs.changed_files }}
546547
selected_tags: >-
547548
${{
548-
(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
549+
(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
549550
'["ALL"]'
550551
}}
551552
secrets: inherit
@@ -554,11 +555,11 @@ jobs:
554555
# TODO: Remove continue-on-error once fixture validation is stable
555556
validate-e2e-fixtures:
556557
name: 'Validate E2E Fixtures'
557-
if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }}
558+
if: ${{ github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork && (needs.needs_e2e_build.outputs.ios_changed == 'true' || needs.smart-e2e-selection.outputs.force_run == 'true') }}
558559
permissions:
559560
contents: read
560561
id-token: write
561-
needs: [needs_e2e_build, ios-tests-ready]
562+
needs: [needs_e2e_build, ios-tests-ready, smart-e2e-selection]
562563
uses: ./.github/workflows/run-e2e-workflow.yml
563564
with:
564565
test-suite-name: validate-e2e-fixtures

.github/workflows/update-e2e-fixtures.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ jobs:
254254
PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app
255255

256256
- name: Cache updated fixture
257+
if: ${{ !cancelled() }}
257258
uses: actions/cache/save@v4
258259
with:
259260
path: tests/framework/fixtures/json/default-fixture.json
@@ -263,21 +264,22 @@ jobs:
263264
name: Commit the updated fixtures
264265
runs-on: ubuntu-latest
265266
timeout-minutes: 10
267+
permissions:
268+
contents: write
269+
pull-requests: write
266270
needs:
267271
- prepare
268272
- is-fork-pull-request
269273
- update-fixtures
270-
if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }}
274+
if: ${{ !cancelled() && needs.is-fork-pull-request.outputs.IS_FORK == 'false' }}
271275
steps:
272276
- name: Checkout repository
273277
uses: actions/checkout@v4
274-
with:
275-
token: ${{ secrets.ACTIONS_WRITE_TOKEN }}
276278

277279
- name: Checkout pull request
278280
run: gh pr checkout "${PR_NUMBER}"
279281
env:
280-
GITHUB_TOKEN: ${{ secrets.ACTIONS_WRITE_TOKEN }}
282+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
281283
PR_NUMBER: ${{ needs.prepare.outputs.PR_NUMBER }}
282284

283285
- name: Restore updated fixture
@@ -318,7 +320,7 @@ jobs:
318320
fi
319321
env:
320322
HAS_CHANGES: ${{ steps.fixture-changes.outputs.HAS_CHANGES }}
321-
GITHUB_TOKEN: ${{ secrets.ACTIONS_WRITE_TOKEN }}
323+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
322324
PR_NUMBER: ${{ needs.prepare.outputs.PR_NUMBER }}
323325

324326
check-status:
@@ -346,6 +348,9 @@ jobs:
346348
if: ${{ !cancelled() && needs.is-fork-pull-request.outputs.IS_FORK == 'false' }}
347349
runs-on: ubuntu-latest
348350
timeout-minutes: 5
351+
permissions:
352+
contents: read
353+
pull-requests: write
349354
needs:
350355
- is-fork-pull-request
351356
- check-status

scripts/update-e2e-fixture.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
# Updates the default E2E fixture from the generated report.
3+
# Run this after executing the fixture-validation.spec.ts test locally.
4+
5+
set -e
6+
7+
REPORT_FILE="tests/reports/updated-default-fixture.json"
8+
TARGET_FILE="tests/framework/fixtures/json/default-fixture.json"
9+
10+
if [ ! -f "$REPORT_FILE" ]; then
11+
echo "Error: $REPORT_FILE not found."
12+
echo "Run the fixture validation test first:"
13+
echo " yarn detox test tests/regression/fixtures/fixture-validation.spec.ts -c <config>"
14+
exit 1
15+
fi
16+
17+
cp "$REPORT_FILE" "$TARGET_FILE"
18+
echo "Updated $TARGET_FILE from $REPORT_FILE"

tests/framework/fixtures/fixture-validation.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,5 +453,31 @@ describe('fixture-validation', () => {
453453
const diff = computeSchemaDiff(state, state);
454454
expect(hasSchemaDifferences(diff)).toBe(false);
455455
});
456+
457+
it('fails validation when candidate has new unexpected keys', () => {
458+
const fixture = readFixtureFile('default-fixture.json');
459+
const state = fixture.state as Record<string, unknown>;
460+
const engine = state.engine as Record<string, unknown>;
461+
const backgroundState = engine.backgroundState as Record<string, unknown>;
462+
463+
const candidateWithNewKey = {
464+
...state,
465+
engine: {
466+
...engine,
467+
backgroundState: {
468+
...backgroundState,
469+
SomeNewController: { foo: 'bar' },
470+
},
471+
},
472+
};
473+
474+
const diff = computeSchemaDiff(state, candidateWithNewKey);
475+
expect(hasSchemaDifferences(diff)).toBe(true);
476+
expect(
477+
diff.newKeys.some((k) =>
478+
k.startsWith('engine.backgroundState.SomeNewController'),
479+
),
480+
).toBe(true);
481+
});
456482
});
457483
});

tests/framework/fixtures/fixture-validation.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,15 @@ export function getMobileFixtureIgnoredKeys(): string[] {
315315
'card.geoLocation',
316316
'fiatOrders.detectedGeolocation',
317317

318+
// ── Networks present in app defaults but not in fixture (added by controller at runtime) ──
319+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0x2105', // Base
320+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0xa4b1', // Arbitrum
321+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0xa', // Optimism
322+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0x89', // Polygon
323+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0x38', // BNB Chain
324+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0x279f', // Monad Testnet
325+
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.0x18c7', // MegaETH Testnet
326+
318327
// ── Dynamic network client IDs, port-dependent URLs, and display names ──
319328
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.*.name',
320329
'engine.backgroundState.NetworkController.networkConfigurationsByChainId.*.nativeCurrency',

tests/regression/fixtures/fixture-validation.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,16 +163,16 @@ describe(FixtureValidation('Fixture Validation — Post-Onboarding'), () => {
163163
'utf-8',
164164
);
165165

166-
// TODO: Change console.warn to throw once fixture validation is stable
167-
console.warn(
166+
throw new Error(
168167
`Committed fixture is out of date.\n` +
169168
` New keys: ${diff.newKeys.length}\n` +
170169
` Missing keys: ${diff.missingKeys.length}\n` +
171170
` Type mismatches: ${diff.typeMismatches.length}\n` +
172171
` Auto-updated values: ${autoUpdateMismatches.length}\n\n` +
173172
`Updated fixture written to: ${fixturePath}\n` +
174173
`Structural changes and auto-updatable keys were applied.\n` +
175-
`Other value mismatches require manual review.`,
174+
`Other value mismatches require manual review.\n\n` +
175+
`To fix: commit the updated fixture, or add new keys to getMobileFixtureIgnoredKeys() in fixture-validation.ts.`,
176176
);
177177
} else if (diff.valueMismatches.length > 0) {
178178
console.log(

0 commit comments

Comments
 (0)