From f70ad1644c9699ea339ff9f13523dbc84fd1b7b9 Mon Sep 17 00:00:00 2001 From: Gopar Date: Mon, 17 Aug 2026 12:01:22 -0700 Subject: [PATCH 01/17] [bug-1846013] Allows users to fetch results from all frameworks --- tests/webapp/api/test_perfcompare_api.py | 89 +++++++++++++++++++ tests/webapp/api/test_performance_data_api.py | 33 +++++++ treeherder/webapp/api/performance_data.py | 16 ++-- 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/tests/webapp/api/test_perfcompare_api.py b/tests/webapp/api/test_perfcompare_api.py index 48f03bcdde4..3592f8e86c1 100644 --- a/tests/webapp/api/test_perfcompare_api.py +++ b/tests/webapp/api/test_perfcompare_api.py @@ -9,6 +9,8 @@ PerfCompareMwuCache, PerformanceDatum, PerformanceDatumReplicate, + PerformanceFramework, + PerformanceSignature, ) from treeherder.webapp.api import perfcompare_utils @@ -1664,3 +1666,90 @@ def test_mwu_cache_recalculates_after_data_change( assert PerfCompareMwuCache.objects.count() == 2 cache_keys = list(PerfCompareMwuCache.objects.values_list("hash_key", flat=True)) assert cache_keys[0] != cache_keys[1] + + +def test_perfcompare_results_without_framework_param( + client, + create_perf_datum, + test_perf_signature, + test_repository, + try_repository, + eleven_jobs_stored, + test_perfcomp_push, + test_perfcomp_push_2, + test_linux_platform, +): + # Given two frameworks with performance data in different repositories + framework2 = PerformanceFramework.objects.create(name="test_talos_2", enabled=True) + + perf_jobs = Job.objects.filter(pk__in=range(1, 11)).order_by("push__time").all() + test_perfcomp_push.time = FOUR_DAYS_AGO + test_perfcomp_push.repository = try_repository + test_perfcomp_push.save() + test_perfcomp_push_2.time = datetime.datetime.now() + test_perfcomp_push_2.save() + + base_options = dict( + test="dhtml.html", + has_subtests=False, + extra_options="e10s fission stylo webrender", + measurement_unit="ms", + last_updated=datetime.datetime.now(), + ) + + base_sig = PerformanceSignature.objects.create( + repository=try_repository, + signature_hash=(20 * "n1"), + framework=framework2, + platform=test_linux_platform, + option_collection=test_perf_signature.option_collection, + suite="a11yr", + **base_options, + ) + new_sig = PerformanceSignature.objects.create( + repository=test_repository, + signature_hash=(20 * "n2"), + framework=test_perf_signature.framework, + platform=test_linux_platform, + option_collection=test_perf_signature.option_collection, + suite="b11yr", + **base_options, + ) + + job = perf_jobs[0] + job.push = test_perfcomp_push + job.save() + perf_datum = PerformanceDatum.objects.create( + value=32.4, + push_timestamp=job.push.time, + job=job, + push=job.push, + repository=try_repository, + signature=base_sig, + ) + perf_datum.push.time = job.push.time + perf_datum.push.save() + create_perf_datum(0, perf_jobs[1], test_perfcomp_push_2, new_sig, [40.2]) + + # When the framework parameter is omitted + query_params = ( + f"?base_repository={try_repository.name}&new_repository={test_repository.name}" + f"&new_revision={test_perfcomp_push_2.revision}" + f"&interval=604800&no_subtests=true" + ) + response = client.get(reverse("perfcompare-results") + query_params) + + # Then results are returned across all frameworks + assert response.status_code == 200 + results = response.json() + assert len(results) > 0 + + # And each result has a valid framework_id from its signature model + # And graph links are free of placeholder values + for result in results: + assert result["framework_id"] is not None + assert "None" not in result["graphs_link"] + + # And results include data from both frameworks + framework_ids = {result["framework_id"] for result in results} + assert framework_ids == {framework2.id, test_perf_signature.framework_id} diff --git a/tests/webapp/api/test_performance_data_api.py b/tests/webapp/api/test_performance_data_api.py index 90b13776147..60dad9c9447 100644 --- a/tests/webapp/api/test_performance_data_api.py +++ b/tests/webapp/api/test_performance_data_api.py @@ -798,3 +798,36 @@ def test_alert_summary_tasks_get_failure(client, test_perf_alert_summary): resp = client.get(reverse("performance-alertsummary-tasks")) assert resp.status_code == 400 assert resp.json() == {"id": ["This field is required."]} + + +def test_perf_summary_without_framework_param( + client, + test_perf_signature, + test_perf_signature_same_hash_different_framework, + test_perf_data, +): + # Given two signatures in different frameworks with performance data + signature1 = test_perf_signature + signature2 = test_perf_signature_same_hash_different_framework + + PerformanceDatum.objects.create( + repository=signature2.repository, + push=test_perf_data[0].push, + job=test_perf_data[0].job, + signature=signature2, + value=20.0, + push_timestamp=test_perf_data[0].push_timestamp, + ) + + # When the framework parameter is omitted from the summary request + query_params = ( + f"?repository={signature1.repository.name}" + f"&interval=172800&no_subtests=true" + f"&revision={test_perf_data[0].push.revision}" + ) + response = client.get(reverse("performance-summary") + query_params) + + # Then results are returned for both frameworks + assert response.status_code == 200 + framework_ids = {item["framework_id"] for item in response.json()} + assert sorted(framework_ids) == sorted({signature1.framework_id, signature2.framework_id}) diff --git a/treeherder/webapp/api/performance_data.py b/treeherder/webapp/api/performance_data.py index fa647ee5e11..12c86453d49 100644 --- a/treeherder/webapp/api/performance_data.py +++ b/treeherder/webapp/api/performance_data.py @@ -1645,6 +1645,9 @@ def _build_common_result( # Build common result dictionary (contains only data both test versions use) is_complete = base_runs_count and new_runs_count + resolved_framework = ( + framework or base_sig.get("framework_id") or new_sig.get("framework_id") + ) common_result = { "base_rev": base_rev, "new_rev": new_rev, @@ -1655,7 +1658,7 @@ def _build_common_result( "suite": suite, "test": test, "is_complete": is_complete, - "framework_id": framework, + "framework_id": resolved_framework, "option_name": option_name, "extra_options": extra_options, "base_repository_name": base_repo_name, @@ -1671,7 +1674,7 @@ def _build_common_result( new_repo_name, base_rev, new_rev, - str(framework), + str(resolved_framework), push_timestamp, str(sig_hash), ), @@ -2164,10 +2167,13 @@ def list(self, request): return Response(data=query_params.errors, status=HTTP_400_BAD_REQUEST) framework_id = query_params.validated_data["framework"] + query_set = PerformanceSignature.objects.filter(parent_signature_id=None).prefetch_related( + "performancealert" + ) + if framework_id is not None: + query_set = query_set.filter(framework_id=framework_id) query_set = ( - PerformanceSignature.objects.prefetch_related("performancealert") - .filter(framework_id=framework_id, parent_signature_id=None) - .values("suite", "test") + query_set.values("suite", "test") .annotate(repositories=GroupConcat("repository_id", distinct=True)) .annotate(platforms=GroupConcat("platform_id", distinct=True)) .annotate(total_alerts=Count("performancealert")) From 0a4ac49c28776c18873b17246a74b22219a679d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:32:09 +0000 Subject: [PATCH 02/17] Update dependency zustand to v5.0.15 (#9790) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4c7365be3bf..e733d2a2650 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "url": "0.11.4", "victory": "37.3.6", "vm-browserify": "1.1.2", - "zustand": "5.0.14" + "zustand": "5.0.15" }, "devDependencies": { "@babel/core": "7.26.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f987d3b4fa9..0109560e6cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,8 +154,8 @@ importers: specifier: 1.1.2 version: 1.1.2 zustand: - specifier: 5.0.14 - version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + specifier: 5.0.15 + version: 5.0.15(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: '@babel/core': specifier: 7.26.10 @@ -5604,8 +5604,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@5.0.14: - resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -11751,7 +11751,7 @@ snapshots: zod@3.25.76: {} - zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + zustand@5.0.15(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 react: 19.2.7 From c90d4a3788c64c09ebd1338e7eab85aa89787946 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Tue, 18 Aug 2026 07:00:01 -0700 Subject: [PATCH 03/17] Clear the selected task when switching repos via the watched-repo links (#9778) With a task selected, clicking a different tree in the secondary navbar opened the new repository but kept the old task's details panel (task details, summary/failures tabs) open, even though the task is not part of the new repository. Two defects composed to cause this: 1. updateRepoParams() removed the legacy selectedJob param but not the current selectedTaskRun param, so the stale selection traveled into the new repo's URL. 2. The Redux-to-Zustand migration regressed doClearSelectedJob(): the old Redux version documented and handled {} (passed by all URL-sync clear paths) as "no pinned jobs, clear unconditionally", but the Zustand version used a plain truthiness check, where {} is truthy. Every clear on the URL-sync path became a silent no-op, so the store kept the old task and the details panel stayed open. updateRepoParams() now also drops selectedTaskRun, and doClearSelectedJob() restores the number-or-object handling. All existing callers pass numbers, so their behavior is unchanged. --- tests/ui/helpers/location.test.js | 9 +++ tests/ui/job-view/stores/selectedJob_test.jsx | 71 +++++++++++++++++++ ui/helpers/location.js | 1 + ui/shared/stores/selectedJobStore.js | 10 ++- 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/ui/job-view/stores/selectedJob_test.jsx diff --git a/tests/ui/helpers/location.test.js b/tests/ui/helpers/location.test.js index 181b38ce218..1003e59cf9e 100644 --- a/tests/ui/helpers/location.test.js +++ b/tests/ui/helpers/location.test.js @@ -86,6 +86,15 @@ describe('updateRepoParams', () => { expect(result).not.toContain('author='); }); + it('removes selection parameters when changing repo', () => { + window.location.search = + '?repo=autoland&selectedJob=456&selectedTaskRun=OeYt2-iLQSaQb2ashZ_VIQ.0'; + const result = updateRepoParams('try'); + + expect(result).not.toContain('selectedJob='); + expect(result).not.toContain('selectedTaskRun='); + }); + it('preserves other parameters when changing repo', () => { window.location.search = '?repo=autoland&resultStatus=success&tier=1'; const result = updateRepoParams('try'); diff --git a/tests/ui/job-view/stores/selectedJob_test.jsx b/tests/ui/job-view/stores/selectedJob_test.jsx new file mode 100644 index 00000000000..6a53fa937c0 --- /dev/null +++ b/tests/ui/job-view/stores/selectedJob_test.jsx @@ -0,0 +1,71 @@ +import { + useSelectedJobStore, + syncSelectionFromUrl, +} from '../../../../ui/shared/stores/selectedJobStore'; + +jest.mock('../../../../ui/models/job', () => ({ + __esModule: true, + default: { + getList: jest.fn(() => Promise.resolve({ data: [], failureStatus: null })), + }, +})); + +const testJob = { + id: 259537372, + task_id: 'OeYt2-iLQSaQb2ashZ_VIQ', + retry_id: 0, + job_type_name: 'source-test-mozlint-spell', +}; + +const notify = jest.fn(); + +const setLocationSearch = (search) => { + window.history.replaceState(null, null, `/jobs${search}`); +}; + +beforeEach(() => { + notify.mockClear(); + useSelectedJobStore.setState({ selectedJob: null }); + setLocationSearch('?repo=autoland'); +}); + +afterEach(() => { + setLocationSearch(''); +}); + +describe('syncSelectionFromUrl', () => { + it('clears the selected job when the URL has no selection params', () => { + // A task was selected, then the user navigated to a URL without + // selectedTaskRun (e.g. switched repos via the watched-repo links). + useSelectedJobStore.setState({ selectedJob: testJob }); + setLocationSearch('?repo=mozilla-central'); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBeNull(); + }); + + it('clears the selected job when the task is not in the loaded jobs', () => { + // The URL still names a task, but the loaded pushes (e.g. after a repo + // switch) don't contain it. + useSelectedJobStore.setState({ selectedJob: testJob }); + setLocationSearch( + '?repo=mozilla-central&selectedTaskRun=OeYt2-iLQSaQb2ashZ_VIQ.0', + ); + + syncSelectionFromUrl({}, notify); + + expect(useSelectedJobStore.getState().selectedJob).toBeNull(); + }); + + it('keeps the selected job when the task is in the loaded jobs', () => { + useSelectedJobStore.setState({ selectedJob: null }); + setLocationSearch( + '?repo=autoland&selectedTaskRun=OeYt2-iLQSaQb2ashZ_VIQ.0', + ); + + syncSelectionFromUrl({ [testJob.id]: testJob }, notify); + + expect(useSelectedJobStore.getState().selectedJob).toEqual(testJob); + }); +}); diff --git a/ui/helpers/location.js b/ui/helpers/location.js index d887bb7a0b4..d30e20b8713 100644 --- a/ui/helpers/location.js +++ b/ui/helpers/location.js @@ -54,6 +54,7 @@ export const updateRepoParams = function updateRepoParams(newRepoName) { const params = getAllUrlParams(); params.delete('selectedJob'); + params.delete('selectedTaskRun'); params.delete('fromchange'); params.delete('tochange'); params.delete('revision'); diff --git a/ui/shared/stores/selectedJobStore.js b/ui/shared/stores/selectedJobStore.js index 91150bfb776..cbac07c5b04 100644 --- a/ui/shared/stores/selectedJobStore.js +++ b/ui/shared/stores/selectedJobStore.js @@ -42,8 +42,16 @@ const doSelectJob = (job) => { return { selectedJob: job }; }; +// ``countPinnedJobs`` may be a number of pinned jobs, or a pinned-jobs +// object ({} when called from the URL-sync paths). Only skip clearing +// when jobs are actually pinned. const doClearSelectedJob = (countPinnedJobs) => { - if (!countPinnedJobs) { + const hasPinnedJobs = + typeof countPinnedJobs === 'number' + ? countPinnedJobs > 0 + : Object.keys(countPinnedJobs || {}).length > 0; + + if (!hasPinnedJobs) { const selected = findSelectedInstance(); if (selected) selected.setSelected(false); From ce5014de97f2f992a6cd25dabac90cb638b04762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Tue, 18 Aug 2026 17:32:21 +0200 Subject: [PATCH 04/17] Show a link to the Phabricator revision of each commit in the push list. (#9741) The Differential Revision trailer of the commit message is already part of the push data, so no additional request is needed to know it. Give the author initials a minimum width, so that the icons of the commits of a push are aligned. --- tests/ui/job-view/revisions_test.jsx | 29 ++++++++++++++++++++++++++++ ui/css/treeherder-pushes.css | 7 +++++++ ui/shared/Revision.jsx | 16 +++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/tests/ui/job-view/revisions_test.jsx b/tests/ui/job-view/revisions_test.jsx index c514dbdb468..d45b30db51f 100644 --- a/tests/ui/job-view/revisions_test.jsx +++ b/tests/ui/job-view/revisions_test.jsx @@ -150,6 +150,35 @@ describe('Revision item component', () => { ).toBeInTheDocument(); }); + test('links the Phabricator revision of the commit message trailer', () => { + const { getByTitle } = render( + , + ); + + expect( + getByTitle('Open D315454 on Phabricator').getAttribute('href'), + ).toEqual('https://phabricator.services.mozilla.com/D315454'); + }); + + test('shows no Phabricator link for a commit message without the trailer', () => { + render( + , + ); + + expect(document.querySelectorAll('svg.fa-phabricator')).toHaveLength(0); + }); + test('marks the revision as backed out if the words "Back out" appear in the comments', () => { revision.comments = 'Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure'; diff --git a/ui/css/treeherder-pushes.css b/ui/css/treeherder-pushes.css index 8cdb2ce8a72..153c6ccc0b9 100644 --- a/ui/css/treeherder-pushes.css +++ b/ui/css/treeherder-pushes.css @@ -153,6 +153,13 @@ fieldset[disabled] .btn-push:hover { font-size: inherit; /* Inherit from parent */ } +/* Wide enough for almost all pairs of initials, so that what follows stays + aligned across the commits of a push. */ +.user-push-initials { + display: inline-block; + min-width: 1.7em; +} + /* * Revision */ diff --git a/ui/shared/Revision.jsx b/ui/shared/Revision.jsx index 7ab1191fd8b..19ff6b4559a 100644 --- a/ui/shared/Revision.jsx +++ b/ui/shared/Revision.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUser } from '@fortawesome/free-regular-svg-icons'; +import { faPhabricator } from '@fortawesome/free-brands-svg-icons'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; import { parseAuthor } from '../helpers/revision'; @@ -74,6 +75,10 @@ export class Revision extends React.PureComponent { } = this.props; const comment = comments.split('\n')[0]; const bugMatches = comment.match(/-- ([0-9]+)|bug.([0-9]+)/gi); + // Only commits that were submitted to Phabricator carry this trailer. + const phabricatorMatch = comments.match( + /^Differential Revision: (\S+\/(D[0-9]+))\s*$/m, + ); const { clipboardVisible } = this.state; const { name, email } = parseAuthor(author); const commentColor = this.isBackout(comment) @@ -103,6 +108,17 @@ export class Revision extends React.PureComponent { )} + {phabricatorMatch && ( + + + + )} Date: Tue, 18 Aug 2026 17:36:52 +0200 Subject: [PATCH 05/17] Show the Firefox Profiler link in the failure summary regardless of file extension. (#9736) Follow-up to #9565, which relaxed the extension check for the artifact panel but left the failure summary regex requiring a .js.json suffix. Profiles of mochitest plain test failures are named after the test file, eg. profile_test_group_touchevents-6-2.html.json, so their "profile uploaded in" lines were rendered without a profiler link. --- tests/ui/helpers/logFormatting.test.jsx | 21 +++++++++++++++++++++ ui/helpers/logFormatting.jsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/ui/helpers/logFormatting.test.jsx b/tests/ui/helpers/logFormatting.test.jsx index 27d3e21ac9e..1b1b3fce2fb 100644 --- a/tests/ui/helpers/logFormatting.test.jsx +++ b/tests/ui/helpers/logFormatting.test.jsx @@ -109,6 +109,27 @@ describe('formatLogLineWithLinks', () => { ).toBeInTheDocument(); }); + it('creates link for profile artifacts with other file extensions', () => { + const line = + 'TEST-UNEXPECTED-FAIL | gfx/layers/apz/test/mochitest/test_group_touchevents-6.html | profile uploaded in profile_test_group_touchevents-6-2.html.json'; + const jobDetails = [ + { + value: 'profile_test_group_touchevents-6-2.html.json', + url: 'https://taskcluster.example.com/artifacts/profile_test_group_touchevents-6-2.html.json', + }, + ]; + + const result = formatLogLineWithLinks(line, jobDetails, mockJob); + const Wrapper = () => <>{result}; + render(); + + expect( + screen.getByText( + 'open profile_test_group_touchevents-6-2.html.json in the Firefox Profiler', + ), + ).toBeInTheDocument(); + }); + it('returns original line when profile artifact not found in jobDetails', () => { const line = 'INFO profile uploaded in profile_other.js.json to TaskCluster'; diff --git a/ui/helpers/logFormatting.jsx b/ui/helpers/logFormatting.jsx index 60700aa97ad..796e172a377 100644 --- a/ui/helpers/logFormatting.jsx +++ b/ui/helpers/logFormatting.jsx @@ -82,7 +82,7 @@ export default function formatLogLineWithLinks( } // Check for profile uploaded - const hasProfile = line.match(/profile uploaded in (profile_.*\.js\.json)/); + const hasProfile = line.match(/profile uploaded in (profile_\S+)/); if (hasProfile) { const artifact = jobDetails.find( (artifact) => artifact.value === hasProfile[1], From 0b448e0ae7e899e0d6661fdf52f60ad028479597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Tue, 18 Aug 2026 17:37:38 +0200 Subject: [PATCH 06/17] Restore visited link color in ReactTable bodies. (#9725) .rt-tbody a and a:visited have equal specificity (0,1,1), so the tie was broken by source order. treeherder-base.css is hoisted ahead of treeherder-custom-styles.css in the bundle (ui/App.jsx statically imports ./userguide/App, which is the first module to pull in base.css), which made the teal .rt-tbody rule win for visited links too. Adding .rt-tbody a:visited at specificity (0,2,1) restores purple for visited links in the intermittent failures tables and the Perfherder graph table view, while unvisited links keep #187c86. purple (#800080) on the table background is ~8.9:1, so this does not regress the contrast pass that motivated the teal rule. --- ui/css/treeherder-custom-styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/css/treeherder-custom-styles.css b/ui/css/treeherder-custom-styles.css index 1838355e3e0..5f5dcc02e3d 100644 --- a/ui/css/treeherder-custom-styles.css +++ b/ui/css/treeherder-custom-styles.css @@ -505,6 +505,10 @@ a.btn-outline-dark:hover { color: #187c86; } +.rt-tbody a:visited { + color: purple; +} + /* * Onscreen help */ From ef5b4dfe59ddbb7dd0f67e10fc1f95d84ce38eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Qu=C3=A8ze?= Date: Tue, 18 Aug 2026 17:41:05 +0200 Subject: [PATCH 07/17] Group crash artifacts into a single row even when the .extra file is missing. (#9726) The crash viewer reads the .json file, so a crash with only a .dmp and a .json can still be opened there. Show the .extra link only when that file is present, and aggregate the size and expiry over the files we have. --- ui/shared/JobArtifacts.jsx | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/ui/shared/JobArtifacts.jsx b/ui/shared/JobArtifacts.jsx index b81af88a693..703e161915e 100644 --- a/ui/shared/JobArtifacts.jsx +++ b/ui/shared/JobArtifacts.jsx @@ -101,7 +101,7 @@ export default class JobArtifacts extends React.PureComponent { groupCrashDumps(jobDetails) { const crashDumps = new Map(); // Maps crash ID to {dmp, extra, json} artifacts - const completeCrashIds = new Set(); // Crash IDs with all 3 files + const mergedCrashIds = new Set(); // Crash IDs shown as a single row jobDetails.forEach((artifact) => { const match = artifact.value.match(CRASH_DUMP_PATTERN); @@ -116,12 +116,13 @@ export default class JobArtifacts extends React.PureComponent { } }); - // Identify complete crash dumps (all 3 files present) and compute - // aggregated size (sum) and expiry (earliest) across the 3 files. + // Identify crash dumps we can show in the crash viewer (the viewer reads + // the .json file, the .extra file is optional) and compute aggregated + // size (sum) and expiry (earliest) across the files we have. crashDumps.forEach((crash, crashId) => { - if (crash.dmp && crash.extra && crash.json) { - completeCrashIds.add(crashId); - const files = [crash.dmp, crash.extra, crash.json]; + if (crash.dmp && crash.json) { + mergedCrashIds.add(crashId); + const files = [crash.dmp, crash.extra, crash.json].filter(Boolean); crash.contentLength = files.reduce( (sum, f) => Number.isFinite(f.contentLength) ? sum + f.contentLength : sum, @@ -134,7 +135,7 @@ export default class JobArtifacts extends React.PureComponent { } }); - return { crashDumps, completeCrashIds }; + return { crashDumps, mergedCrashIds }; } render() { @@ -145,15 +146,15 @@ export default class JobArtifacts extends React.PureComponent { selectedJob = null, } = this.props; - const { crashDumps, completeCrashIds } = this.groupCrashDumps(jobDetails); + const { crashDumps, mergedCrashIds } = this.groupCrashDumps(jobDetails); - // Emit one row per artifact, collapsing complete crash dumps into a - // single aggregated row (anchored on the .json file). + // Emit one row per artifact, collapsing crash dumps into a single + // aggregated row (anchored on the .json file). const rows = jobDetails.flatMap((line) => { const match = line.value.match(CRASH_DUMP_PATTERN); if (match) { const [, crashId, fileType] = match; - if (completeCrashIds.has(crashId)) { + if (mergedCrashIds.has(crashId)) { if (fileType !== 'json') return []; const crash = crashDumps.get(crashId); return [ @@ -226,10 +227,14 @@ export default class JobArtifacts extends React.PureComponent { - {', '} - - .extra - + {crash.extra && ( + <> + {', '} + + .extra + + + )} {', '} .json{' '} -{' '} From 3cd2864b8c4f479d974f7c973cf909fe4e265dd3 Mon Sep 17 00:00:00 2001 From: Ted Campbell <53823885+moztcampbell@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:59:27 -0400 Subject: [PATCH 08/17] Bug 2045860 - Transform repo name for treestatus links (#9595) We transform the name already for querying current status, but the UI links are missing that. Factor out and reuse the same logic. --- ui/job-view/headerbars/WatchedRepo.jsx | 7 +++++-- ui/models/treeStatus.js | 17 +++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/job-view/headerbars/WatchedRepo.jsx b/ui/job-view/headerbars/WatchedRepo.jsx index 1a9c68c68dd..9ec1f552e25 100644 --- a/ui/job-view/headerbars/WatchedRepo.jsx +++ b/ui/job-view/headerbars/WatchedRepo.jsx @@ -13,7 +13,10 @@ import { import { Button, Dropdown } from 'react-bootstrap'; import { Link } from 'react-router'; -import TreeStatusModel, { treeStatusUiUrl } from '../../models/treeStatus'; +import TreeStatusModel, { + treeStatusUiUrl, + treeStatusName, +} from '../../models/treeStatus'; import BugLinkify from '../../shared/BugLinkify'; import { updateRepoParams } from '../../helpers/location'; @@ -171,7 +174,7 @@ function WatchedRepo({ repoName, unwatchRepo, repo, setCurrentRepoTreeStatus }) )} diff --git a/ui/models/treeStatus.js b/ui/models/treeStatus.js index f319229094a..328661a0964 100644 --- a/ui/models/treeStatus.js +++ b/ui/models/treeStatus.js @@ -28,16 +28,21 @@ export function treeStatusUiUrl() { return _treeStatusUiUrl; } +export function treeStatusName(repoName) { + if (repoMap.has(repoName)) { + return repoMap.get(repoName); + } + if (repoName.includes('-esr')) { + return `firefox-esr${repoName.split('-esr')[1]}`; + } + return repoName; +} + const apiUrl = `${_treeStatusApiUrl}trees/`; export default class TreeStatusModel { static get(repoName) { - let repoNameGit = repoName; - if (repoMap.has(repoName)) { - repoNameGit = repoMap.get(repoName); - } else if (repoName.includes('-esr')) { - repoNameGit = `firefox-esr${repoName.split('-esr')[1]}`; - } + const repoNameGit = treeStatusName(repoName); return fetch(`${apiUrl}${repoNameGit}`) .then(async (resp) => { if (resp.ok) { From e6b8329ec1906632f34fff339497fd64b64bfbde Mon Sep 17 00:00:00 2001 From: Gopar Date: Mon, 24 Aug 2026 11:37:16 -0700 Subject: [PATCH 09/17] Bug 2020493 Make common methods (#9779) * [bug-2020493] Add helper classes for easier data passing * [bug-2020493] Add helper function to reduce boiler plate * [bug-2020493] Avoid double negative in variable naming for easier reading * [bug-2020493] Move class creation to their own section * [bug-2020493] Fix conditional error * [bug-2020493] Rename variables for better readability --- treeherder/webapp/api/performance_data.py | 487 +++++++++------------- 1 file changed, 193 insertions(+), 294 deletions(-) diff --git a/treeherder/webapp/api/performance_data.py b/treeherder/webapp/api/performance_data.py index 12c86453d49..3cb2b69c4a4 100644 --- a/treeherder/webapp/api/performance_data.py +++ b/treeherder/webapp/api/performance_data.py @@ -7,6 +7,7 @@ import warnings from collections import defaultdict from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from decimal import Decimal from urllib.parse import urlencode @@ -1186,6 +1187,26 @@ def list(self, request): return Response(data=serializer.data) +@dataclass(frozen=True) +class _RepoPerfData: + signatures_map: dict + values: dict + replicates: dict + stats: dict + job_ids: dict + rev: str | None + repo_name: str + + +@dataclass(frozen=True) +class _ComparisonData: + base: _RepoPerfData + new: _RepoPerfData + option_collection_map: dict + framework: int + push_timestamp: int + + class PerfCompareResults(generics.ListAPIView): serializer_class = PerfCompareResultsSerializer queryset = None @@ -1294,15 +1315,37 @@ def list(self, request): platforms = set(base_platforms + new_platforms) self.queryset = [] + base = _RepoPerfData( + signatures_map=base_signatures_map, + values=base_grouped_values, + replicates=base_grouped_replicates, + stats=statistics_base_grouped_data, + job_ids=base_grouped_job_ids, + rev=base_rev, + repo_name=base_repo_name, + ) + new = _RepoPerfData( + signatures_map=new_signatures_map, + values=new_grouped_values, + replicates=new_grouped_replicates, + stats=statistics_new_grouped_data, + job_ids=new_grouped_job_ids, + rev=new_rev, + repo_name=new_repo_name, + ) + comparison_inputs = _ComparisonData( + base=base, + new=new, + option_collection_map=option_collection_map, + framework=framework, + push_timestamp=push_timestamp, + ) + # Process results based on test version cache_key = None if test_version == "mann-whitney-u": cache_key = self._compute_mwu_cache_key( - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, + comparison_inputs, interval, no_subtests, base_parent_signature, @@ -1311,56 +1354,18 @@ def list(self, request): enable_silverman_kde, base_signatures, new_signatures, - statistics_base_grouped_data, - statistics_new_grouped_data, ) + cached = PerfCompareMwuCache.objects.filter(hash_key=cache_key).first() if cached: return Response(data=cached.results) - self._process_mann_whitney_u_version( - header_names, - platforms, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - enable_silverman_kde, + self.queryset = PerfCompareResults._process_mann_whitney_u( + comparison_inputs, header_names, platforms, enable_silverman_kde ) else: - self._process_student_t_version( - header_names, - platforms, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, + self.queryset = PerfCompareResults._process_student_t( + comparison_inputs, header_names, platforms ) serializer = self.get_serializer(self.queryset, many=True) @@ -1372,242 +1377,140 @@ def list(self, request): return Response(data=serialized_data) - def _process_mann_whitney_u_version( - self, - header_names, - platforms, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - enable_silverman_kde, - ): - """ - Process performance comparison results using Mann-Whitney U test with parallel processing. - """ - tasks = [] + @staticmethod + def _comparison_pairs(comparison_inputs, header_names, platforms): + """Yield each valid (header, platform) pair with its unpacked common result.""" for header in header_names: for platform in platforms: - # Build common result using shared method ( lower_is_better, statistics_base_perf_data, statistics_new_perf_data, - no_results_to_show, + has_results, common_result, - ) = self._build_common_result( - header, - platform, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - ) - - if no_results_to_show: - continue + ) = PerfCompareResults._build_common_result(comparison_inputs, header, platform) - tasks.append( - ( + if has_results: + yield ( + lower_is_better, statistics_base_perf_data, statistics_new_perf_data, header, - lower_is_better, common_result, - enable_silverman_kde, ) - ) - # Process tasks in parallel using multiprocessing + @staticmethod + def _process_mann_whitney_u(comparison_inputs, header_names, platforms, enable_silverman_kde): + """ + Process performance comparison results using Mann-Whitney U test with parallel processing. + """ + tasks = [] + for ( + lower_is_better, + stats_base, + stats_new, + header, + common, + ) in PerfCompareResults._comparison_pairs(comparison_inputs, header_names, platforms): + tasks.append( + (stats_base, stats_new, header, lower_is_better, common, enable_silverman_kde) + ) + workers = multiprocessing.cpu_count() logger.warning(f"Workers used for MWU analysis: {workers}") with multiprocessing.Pool(processes=workers) as pool: - results = pool.starmap(self._process_mann_whitney_task, tasks) - - self.queryset.extend(results) - - def _process_student_t_version( - self, - header_names, - platforms, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - ): + results = pool.starmap(PerfCompareResults._process_mann_whitney_task, tasks) + + return results + + @staticmethod + def _process_student_t(comparison_inputs, header_names, platforms): """ Process performance comparison results using Student's t-test (sequential processing). """ - for header in header_names: - for platform in platforms: - # Build common result using shared method - ( - lower_is_better, - statistics_base_perf_data, - statistics_new_perf_data, - no_results_to_show, - common_result, - ) = self._build_common_result( - header, - platform, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - ) + results = [] + for ( + lower_is_better, + stats_base, + stats_new, + header, + common, + ) in PerfCompareResults._comparison_pairs(comparison_inputs, header_names, platforms): + # Calculate Student's t-test specific data + base_runs_count = len(stats_base) + new_runs_count = len(stats_new) + is_complete = base_runs_count and new_runs_count + + base_avg_value = perfcompare_utils.get_avg(stats_base, header) + base_stddev = perfcompare_utils.get_stddev(stats_base, header) + base_median_value = perfcompare_utils.get_median(stats_base) + new_avg_value = perfcompare_utils.get_avg(stats_new, header) + new_stddev = perfcompare_utils.get_stddev(stats_new, header) + new_median_value = perfcompare_utils.get_median(stats_new) + base_stddev_pct = perfcompare_utils.get_stddev_pct(base_avg_value, base_stddev) + new_stddev_pct = perfcompare_utils.get_stddev_pct(new_avg_value, new_stddev) + confidence = perfcompare_utils.get_abs_ttest_value(stats_base, stats_new) + confidence_text = perfcompare_utils.get_confidence_text(confidence) + delta_value = perfcompare_utils.get_delta_value(new_avg_value, base_avg_value) + delta_percentage = perfcompare_utils.get_delta_percentage(delta_value, base_avg_value) + magnitude = perfcompare_utils.get_magnitude(delta_percentage) + new_is_better = perfcompare_utils.is_new_better(delta_value, lower_is_better) + is_confident = perfcompare_utils.is_confident( + base_runs_count, new_runs_count, confidence + ) + more_runs_are_needed = perfcompare_utils.more_runs_are_needed( + is_complete, is_confident, base_runs_count + ) + class_name = perfcompare_utils.get_class_name( + new_is_better, base_avg_value, new_avg_value, confidence + ) - if no_results_to_show: - continue - - # Calculate Student's t-test specific data - base_runs_count = len(statistics_base_perf_data) - new_runs_count = len(statistics_new_perf_data) - is_complete = base_runs_count and new_runs_count - - base_avg_value = perfcompare_utils.get_avg(statistics_base_perf_data, header) - base_stddev = perfcompare_utils.get_stddev(statistics_base_perf_data, header) - base_median_value = perfcompare_utils.get_median(statistics_base_perf_data) - new_avg_value = perfcompare_utils.get_avg(statistics_new_perf_data, header) - new_stddev = perfcompare_utils.get_stddev(statistics_new_perf_data, header) - new_median_value = perfcompare_utils.get_median(statistics_new_perf_data) - base_stddev_pct = perfcompare_utils.get_stddev_pct(base_avg_value, base_stddev) - new_stddev_pct = perfcompare_utils.get_stddev_pct(new_avg_value, new_stddev) - confidence = perfcompare_utils.get_abs_ttest_value( - statistics_base_perf_data, statistics_new_perf_data - ) - confidence_text = perfcompare_utils.get_confidence_text(confidence) - delta_value = perfcompare_utils.get_delta_value(new_avg_value, base_avg_value) - delta_percentage = perfcompare_utils.get_delta_percentage( - delta_value, base_avg_value - ) - magnitude = perfcompare_utils.get_magnitude(delta_percentage) - new_is_better = perfcompare_utils.is_new_better(delta_value, lower_is_better) - is_confident = perfcompare_utils.is_confident( - base_runs_count, new_runs_count, confidence - ) - more_runs_are_needed = perfcompare_utils.more_runs_are_needed( - is_complete, is_confident, base_runs_count - ) - class_name = perfcompare_utils.get_class_name( - new_is_better, base_avg_value, new_avg_value, confidence - ) + is_improvement = class_name == "success" + is_regression = class_name == "danger" + is_meaningful = class_name == "" + + row_result = { + **common, + "base_avg_value": base_avg_value, + "new_avg_value": new_avg_value, + "base_median_value": base_median_value, + "new_median_value": new_median_value, + "base_stddev": base_stddev, + "new_stddev": new_stddev, + "confidence": confidence, + "confidence_text": confidence_text, + "delta_value": delta_value, + "delta_percentage": delta_percentage, + "magnitude": magnitude, + "new_is_better": new_is_better, + "lower_is_better": lower_is_better, + "is_confident": is_confident, + "more_runs_are_needed": more_runs_are_needed, + "is_improvement": is_improvement, + "is_regression": is_regression, + "is_meaningful": is_meaningful, + "base_stddev_pct": base_stddev_pct, + "new_stddev_pct": new_stddev_pct, + } - is_improvement = class_name == "success" - is_regression = class_name == "danger" - is_meaningful = class_name == "" - - row_result = { - **common_result, - "base_avg_value": base_avg_value, - "new_avg_value": new_avg_value, - "base_median_value": base_median_value, - "new_median_value": new_median_value, - "base_stddev": base_stddev, - "new_stddev": new_stddev, - "confidence": confidence, - "confidence_text": confidence_text, - "delta_value": delta_value, - "delta_percentage": delta_percentage, - "magnitude": magnitude, - "new_is_better": new_is_better, - "lower_is_better": lower_is_better, - "is_confident": is_confident, - "more_runs_are_needed": more_runs_are_needed, - "is_improvement": is_improvement, - "is_regression": is_regression, - "is_meaningful": is_meaningful, - "base_stddev_pct": base_stddev_pct, - "new_stddev_pct": new_stddev_pct, - } + results.append(row_result) - self.queryset.append(row_result) + return results - def _build_common_result( - self, - header, - platform, - base_signatures_map, - new_signatures_map, - base_grouped_values, - new_grouped_values, - base_grouped_replicates, - new_grouped_replicates, - statistics_base_grouped_data, - statistics_new_grouped_data, - base_grouped_job_ids, - new_grouped_job_ids, - option_collection_map, - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, - push_timestamp, - ): + @staticmethod + def _build_common_result(comparison_inputs, header, platform): """ Build the common result dictionary that is shared between Mann-Whitney U and Student's t-test processing. Returns a tuple of: (lower_is_better, statistics_base_perf_data, statistics_new_perf_data, - no_results_to_show, common_result) + has_results, common_result) """ sig_identifier = perfcompare_utils.get_sig_identifier(header, platform) - base_sig = base_signatures_map.get(sig_identifier, {}) + base_sig = comparison_inputs.base.signatures_map.get(sig_identifier, {}) base_sig_id = base_sig.get("id", None) - new_sig = new_signatures_map.get(sig_identifier, {}) + new_sig = comparison_inputs.new.signatures_map.get(sig_identifier, {}) new_sig_id = new_sig.get("id", None) # Get signature-based properties @@ -1619,7 +1522,9 @@ def _build_common_result( sig_hash, suite, test, - ) = self._get_signature_based_properties(base_sig, option_collection_map) + ) = PerfCompareResults._get_signature_based_properties( + base_sig, comparison_inputs.option_collection_map + ) else: ( extra_options, @@ -1628,29 +1533,28 @@ def _build_common_result( sig_hash, suite, test, - ) = self._get_signature_based_properties(new_sig, option_collection_map) + ) = PerfCompareResults._get_signature_based_properties( + new_sig, comparison_inputs.option_collection_map + ) # Extract performance data - base_perf_data_values = base_grouped_values.get(base_sig_id, []) - new_perf_data_values = new_grouped_values.get(new_sig_id, []) - base_perf_data_replicates = base_grouped_replicates.get(base_sig_id, []) - new_perf_data_replicates = new_grouped_replicates.get(new_sig_id, []) - statistics_base_perf_data = statistics_base_grouped_data.get(base_sig_id, []) - statistics_new_perf_data = statistics_new_grouped_data.get(new_sig_id, []) + base_perf_data_values = comparison_inputs.base.values.get(base_sig_id, []) + new_perf_data_values = comparison_inputs.new.values.get(new_sig_id, []) + base_perf_data_replicates = comparison_inputs.base.replicates.get(base_sig_id, []) + new_perf_data_replicates = comparison_inputs.new.replicates.get(new_sig_id, []) + statistics_base_perf_data = comparison_inputs.base.stats.get(base_sig_id, []) + statistics_new_perf_data = comparison_inputs.new.stats.get(new_sig_id, []) # Check if there are no results to show base_runs_count = len(statistics_base_perf_data) new_runs_count = len(statistics_new_perf_data) - no_results_to_show = not base_runs_count and not new_runs_count + has_results = base_runs_count or new_runs_count # Build common result dictionary (contains only data both test versions use) is_complete = base_runs_count and new_runs_count - resolved_framework = ( - framework or base_sig.get("framework_id") or new_sig.get("framework_id") - ) common_result = { - "base_rev": base_rev, - "new_rev": new_rev, + "base_rev": comparison_inputs.base.rev, + "new_rev": comparison_inputs.new.rev, "header_name": header, "platform": platform, "base_app": base_sig.get("application", ""), @@ -1658,28 +1562,28 @@ def _build_common_result( "suite": suite, "test": test, "is_complete": is_complete, - "framework_id": resolved_framework, + "framework_id": comparison_inputs.framework, "option_name": option_name, "extra_options": extra_options, - "base_repository_name": base_repo_name, - "new_repository_name": new_repo_name, + "base_repository_name": comparison_inputs.base.repo_name, + "new_repository_name": comparison_inputs.new.repo_name, "base_measurement_unit": base_sig.get("measurement_unit", ""), "new_measurement_unit": new_sig.get("measurement_unit", ""), "base_runs": base_perf_data_values, "new_runs": new_perf_data_values, "base_runs_replicates": base_perf_data_replicates, "new_runs_replicates": new_perf_data_replicates, - "graphs_link": self._create_graph_links( - base_repo_name, - new_repo_name, - base_rev, - new_rev, - str(resolved_framework), - push_timestamp, + "graphs_link": PerfCompareResults._create_graph_links( + comparison_inputs.base.repo_name, + comparison_inputs.new.repo_name, + comparison_inputs.base.rev, + comparison_inputs.new.rev, + str(comparison_inputs.framework), + comparison_inputs.push_timestamp, str(sig_hash), ), - "base_retriggerable_job_ids": base_grouped_job_ids.get(base_sig_id, []), - "new_retriggerable_job_ids": new_grouped_job_ids.get(new_sig_id, []), + "base_retriggerable_job_ids": comparison_inputs.base.job_ids.get(base_sig_id, []), + "new_retriggerable_job_ids": comparison_inputs.new.job_ids.get(new_sig_id, []), "base_parent_signature": base_sig.get("parent_signature_id", None), "new_parent_signature": new_sig.get("parent_signature_id", None), "base_signature_id": base_sig_id, @@ -1693,15 +1597,16 @@ def _build_common_result( lower_is_better, statistics_base_perf_data, statistics_new_perf_data, - no_results_to_show, + has_results, common_result, ) - def _get_signature_based_properties(self, sig, option_collection_map): + @staticmethod + def _get_signature_based_properties(sig, option_collection_map): return ( sig.get("extra_options", ""), sig.get("lower_is_better", ""), - self._get_option_name(sig, option_collection_map), + PerfCompareResults._get_option_name(sig, option_collection_map), sig.get("signature_hash", ""), sig.get("suite", ""), sig.get("test", ""), @@ -2113,11 +2018,7 @@ def _process_stats( @staticmethod def _compute_mwu_cache_key( - base_rev, - new_rev, - base_repo_name, - new_repo_name, - framework, + comparison_inputs: _ComparisonData, interval, no_subtests, base_parent_signature, @@ -2126,20 +2027,18 @@ def _compute_mwu_cache_key( enable_silverman_kde, base_signatures, new_signatures, - statistics_base_grouped_data, - statistics_new_grouped_data, ): base_sig_ids = sorted(str(s["id"]) for s in base_signatures) new_sig_ids = sorted(str(s["id"]) for s in new_signatures) - total_data_points = sum(len(v) for v in statistics_base_grouped_data.values()) + sum( - len(v) for v in statistics_new_grouped_data.values() + total_data_points = sum(len(v) for v in comparison_inputs.base.stats.values()) + sum( + len(v) for v in comparison_inputs.new.stats.values() ) key_components = { - "base_rev": base_rev, - "new_rev": new_rev, - "base_repo": base_repo_name, - "new_repo": new_repo_name, - "framework": framework, + "base_rev": comparison_inputs.base.rev, + "new_rev": comparison_inputs.new.rev, + "base_repo": comparison_inputs.base.repo_name, + "new_repo": comparison_inputs.new.repo_name, + "framework": comparison_inputs.framework, "interval": interval, "no_subtests": no_subtests, "base_parent_signature": base_parent_signature, From 46b37fc0642ea99ea1e76d34ef9e4455183fb5e0 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 19 Aug 2026 08:43:20 -0400 Subject: [PATCH 10/17] Bug 2062732 - Fix submit_time when using replicates=true (#9775) We were using the key `submit_time` in the hand-built datum dicts, but then during serialization, `PerformanceDatumSerializer` was looking for a `job__submit_time` key instead. That's because we supply `source="job__submit_time"` in the serializer's constructor. The mismatch failed silently rather than raising: the field is declared `required=False, default=None`, so every datum serialized with `submit_time: null`, which left the graphs view with an Invalid Date for its retrigger times. Use `job__submit_time` so that the serializer finds the field. --- tests/webapp/api/test_performance_data_api.py | 53 +++++++++++++++++++ treeherder/webapp/api/performance_data.py | 4 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/webapp/api/test_performance_data_api.py b/tests/webapp/api/test_performance_data_api.py index 60dad9c9447..1383bfbb783 100644 --- a/tests/webapp/api/test_performance_data_api.py +++ b/tests/webapp/api/test_performance_data_api.py @@ -1,6 +1,7 @@ import copy import datetime from collections import defaultdict +from urllib.parse import urlencode import pytest from django.urls import reverse @@ -10,6 +11,7 @@ from treeherder.perf.models import ( PerformanceAlert, PerformanceDatum, + PerformanceDatumReplicate, PerformanceFramework, PerformanceSignature, ) @@ -530,6 +532,57 @@ def test_perf_summary(client, test_perf_signature, test_perf_data): assert resp2.json() == expected +def summary_query_params(signature, perf_data, **extra): + """ + An `all_data` query for one signature, spanning the data it was given. + + We expand the time window outwards by one day so that it definitely + covers the entire data and we don't end up checking an empty list. + """ + timestamps = [datum.push_timestamp for datum in perf_data] + one_day = datetime.timedelta(days=1) + return "?" + urlencode( + { + "repository": signature.repository.name, + "framework": signature.framework_id, + "signature": signature.id, + "startday": (min(timestamps) - one_day).isoformat(), + "endday": (max(timestamps) + one_day).isoformat(), + "all_data": "true", + **extra, + } + ) + + +@pytest.mark.parametrize("replicates", ["true", "false"]) +def test_perf_summary_data_includes_submit_time( + client, test_perf_signature, test_perf_data, replicates +): + """ + Test that `submit_time` is not null when using `replicates=true`. + + The `replicates=false` case goes through a code path that was already + correct; it's covered here as a regression guard. + """ + + # Add a replicate row - the fixture doesn't have any replicates. + PerformanceDatumReplicate.objects.create( + performance_datum=test_perf_data[0], value=test_perf_data[0].value + ) + + query_params = summary_query_params(test_perf_signature, test_perf_data, replicates=replicates) + + response = client.get(reverse("performance-summary") + query_params) + assert response.status_code == 200 + + data = response.json()[0]["data"] + expected = {datum.job.submit_time.strftime("%Y-%m-%dT%H:%M:%S") for datum in test_perf_data} + # One row per datum: the single replicate we added stands in for its datum's + # value rather than adding a row. + assert len(data) == len(test_perf_data) + assert {row["submit_time"] for row in data} == expected + + def test_perf_summary_should_alert_is_false_edge_case( client, test_perf_signature, test_perf_signature_2, test_perf_data ): diff --git a/treeherder/webapp/api/performance_data.py b/treeherder/webapp/api/performance_data.py index 3cb2b69c4a4..d7441845de3 100644 --- a/treeherder/webapp/api/performance_data.py +++ b/treeherder/webapp/api/performance_data.py @@ -1046,7 +1046,7 @@ def list(self, request): "push_id": push_id, "push_timestamp": push_timestamp, "push__revision": push_revision, - "submit_time": submit_time, + "job__submit_time": submit_time, } ) elif value is not None: @@ -1058,7 +1058,7 @@ def list(self, request): "push_id": push_id, "push_timestamp": push_timestamp, "push__revision": push_revision, - "submit_time": submit_time, + "job__submit_time": submit_time, } ) else: From 8d3bdb20650034f7965a772ddb650bc7b4ba9622 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Wed, 19 Aug 2026 05:48:28 -0700 Subject: [PATCH 11/17] Migrate UI integration tests from Puppeteer to Playwright (#9735) * Migrate UI integration tests from Puppeteer to Playwright Replace the jest-puppeteer + Polly.js integration test setup with @playwright/test: - Add playwright.config.js; the config starts (or reuses) the dev server on port 5000, matching the previous jest-puppeteer behavior. - Convert the logviewer suite to a Playwright spec, keeping the fetch-mocking approach via page.addInitScript. Selectors and labels are updated to the current toolbar UI (classic-log-toolbar-label replaced the old copy-highlight bar). - Convert the graphs view suite to a Playwright spec. The existing Polly HAR recordings are replayed with page.routeFromHAR; the performance/summary responses are served by signature because the query params have changed since the HAR was recorded. - Remove puppeteer, jest-puppeteer, jest-environment-puppeteer, the @pollyjs packages and setup-polly-jest, along with jest.integration.config.js and jest-puppeteer.config.js. - Update docs/testing.md and biome.json accordingly. * Add Playwright integration tests for the Jobs view Cover the basic Jobs view workflows end to end: rendering the push list, selecting a job and verifying the details panel opens with the job's details and the selectedTaskRun URL param, and narrowing the displayed jobs with the quick filter. API responses are served from the JSON fixtures in tests/ui/mock/ via page.route, so the tests run deterministically without a backend. * Run Playwright integration tests in Firefox by default Firefox is the preferred browser for this project. Switch the Playwright project from Chromium to Firefox and update docs/testing.md accordingly. Granting clipboard permissions is a Chromium-only API that throws on Firefox, so the logviewer copy test now only grants them when running in Chromium; Playwright's Firefox permits the clipboard write in tests without an explicit grant. * fix: regenerate pnpm-lock.yaml after rebase conflict resolution The rebase onto origin/master conflicted in pnpm-lock.yaml. Took the target branch's version as a starting point and regenerated the lockfile with `pnpm install --no-frozen-lockfile` to ensure it is consistent with package.json (playwright/puppeteer/polly changes from this branch plus master's zustand 5.0.15 bump). Co-Authored-By: Claude Opus 4.6 * Run Playwright integration tests in CircleCI Adds a javascript-integration-tests job that installs and caches the Playwright Firefox build, runs pnpm test:integration (Playwright's webServer starts the dev server itself), and uploads the JUnit results plus the HTML report/traces as artifacts. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.6 --- .circleci/config.yml | 32 + .gitignore | 5 +- biome.json | 2 +- docs/testing.md | 31 +- jest-puppeteer.config.js | 11 - jest.integration.config.js | 6 - package.json | 12 +- playwright.config.js | 38 + pnpm-lock.yaml | 1187 +---------------- .../graphs-view/graphs_view.spec.js | 96 ++ .../graphs_view_integration_test.jsx | 97 -- .../ui/integration/job-view/jobs_view.spec.js | 176 +++ .../integration/logviewer/logviewer.spec.js | 334 +++++ .../logviewer/logviewer_integration_test.js | 399 ------ tests/ui/integration/test-setup.js | 2 - 15 files changed, 771 insertions(+), 1657 deletions(-) delete mode 100644 jest-puppeteer.config.js delete mode 100644 jest.integration.config.js create mode 100644 playwright.config.js create mode 100644 tests/ui/integration/graphs-view/graphs_view.spec.js delete mode 100644 tests/ui/integration/graphs-view/graphs_view_integration_test.jsx create mode 100644 tests/ui/integration/job-view/jobs_view.spec.js create mode 100644 tests/ui/integration/logviewer/logviewer.spec.js delete mode 100644 tests/ui/integration/logviewer/logviewer_integration_test.js delete mode 100644 tests/ui/integration/test-setup.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 2aafc9ccbe2..8db7a3469b8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -153,6 +153,37 @@ jobs: name: Run Jest tests - codecov/upload + javascript-integration-tests: + executor: + name: node/default + tag: '22.0' + # 4 vCPU: Playwright runs spec files in parallel workers, and the rspack + # dev server build that `webServer` triggers benefits from the extra cores. + resource_class: large + steps: + - checkout + - setup-pnpm + - restore_cache: + keys: + - playwright-firefox-v1-{{ checksum "pnpm-lock.yaml" }} + - playwright-firefox-v1- + - run: + name: Install Playwright Firefox + command: npx playwright install --with-deps firefox + - save_cache: + key: playwright-firefox-v1-{{ checksum "pnpm-lock.yaml" }} + paths: + - ~/.cache/ms-playwright + - run: + name: Run Playwright integration tests + command: pnpm test:integration + - store_test_results: + path: test-results + - store_artifacts: + path: playwright-report + - store_artifacts: + path: test-results + javascript-lint: executor: name: node/default @@ -257,6 +288,7 @@ workflows: run-tests: jobs: - javascript-tests + - javascript-integration-tests - javascript-lint - builds - python-tests-frontend diff --git a/.gitignore b/.gitignore index 2df5960df85..23d8c269f61 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ CLAUDE.md .superpowers/ # Cloud SQL Auth Proxy -cloud-sql-proxy \ No newline at end of file +cloud-sql-proxy +# Playwright artifacts +test-results/ +playwright-report/ diff --git a/biome.json b/biome.json index 2801e064bfe..28b217e40a4 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,7 @@ "quoteStyle": "single", "trailingCommas": "all" }, - "globals": ["page", "browser", "jestPuppeteer"] + "globals": [] }, "css": { "linter": { diff --git a/docs/testing.md b/docs/testing.md index 70bedbe0244..629d1cbbd61 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,18 +36,40 @@ See the [code style](code_style.md#ui) section for more details. ### Running the Jest front-end tests The unit tests for the UI are run with [Jest]. -The tests are written with react testing library. For the integration tests PollyJS is used to mock APIs. +The tests are written with react testing library. -Integration tests are useful when testing higher level components that would be hard to setup with fetch mock. -They use PollyJS because it helps to automatically record and replay requests/responses. -To refresh the PollyJS recordings (usually when an endpoint response changes), just delete the recordings folder and run `pnpm test:integration` again like described below. +### Running the Playwright integration tests + +Integration tests are useful when testing higher level components that would be hard to set up with fetch mock. +They are run with [Playwright] (see `playwright.config.js`) in Firefox, which starts the dev server +automatically if it isn't already running on port 5000. API responses for the graphs view tests are replayed +from the HAR recordings in `tests/ui/integration/recordings/`; requests not present in a recording fall +through to the dev server proxy. + +The integration tests also run in CI (the `javascript-integration-tests` CircleCI job), with two retries +for any test that fails and traces/screenshots uploaded as build artifacts on failure. To run the tests: - If you haven't already done so, install local dependencies by running `pnpm install` from the project root. +- Install the Playwright browser once with `npx playwright install firefox`. - For unit tests run `pnpm test` to execute the tests. - For integration tests run `pnpm test:integration` to execute the tests. +#### Firefox notes + +The integration tests run against Firefox by default. A couple of things are worth knowing +about clipboard access under Firefox: + +- Granting clipboard permissions via `context.grantPermissions()` is a Chromium-only Playwright + API and throws on Firefox, so the logviewer copy test only requests those permissions when + `browserName === 'chromium'`. +- Copy/paste would not normally work in a stock headless Firefox build. Playwright ships its own + patched build of Firefox that permits clipboard writes in tests without an explicit grant, so + under Playwright's Firefox the copy test still works seamlessly. This is a non-issue in + practice, just a distinction worth knowing if you ever see clipboard behavior differ outside + of Playwright's bundled browser. + While working on the frontend, you may wish to watch JavaScript files and re-run the unit tests automatically when files change. To do this, you may run one of the following commands: @@ -114,3 +136,4 @@ There are a lot of taskid, revisions, and expected fields to update in tests. F [biome]: https://biomejs.dev [prettier]: https://prettier.io +[playwright]: https://playwright.dev diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js deleted file mode 100644 index f211eafe8bc..00000000000 --- a/jest-puppeteer.config.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - launch: { - headless: true, - }, - server: { - command: 'BROWSER=none pnpm start', - port: 5000, - launchTimeout: 30000, - usedPortAction: 'ignore', - }, -}; diff --git a/jest.integration.config.js b/jest.integration.config.js deleted file mode 100644 index 7ebe10a4460..00000000000 --- a/jest.integration.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - preset: 'jest-puppeteer', - testRegex: 'tests/ui/integration/.*(_test|_spec|\\.test|\\.spec)\\.(mjs|jsx|js)$', - testTimeout: 30000, - verbose: true, -}; diff --git a/package.json b/package.json index e733d2a2650..6de59271544 100644 --- a/package.json +++ b/package.json @@ -65,11 +65,7 @@ "@babel/preset-env": "7.26.9", "@babel/preset-react": "7.27.1", "@biomejs/biome": "2.3.15", - "@pollyjs/adapter-fetch": "6.0.7", - "@pollyjs/adapter-node-http": "6.0.6", - "@pollyjs/adapter-puppeteer": "6.0.6", - "@pollyjs/core": "6.0.6", - "@pollyjs/persister-fs": "6.0.6", + "@playwright/test": "1.62.0", "@rspack/cli": "1.6.8", "@rspack/core": "1.6.8", "@rspack/plugin-react-refresh": "1.5.3", @@ -86,13 +82,9 @@ "html-loader": "5.1.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-environment-puppeteer": "11.0.0", - "jest-puppeteer": "11.0.0", "markdownlint-cli": "0.43.0", - "puppeteer": "24.2.1", "sass": "1.101.7", "sass-loader": "16.0.8", - "setup-polly-jest": "0.11.0", "style-loader": "4.0.0", "webpack-merge": "6.0.1" }, @@ -112,7 +104,7 @@ "test": "jest --maxWorkers=50%", "test:coverage": "jest --maxWorkers=50% --coverage", "test:ci": "jest --maxWorkers=4 --coverage", - "test:integration": "jest --config jest.integration.config.js", + "test:integration": "playwright test", "test:watch": "jest --watch --maxWorkers=25%" }, "pnpm": { diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000000..06f7a4ce2ec --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,38 @@ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/ui/integration', + testMatch: '**/*.spec.js', + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI + ? [ + ['list'], + ['html', { open: 'never' }], + ['junit', { outputFile: 'test-results/junit.xml' }], + ] + : 'list', + + use: { + baseURL: 'http://localhost:5000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], + + webServer: { + command: 'BROWSER=none pnpm start', + port: 5000, + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0109560e6cc..ed184922555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,21 +169,9 @@ importers: '@biomejs/biome': specifier: 2.3.15 version: 2.3.15 - '@pollyjs/adapter-fetch': - specifier: 6.0.7 - version: 6.0.7 - '@pollyjs/adapter-node-http': - specifier: 6.0.6 - version: 6.0.6 - '@pollyjs/adapter-puppeteer': - specifier: 6.0.6 - version: 6.0.6 - '@pollyjs/core': - specifier: 6.0.6 - version: 6.0.6 - '@pollyjs/persister-fs': - specifier: 6.0.6 - version: 6.0.6 + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 '@rspack/cli': specifier: 1.6.8 version: 1.6.8(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@types/express@4.17.25)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.18))(postcss@8.5.6)) @@ -232,27 +220,15 @@ importers: jest-environment-jsdom: specifier: 29.7.0 version: 29.7.0 - jest-environment-puppeteer: - specifier: 11.0.0 - version: 11.0.0 - jest-puppeteer: - specifier: 11.0.0 - version: 11.0.0(puppeteer@24.2.1) markdownlint-cli: specifier: 0.43.0 version: 0.43.0 - puppeteer: - specifier: 24.2.1 - version: 24.2.1 sass: specifier: 1.101.7 version: 1.101.7 sass-loader: specifier: 16.0.8 version: 16.0.8(@rspack/core@1.6.8(@swc/helpers@0.5.18))(sass@1.101.7)(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.18))(postcss@8.5.6)) - setup-polly-jest: - specifier: 0.11.0 - version: 0.11.0(@pollyjs/core@6.0.6) style-loader: specifier: 4.0.0 version: 4.0.0(webpack@5.104.1(@swc/core@1.15.47(@swc/helpers@0.5.18))(postcss@8.5.6)) @@ -999,10 +975,6 @@ packages: '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@hapi/address@5.1.1': - resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} - engines: {node: '>=14.0.0'} - '@hapi/b64@5.0.0': resolution: {integrity: sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==} @@ -1013,25 +985,9 @@ packages: resolution: {integrity: sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==} engines: {node: '>=12.0.0'} - '@hapi/formula@3.0.2': - resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} - - '@hapi/hoek@11.0.7': - resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} - '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - '@hapi/pinpoint@2.0.1': - resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - - '@hapi/tlds@1.1.4': - resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} - engines: {node: '>=14.0.0'} - - '@hapi/topo@6.0.2': - resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1414,44 +1370,17 @@ packages: resolution: {integrity: sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@pollyjs/adapter-fetch@6.0.7': - resolution: {integrity: sha512-kv44DROx/2qzlcgS71EccGr2/I5nK40Xt92paGNI+1/Kmz290bw/ykt8cvXDg4O4xCc9Fh/jXeAkS7qwGpCx2g==} - - '@pollyjs/adapter-node-http@6.0.6': - resolution: {integrity: sha512-jdJG7oncmSHZAtVMmRgOxh5A56b7G8H9ULlk/ZaVJ+jNrlFXhLmPpx8OQoSF4Cuq2ugdiWmwmAjFXHStcpY3Mw==} - - '@pollyjs/adapter-puppeteer@6.0.6': - resolution: {integrity: sha512-N2axrR9NU3yE1obVcCml5WPN1vX7ACMoG+tU6yPzEM4FeeplkWnGTmGO5SkL6TjMZ+AihtkMmZhtY56lgqC7Ag==} - - '@pollyjs/adapter@6.0.6': - resolution: {integrity: sha512-szhys0NiFQqCJDMC0kpDyjhLqSI7aWc6m6iATCRKgcMcN/7QN85pb3GmRzvnNV8+/Bi2AUSCwxZljcsKhbYVWQ==} - - '@pollyjs/core@6.0.6': - resolution: {integrity: sha512-1ZZcmojW8iSFmvHGeLlvuudM3WiDV842FsVvtPAo3HoAYE6jCNveLHJ+X4qvonL4enj1SyTF3hXA107UkQFQrA==} - - '@pollyjs/node-server@6.0.6': - resolution: {integrity: sha512-nkP1+hdNoVOlrRz9R84haXVsaSmo8Xmq7uYK9GeUMSLQy4Fs55ZZ9o2KI6vRA8F6ZqJSbC31xxwwIoTkjyP7Vg==} - - '@pollyjs/persister-fs@6.0.6': - resolution: {integrity: sha512-/ALVgZiH2zGqwLkW0Mntc0Oq1v7tR8LS8JD2SAyIsHpnSXeBUnfPWwjAuYw0vqORHFVEbwned6MBRFfvU/3qng==} - - '@pollyjs/persister@6.0.6': - resolution: {integrity: sha512-9KB1p+frvYvFGur4ifzLnFKFLXAMXrhAhCnVhTnkG2WIqqQPT7y+mKBV/DKCmYFx8GPA9FiNGqt2pB53uJpIdw==} - - '@pollyjs/utils@6.0.6': - resolution: {integrity: sha512-nhVJoI3nRgRimE0V2DVSvsXXNROUH6iyJbroDu4IdsOIOFC1Ds0w+ANMB4NMwFaqE+AisWOmXFzwAGdAfyiQVg==} - '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@puppeteer/browsers@2.7.1': - resolution: {integrity: sha512-MK7rtm8JjaxPN7Mf1JdZIZKPD2Z+W7osvrC1vjpvfOX1K0awDIHYbNi89f7eotp7eMUn2shWnt03HwVbriXtKQ==} - engines: {node: '>=18'} - hasBin: true - '@react-aria/ssr@3.9.10': resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} engines: {node: '>= 12'} @@ -1579,19 +1508,12 @@ packages: '@sinclair/typebox@0.34.47': resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==} - '@sindresorhus/fnv1a@2.0.1': - resolution: {integrity: sha512-suq9tRQ6bkpMukTG5K5z0sPWB7t0zExMzZCdmYm6xTSSIm/yCKNm7VCL36wVeyTsFr597/UhU1OAYdHGMDiHrw==} - engines: {node: '>=10'} - '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/core-darwin-arm64@1.15.47': resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} engines: {node: '>=10'} @@ -1721,9 +1643,6 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1874,9 +1793,6 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/set-cookie-parser@2.4.10': - resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} - '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} @@ -1904,9 +1820,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2060,10 +1973,6 @@ packages: assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2071,17 +1980,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} - - b4a@1.7.3: - resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2132,44 +2030,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.5.2: - resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.6.2: - resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - - bare-stream@2.7.0: - resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} - peerDependencies: - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.3.2: - resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2177,15 +2037,6 @@ packages: resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==} hasBin: true - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} - engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.1, please upgrade - batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -2193,9 +2044,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - blueimp-md5@2.19.0: - resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} - bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -2214,9 +2062,6 @@ packages: peerDependencies: '@popperjs/core': ^2.11.8 - bowser@2.13.1: - resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2262,9 +2107,6 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2338,11 +2180,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - chromium-bidi@1.3.0: - resolution: {integrity: sha512-G3x1bkST13kmbL7+dT/oRkNH/7C4UqG+0YQpmySrzXspyOhYgDNc6lhSGpj3cuexvH25WTENhTYq2Tt9JRXtbw==} - peerDependencies: - devtools-protocol: '*' - ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -2462,28 +2299,6 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} - - cosmiconfig@8.3.6: - resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - create-ecdh@4.0.4: resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} @@ -2553,10 +2368,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cwd@0.10.0: - resolution: {integrity: sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA==} - engines: {node: '>=0.8'} - d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -2604,10 +2415,6 @@ packages: d3-voronoi@1.1.4: resolution: {integrity: sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -2681,10 +2488,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - delaunator@4.0.1: resolution: {integrity: sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==} @@ -2725,9 +2528,6 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - devtools-protocol@0.0.1402036: - resolution: {integrity: sha512-JwAYQgEvm3yD45CHB+RmF5kMbWtXBaOGwuxa87sZogHcLCv8c/IqnThaoQ1y60d7pXWjSKWQphPEc+1rAScVdg==} - diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2789,9 +2589,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} @@ -2804,10 +2601,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2894,9 +2687,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2916,14 +2706,6 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} - expand-tilde@1.2.2: - resolution: {integrity: sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q==} - engines: {node: '>=0.10.0'} - - expect-puppeteer@11.0.0: - resolution: {integrity: sha512-fgxsbOD+HqwOCMitYqEDzRoJM2fxKbCKPYfUoukK+qdZm/nC+cTOI74Au2MfmMZmF/5CgQGO4+1Ywq2GgD8zCQ==} - engines: {node: '>=18'} - expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2932,17 +2714,9 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2963,9 +2737,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fetch-mock@9.4.0: resolution: {integrity: sha512-tqnFmcjYheW5Z9zOPRVY+ZXjB/QWCYtPiOrYGEsPgKfpGHco97eaaj7Rv9MjK7PVWG4rWfv6t2IgQAzDQizBZA==} engines: {node: '>=4.0.0'} @@ -2987,18 +2758,6 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - find-file-up@0.1.3: - resolution: {integrity: sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A==} - engines: {node: '>=0.10.0'} - - find-pkg@0.1.2: - resolution: {integrity: sha512-0rnQWcFwZr7eO0513HahrWafsc3CTFioEB7DRiEYCUM/70QXSY8f3mCST17HXLcPvEhzH/Ty/Bxd72ZZsr/yvw==} - engines: {node: '>=0.10.0'} - - find-process@1.4.11: - resolution: {integrity: sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA==} - hasBin: true - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3039,17 +2798,14 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - fs-exists-sync@0.1.0: - resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} - engines: {node: '>=0.10.0'} - - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3086,18 +2842,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3121,14 +2869,6 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - global-modules@0.2.3: - resolution: {integrity: sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA==} - engines: {node: '>=0.10.0'} - - global-prefix@0.1.5: - resolution: {integrity: sha512-gOPiyxcD9dJGCEArAhF4Hd0BAqvAe/JzERP7tYumE4yIkmIedPUVXcJFWbV3/p/ovIIvKjkrTk+f1UVkq7vvbw==} - engines: {node: '>=0.10.0'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3185,10 +2925,6 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - homedir-polyfill@1.0.3: - resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} - engines: {node: '>=0.10.0'} - hotkeys-js@3.13.15: resolution: {integrity: sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg==} @@ -3227,10 +2963,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http-graceful-shutdown@3.1.15: - resolution: {integrity: sha512-7BzY5XxGV4g7QaOZ8xf5Hco8vJMMUFDl+AcB8AuOOdS1wTOZ88aV3EIADUnyAItj2QW+QfBPz08/PzxKiDKh6Q==} - engines: {node: '>=4.0.0'} - http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} @@ -3238,10 +2970,6 @@ packages: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - http-proxy-middleware@2.0.9: resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} engines: {node: '>=12.0.0'} @@ -3298,10 +3026,6 @@ packages: immutable@5.1.8: resolution: {integrity: sha512-TM5YqrGeTsVIPPpILzeqZ8D2Zc2TvNgSDi88zPF2a4cyqQdWV/wVWBDRDbNzzrLeRWScrFcOX9lW2iX6GOtUDw==} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -3325,9 +3049,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ini@4.1.3: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3339,10 +3060,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3351,10 +3068,6 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - is-absolute-url@3.0.3: - resolution: {integrity: sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==} - engines: {node: '>=8'} - is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -3442,10 +3155,6 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} - is-windows@0.2.0: - resolution: {integrity: sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q==} - engines: {node: '>=0.10.0'} - is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} @@ -3521,10 +3230,6 @@ packages: ts-node: optional: true - jest-dev-server@11.0.0: - resolution: {integrity: sha512-a54rw3uEzsPckyiXo2rPji9R/5z0d0qhXtru+NwCP8cDxOFk/BIP9PNgmcLh0DU8UTl8s6Lg1u+ri5uQsTJTmw==} - engines: {node: '>=18'} - jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3550,10 +3255,6 @@ packages: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-environment-puppeteer@11.0.0: - resolution: {integrity: sha512-BJR+k19/awJmXVc5IJ3VY+tho0888PvHAp16D+DP/ezRL84bgg4ggc1Q3mfa85DI+Nw9hgTme3pt0X5F7CWxmg==} - engines: {node: '>=18'} - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3587,12 +3288,6 @@ packages: jest-resolve: optional: true - jest-puppeteer@11.0.0: - resolution: {integrity: sha512-kixkUTNcXikldQ+TusIEvqtTO/et/MiXGkoUBQViPSdSN6JOPvTjDN/mo6Jh4EJzay8qFg/Sd4v4gPS0y9b+zw==} - engines: {node: '>=18'} - peerDependencies: - puppeteer: '>=19' - jest-regex-util@29.6.3: resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3651,10 +3346,6 @@ packages: node-notifier: optional: true - joi@18.0.2: - resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} - engines: {node: '>= 20'} - jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -3721,9 +3412,6 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonpointer@5.0.1: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} @@ -3760,9 +3448,6 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} - lodash-es@4.17.22: - resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} - lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -3776,10 +3461,6 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - loglevel@1.9.2: - resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} - engines: {node: '>= 0.6.0'} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3794,10 +3475,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -3932,9 +3609,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mobx-react-lite@4.1.1: resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==} peerDependencies: @@ -3964,10 +3638,6 @@ packages: mobx@6.13.7: resolution: {integrity: sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==} - morgan@1.10.1: - resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} - engines: {node: '>= 0.8.0'} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -4001,21 +3671,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - nocache@3.0.4: - resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} - engines: {node: '>=12.0.0'} - - nock@13.5.6: - resolution: {integrity: sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==} - engines: {node: '>= 10.13'} - node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -4101,10 +3759,6 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -4134,10 +3788,6 @@ packages: openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} - os-homedir@1.0.2: - resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} - engines: {node: '>=0.10.0'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -4158,14 +3808,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4175,10 +3817,6 @@ packages: param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - parse-asn1@5.1.9: resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} engines: {node: '>= 0.10'} @@ -4187,10 +3825,6 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parse-passwd@1.0.0: - resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} - engines: {node: '>=0.10.0'} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4229,17 +3863,10 @@ packages: path-to-regexp@2.4.0: resolution: {integrity: sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pbkdf2@3.1.5: resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} engines: {node: '>= 0.10'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - perfect-scrollbar@1.5.6: resolution: {integrity: sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==} @@ -4262,6 +3889,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -4332,10 +3969,6 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4348,30 +3981,16 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - propagate@2.0.1: - resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} - engines: {node: '>= 8'} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -4383,16 +4002,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@24.2.1: - resolution: {integrity: sha512-bCypUh3WXzETafv1TCFAjIUnI8BiQ/d+XvEfEXDLcIMm9CAvROqnBmbt79yBjwasoDZsgfXnUmIJU7Y27AalVQ==} - engines: {node: '>=18'} - - puppeteer@24.2.1: - resolution: {integrity: sha512-Euno62ou0cd0dTkOYTNioSOsFF4VpSnz4ldD38hi9ov9xCNtr8DbhmoJRUx+V9OuPgecueZbKOohRrnrhkbg3Q==} - engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported - hasBin: true - pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -4597,14 +4206,6 @@ packages: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} - resolve-dir@0.1.1: - resolution: {integrity: sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -4626,9 +4227,6 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} - route-recognizer@0.3.4: - resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==} - run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4637,9 +4235,6 @@ packages: resolution: {integrity: sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg==} hasBin: true - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -4731,11 +4326,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - setup-polly-jest@0.11.0: - resolution: {integrity: sha512-3ywsCFGfCvfi3ZpwYyDc4YDPNiB70QtjODoKFD5hbhza1GMOh0ZzAYUZO9OBmo/1isasynxcS5WzKYMyDJUeZw==} - peerDependencies: - '@pollyjs/core': '*' - sha.js@2.4.12: resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} engines: {node: '>= 0.10'} @@ -4816,14 +4406,6 @@ packages: resolution: {integrity: sha512-tf+h5W1IrjNm/9rKKj0JU2MDMruiopx0jjVA5zCdBtcGjfp0+c5rHw/zADLC3IeKlGHtVbHtpfzvYA0OYT+HKg==} engines: {node: '>=8.0.0'} - slugify@1.6.6: - resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} - engines: {node: '>=8.0.0'} - - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.3.4: resolution: {integrity: sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==} engines: {node: '>= 18'} @@ -4831,14 +4413,6 @@ packages: sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4853,10 +4427,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - spawnd@11.0.0: - resolution: {integrity: sha512-brBHv9HYi8lwNvbI7X52NDZe4yAdsQwvr81b/r98LaN82LzeEnQ0L6YXBvG25zhgWRadTwB+4GsUu9NrNQcVzw==} - engines: {node: '>=18'} - spdy-transport@3.0.0: resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} @@ -4892,9 +4462,6 @@ packages: stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} - strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -4975,12 +4542,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar-fs@3.1.1: - resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} - - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - taskcluster-client-web@87.1.3: resolution: {integrity: sha512-ZkqJarnOpiCKXMRY/A4iCt8YMqpls+bxB5oDHKECMH3pt/33u5YHV5EiGNRbBM2fpchxBZz/xvTdtOwUJIlepQ==} @@ -5048,9 +4609,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} - thingies@2.5.0: resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} engines: {node: '>=10.18'} @@ -5067,9 +4625,6 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-arraybuffer@1.0.1: - resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} - to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -5106,10 +4661,6 @@ packages: peerDependencies: tslib: '2' - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} @@ -5132,9 +4683,6 @@ packages: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} - typed-query-selector@2.12.0: - resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} - uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -5177,10 +4725,6 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -5206,9 +4750,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utf8-byte-length@1.0.5: - resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5410,11 +4951,6 @@ packages: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} - wait-on@8.0.5: - resolution: {integrity: sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==} - engines: {node: '>=12.0.0'} - hasBin: true - walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -5514,10 +5050,6 @@ packages: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5594,16 +5126,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -6502,10 +6028,6 @@ snapshots: prop-types: 15.8.1 react: 19.2.7 - '@hapi/address@5.1.1': - dependencies: - '@hapi/hoek': 11.0.7 - '@hapi/b64@5.0.0': dependencies: '@hapi/hoek': 9.3.0 @@ -6518,20 +6040,8 @@ snapshots: dependencies: '@hapi/boom': 9.1.4 - '@hapi/formula@3.0.2': {} - - '@hapi/hoek@11.0.7': {} - '@hapi/hoek@9.3.0': {} - '@hapi/pinpoint@2.0.1': {} - - '@hapi/tlds@1.1.4': {} - - '@hapi/topo@6.0.2': - dependencies: - '@hapi/hoek': 11.0.7 - '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.1': @@ -6990,125 +6500,43 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.4 optional: true + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@polka/url@1.0.0-next.29': {} - '@pollyjs/adapter-fetch@6.0.7': - dependencies: - '@pollyjs/adapter': 6.0.6 - '@pollyjs/utils': 6.0.6 - to-arraybuffer: 1.0.1 + '@popperjs/core@2.11.8': {} - '@pollyjs/adapter-node-http@6.0.6': + '@react-aria/ssr@3.9.10(react@19.2.7)': dependencies: - '@pollyjs/adapter': 6.0.6 - '@pollyjs/utils': 6.0.6 - lodash-es: 4.17.22 - nock: 13.5.6 - transitivePeerDependencies: - - supports-color + '@swc/helpers': 0.5.18 + react: 19.2.7 - '@pollyjs/adapter-puppeteer@6.0.6': + '@redocly/ajv@8.17.1': dependencies: - '@pollyjs/adapter': 6.0.6 - '@pollyjs/utils': 6.0.6 + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 - '@pollyjs/adapter@6.0.6': - dependencies: - '@pollyjs/utils': 6.0.6 + '@redocly/config@0.22.2': {} - '@pollyjs/core@6.0.6': + '@redocly/openapi-core@1.34.6': dependencies: - '@pollyjs/utils': 6.0.6 - '@sindresorhus/fnv1a': 2.0.1 - blueimp-md5: 2.19.0 - fast-json-stable-stringify: 2.1.0 - is-absolute-url: 3.0.3 - lodash-es: 4.17.22 - loglevel: 1.9.2 - route-recognizer: 0.3.4 - slugify: 1.6.6 + '@redocly/ajv': 8.17.1 + '@redocly/config': 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6 + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + minimatch: 5.1.6 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color - '@pollyjs/node-server@6.0.6': - dependencies: - '@pollyjs/utils': 6.0.6 - body-parser: 1.20.4 - cors: 2.8.5 - express: 4.22.1 - fs-extra: 10.1.0 - http-graceful-shutdown: 3.1.15 - morgan: 1.10.1 - nocache: 3.0.4 - transitivePeerDependencies: - - supports-color - - '@pollyjs/persister-fs@6.0.6': - dependencies: - '@pollyjs/node-server': 6.0.6 - '@pollyjs/persister': 6.0.6 - transitivePeerDependencies: - - supports-color - - '@pollyjs/persister@6.0.6': - dependencies: - '@pollyjs/utils': 6.0.6 - '@types/set-cookie-parser': 2.4.10 - bowser: 2.13.1 - fast-json-stable-stringify: 2.1.0 - lodash-es: 4.17.22 - set-cookie-parser: 2.7.2 - utf8-byte-length: 1.0.5 - - '@pollyjs/utils@6.0.6': - dependencies: - qs: 6.14.1 - url-parse: 1.5.10 - - '@popperjs/core@2.11.8': {} - - '@puppeteer/browsers@2.7.1': - dependencies: - debug: 4.4.3 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.5.0 - semver: 7.7.3 - tar-fs: 3.1.1 - yargs: 17.7.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - - '@react-aria/ssr@3.9.10(react@19.2.7)': - dependencies: - '@swc/helpers': 0.5.18 - react: 19.2.7 - - '@redocly/ajv@8.17.1': - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - '@redocly/config@0.22.2': {} - - '@redocly/openapi-core@1.34.6': - dependencies: - '@redocly/ajv': 8.17.1 - '@redocly/config': 0.22.2 - colorette: 1.4.0 - https-proxy-agent: 7.0.6 - js-levenshtein: 1.1.6 - js-yaml: 4.3.1 - minimatch: 5.1.6 - pluralize: 8.0.0 - yaml-ast-parser: 0.0.43 - transitivePeerDependencies: - - supports-color - - '@restart/hooks@0.4.16(react@19.2.7)': + '@restart/hooks@0.4.16(react@19.2.7)': dependencies: dequal: 2.0.3 react: 19.2.7 @@ -7230,8 +6658,6 @@ snapshots: '@sinclair/typebox@0.34.47': {} - '@sindresorhus/fnv1a@2.0.1': {} - '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -7240,8 +6666,6 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@standard-schema/spec@1.1.0': {} - '@swc/core-darwin-arm64@1.15.47': optional: true @@ -7346,8 +6770,6 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -7530,10 +6952,6 @@ snapshots: '@types/node': 25.0.9 '@types/send': 0.17.6 - '@types/set-cookie-parser@2.4.10': - dependencies: - '@types/node': 25.0.9 - '@types/sockjs@0.3.36': dependencies: '@types/node': 25.0.9 @@ -7559,11 +6977,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.0.9 - optional: true - '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -7739,26 +7152,12 @@ snapshots: object.assign: 4.1.7 util: 0.12.5 - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - asynckit@0.4.0: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - axios@1.13.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - b4a@1.7.3: {} - babel-jest@29.7.0(@babel/core@7.26.10): dependencies: '@babel/core': 7.26.10 @@ -7847,59 +7246,14 @@ snapshots: balanced-match@4.0.4: {} - bare-events@2.8.2: {} - - bare-fs@4.5.2: - dependencies: - bare-events: 2.8.2 - bare-path: 3.0.0 - bare-stream: 2.7.0(bare-events@2.8.2) - bare-url: 2.3.2 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - optional: true - - bare-os@3.6.2: - optional: true - - bare-path@3.0.0: - dependencies: - bare-os: 3.6.2 - optional: true - - bare-stream@2.7.0(bare-events@2.8.2): - dependencies: - streamx: 2.23.0 - optionalDependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - optional: true - - bare-url@2.3.2: - dependencies: - bare-path: 3.0.0 - optional: true - base64-js@1.5.1: {} baseline-browser-mapping@2.9.15: {} - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - basic-ftp@5.1.0: {} - batch@0.6.1: {} binary-extensions@2.3.0: {} - blueimp-md5@2.19.0: {} - bn.js@4.12.2: {} bn.js@5.2.2: {} @@ -7930,8 +7284,6 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 - bowser@2.13.1: {} - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -8007,8 +7359,6 @@ snapshots: dependencies: node-int64: 0.4.0 - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} buffer-xor@1.0.3: {} @@ -8083,12 +7433,6 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@1.3.0(devtools-protocol@0.0.1402036): - dependencies: - devtools-protocol: 0.0.1402036 - mitt: 3.0.1 - zod: 3.25.76 - ci-info@3.9.0: {} cipher-base@1.0.7: @@ -8189,25 +7533,6 @@ snapshots: core-util-is@1.0.3: {} - cors@2.8.5: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cosmiconfig@8.3.6: - dependencies: - import-fresh: 3.3.1 - js-yaml: 4.3.1 - parse-json: 5.2.0 - path-type: 4.0.0 - - cosmiconfig@9.0.0: - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.3.1 - parse-json: 5.2.0 - create-ecdh@4.0.4: dependencies: bn.js: 4.12.2 @@ -8306,11 +7631,6 @@ snapshots: csstype@3.2.3: {} - cwd@0.10.0: - dependencies: - find-pkg: 0.1.2 - fs-exists-sync: 0.1.0 - d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -8351,8 +7671,6 @@ snapshots: d3-voronoi@1.1.4: {} - data-uri-to-buffer@6.0.2: {} - data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -8404,12 +7722,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - delaunator@4.0.1: {} delaunay-find@0.0.6: @@ -8438,8 +7750,6 @@ snapshots: detect-node@2.1.0: {} - devtools-protocol@0.0.1402036: {} - diff-sequences@29.6.3: {} diffie-hellman@5.0.3: @@ -8504,10 +7814,6 @@ snapshots: encodeurl@2.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 @@ -8517,8 +7823,6 @@ snapshots: entities@6.0.1: {} - env-paths@2.2.1: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -8587,12 +7891,6 @@ snapshots: eventemitter3@5.0.1: {} - events-universal@1.0.1: - dependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - events@3.3.0: {} evp_bytestokey@1.0.3: @@ -8616,12 +7914,6 @@ snapshots: exit@0.1.2: {} - expand-tilde@1.2.2: - dependencies: - os-homedir: 1.0.2 - - expect-puppeteer@11.0.0: {} - expect@29.7.0: dependencies: '@jest/expect-utils': 29.7.0 @@ -8666,20 +7958,8 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-json-stable-stringify@2.1.0: {} fast-safe-stringify@2.1.1: {} @@ -8698,10 +7978,6 @@ snapshots: dependencies: bser: 2.1.1 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fetch-mock@9.4.0(node-fetch@2.7.0): dependencies: babel-runtime: 6.26.0 @@ -8736,21 +8012,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-file-up@0.1.3: - dependencies: - fs-exists-sync: 0.1.0 - resolve-dir: 0.1.1 - - find-pkg@0.1.2: - dependencies: - find-file-up: 0.1.3 - - find-process@1.4.11: - dependencies: - chalk: 4.1.2 - commander: 12.1.0 - loglevel: 1.9.2 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -8783,16 +8044,11 @@ snapshots: fresh@0.5.2: {} - fs-exists-sync@0.1.0: {} - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8826,20 +8082,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@5.2.0: - dependencies: - pump: 3.0.3 - get-stream@6.0.1: {} - get-uri@6.0.5: - dependencies: - basic-ftp: 5.1.0 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8868,18 +8112,6 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - global-modules@0.2.3: - dependencies: - global-prefix: 0.1.5 - is-windows: 0.2.0 - - global-prefix@0.1.5: - dependencies: - homedir-polyfill: 1.0.3 - ini: 1.3.8 - is-windows: 0.2.0 - which: 1.3.1 - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -8942,10 +8174,6 @@ snapshots: dependencies: react-is: 16.13.1 - homedir-polyfill@1.0.3: - dependencies: - parse-passwd: 1.0.0 - hotkeys-js@3.13.15: {} hpack.js@2.1.6: @@ -8996,12 +8224,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-graceful-shutdown@3.1.15: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - http-parser-js@0.5.10: {} http-proxy-agent@5.0.0: @@ -9012,13 +8234,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - http-proxy-middleware@2.0.9(@types/express@4.17.25): dependencies: '@types/http-proxy': 1.17.17 @@ -9077,11 +8292,6 @@ snapshots: immutable@5.1.8: {} - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -9100,8 +8310,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - ini@4.1.3: {} internmap@2.0.3: {} @@ -9110,14 +8318,10 @@ snapshots: dependencies: loose-envify: 1.4.0 - ip-address@10.1.0: {} - ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} - is-absolute-url@3.0.3: {} - is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -9191,8 +8395,6 @@ snapshots: dependencies: which-typed-array: 1.1.20 - is-windows@0.2.0: {} - is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -9331,18 +8533,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-dev-server@11.0.0: - dependencies: - chalk: 4.1.2 - cwd: 0.10.0 - find-process: 1.4.11 - prompts: 2.4.2 - spawnd: 11.0.0 - tree-kill: 1.2.2 - wait-on: 8.0.5 - transitivePeerDependencies: - - debug - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -9386,17 +8576,6 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-environment-puppeteer@11.0.0: - dependencies: - chalk: 4.1.2 - cosmiconfig: 8.3.6 - deepmerge: 4.3.1 - jest-dev-server: 11.0.0 - jest-environment-node: 29.7.0 - transitivePeerDependencies: - - debug - - typescript - jest-get-type@29.6.3: {} jest-haste-map@29.7.0: @@ -9449,15 +8628,6 @@ snapshots: optionalDependencies: jest-resolve: 29.7.0 - jest-puppeteer@11.0.0(puppeteer@24.2.1): - dependencies: - expect-puppeteer: 11.0.0 - jest-environment-puppeteer: 11.0.0 - puppeteer: 24.2.1 - transitivePeerDependencies: - - debug - - typescript - jest-regex-util@29.6.3: {} jest-regex-util@30.0.1: {} @@ -9613,16 +8783,6 @@ snapshots: - supports-color - ts-node - joi@18.0.2: - dependencies: - '@hapi/address': 5.1.1 - '@hapi/formula': 3.0.2 - '@hapi/hoek': 11.0.7 - '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.4 - '@hapi/topo': 6.0.2 - '@standard-schema/spec': 1.1.0 - jose@6.2.2: {} js-cookie@3.0.8: {} @@ -9699,12 +8859,6 @@ snapshots: jsonc-parser@3.3.1: {} - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - jsonpointer@5.0.1: {} kind-of@6.0.3: {} @@ -9734,8 +8888,6 @@ snapshots: dependencies: p-locate: 4.1.0 - lodash-es@4.17.22: {} - lodash.debounce@4.0.8: {} lodash.isequal@4.5.0: {} @@ -9744,8 +8896,6 @@ snapshots: lodash@4.17.23: {} - loglevel@1.9.2: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -9760,8 +8910,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - lunr@2.3.9: {} lz-string@1.5.0: {} @@ -9888,8 +9036,6 @@ snapshots: minipass@7.1.3: {} - mitt@3.0.1: {} - mobx-react-lite@4.1.1(mobx@6.13.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: mobx: 6.13.7 @@ -9908,16 +9054,6 @@ snapshots: mobx@6.13.7: {} - morgan@1.10.1: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color - mrmime@2.0.1: {} ms@2.0.0: {} @@ -9939,23 +9075,11 @@ snapshots: neo-async@2.6.2: {} - netmask@2.0.2: {} - no-case@3.0.4: dependencies: lower-case: 2.0.2 tslib: 2.8.1 - nocache@3.0.4: {} - - nock@13.5.6: - dependencies: - debug: 4.4.3 - json-stringify-safe: 5.0.1 - propagate: 2.0.1 - transitivePeerDependencies: - - supports-color - node-addon-api@7.1.1: optional: true @@ -10042,10 +9166,6 @@ snapshots: obuf@1.1.2: {} - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -10080,8 +9200,6 @@ snapshots: jose: 6.2.2 oauth4webapi: 3.8.5 - os-homedir@1.0.2: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -10102,24 +9220,6 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.1: {} pako@2.1.0: {} @@ -10129,10 +9229,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - parse-asn1@5.1.9: dependencies: asn1.js: 4.10.1 @@ -10148,8 +9244,6 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-passwd@1.0.0: {} - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -10180,8 +9274,6 @@ snapshots: path-to-regexp@2.4.0: {} - path-type@4.0.0: {} - pbkdf2@3.1.5: dependencies: create-hash: 1.2.0 @@ -10191,8 +9283,6 @@ snapshots: sha.js: 2.4.12 to-buffer: 1.2.2 - pend@1.2.0: {} - perfect-scrollbar@1.5.6: {} picocolors@1.1.1: {} @@ -10208,6 +9298,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} polished@4.3.1: @@ -10274,8 +9372,6 @@ snapshots: process@0.11.10: {} - progress@2.0.3: {} - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -10293,28 +9389,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - propagate@2.0.1: {} - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.5.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - psl@1.15.0: dependencies: punycode: 2.3.1 @@ -10328,50 +9407,12 @@ snapshots: randombytes: 2.1.0 safe-buffer: 5.2.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode.js@2.3.1: {} punycode@1.4.1: {} punycode@2.3.1: {} - puppeteer-core@24.2.1: - dependencies: - '@puppeteer/browsers': 2.7.1 - chromium-bidi: 1.3.0(devtools-protocol@0.0.1402036) - debug: 4.4.3 - devtools-protocol: 0.0.1402036 - typed-query-selector: 2.12.0 - ws: 8.19.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - - puppeteer@24.2.1: - dependencies: - '@puppeteer/browsers': 2.7.1 - chromium-bidi: 1.3.0(devtools-protocol@0.0.1402036) - cosmiconfig: 9.0.0 - devtools-protocol: 0.0.1402036 - puppeteer-core: 24.2.1 - typed-query-selector: 2.12.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - typescript - - utf-8-validate - pure-rand@6.1.0: {} qs@6.14.1: @@ -10607,13 +9648,6 @@ snapshots: dependencies: resolve-from: 5.0.0 - resolve-dir@0.1.1: - dependencies: - expand-tilde: 1.2.2 - global-modules: 0.2.3 - - resolve-from@4.0.0: {} - resolve-from@5.0.0: {} resolve.exports@2.0.3: {} @@ -10631,8 +9665,6 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - route-recognizer@0.3.4: {} - run-applescript@7.1.0: {} run-con@1.3.2: @@ -10642,10 +9674,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 3.1.1 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -10752,10 +9780,6 @@ snapshots: setprototypeof@1.2.0: {} - setup-polly-jest@0.11.0(@pollyjs/core@6.0.6): - dependencies: - '@pollyjs/core': 6.0.6 - sha.js@2.4.12: dependencies: inherits: 2.0.4 @@ -10846,10 +9870,6 @@ snapshots: slugify@1.4.7: {} - slugify@1.6.6: {} - - smart-buffer@4.2.0: {} - smol-toml@1.3.4: {} sockjs@0.3.24: @@ -10858,19 +9878,6 @@ snapshots: uuid: 8.3.2 websocket-driver: 0.7.4 - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -10885,11 +9892,6 @@ snapshots: source-map@0.6.1: {} - spawnd@11.0.0: - dependencies: - signal-exit: 4.1.0 - tree-kill: 1.2.2 - spdy-transport@3.0.0: dependencies: debug: 4.4.3 @@ -10932,15 +9934,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - streamx@2.23.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.3 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - strict-uri-encode@2.0.0: {} string-length@4.0.2: @@ -11028,27 +10021,6 @@ snapshots: tapable@2.3.3: {} - tar-fs@3.1.1: - dependencies: - pump: 3.0.3 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 4.5.2 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar-stream@3.1.7: - dependencies: - b4a: 1.7.3 - fast-fifo: 1.3.2 - streamx: 2.23.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - taskcluster-client-web@87.1.3: dependencies: crypto-js: 4.2.0 @@ -11093,12 +10065,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 - text-decoder@1.2.3: - dependencies: - b4a: 1.7.3 - transitivePeerDependencies: - - react-native-b4a - thingies@2.5.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -11109,8 +10075,6 @@ snapshots: tmpl@1.0.5: {} - to-arraybuffer@1.0.1: {} - to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -11146,8 +10110,6 @@ snapshots: dependencies: tslib: 2.8.1 - tree-kill@1.2.2: {} - tslib@2.6.2: {} tslib@2.8.1: {} @@ -11167,8 +10129,6 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.15 - typed-query-selector@2.12.0: {} - uc.micro@1.0.6: {} uc.micro@2.1.0: {} @@ -11202,8 +10162,6 @@ snapshots: universalify@0.2.0: {} - universalify@2.0.1: {} - unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -11228,8 +10186,6 @@ snapshots: dependencies: react: 19.2.7 - utf8-byte-length@1.0.5: {} - util-deprecate@1.0.2: {} util@0.12.5: @@ -11499,16 +10455,6 @@ snapshots: dependencies: xml-name-validator: 4.0.0 - wait-on@8.0.5: - dependencies: - axios: 1.13.2 - joi: 18.0.2 - lodash: 4.17.23 - minimist: 1.2.8 - rxjs: 7.8.2 - transitivePeerDependencies: - - debug - walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -11687,10 +10633,6 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 - which@1.3.1: - dependencies: - isexe: 2.0.0 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -11742,15 +10684,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} - zod@3.25.76: {} - zustand@5.0.15(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 diff --git a/tests/ui/integration/graphs-view/graphs_view.spec.js b/tests/ui/integration/graphs-view/graphs_view.spec.js new file mode 100644 index 00000000000..01c0932370c --- /dev/null +++ b/tests/ui/integration/graphs-view/graphs_view.spec.js @@ -0,0 +1,96 @@ +/** + * Integration tests for the Perfherder Graphs view. + * + * API responses are replayed from the HAR recordings in + * tests/ui/integration/recordings/ (originally captured with Polly.js). + * Requests not present in the recordings fall through to the dev server, + * which proxies /api to the configured backend. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { test, expect } = require('@playwright/test'); + +const RECORDINGS_DIR = path.resolve( + __dirname, + '../recordings/GraphsViewRecord-Test-Pupeteer_1324652544', +); + +test.describe('Graphs View', () => { + test('Add test data modal lists the recorded frameworks', async ({ + page, + }) => { + await page.routeFromHAR( + path.join(RECORDINGS_DIR, 'Record-requests_2171382282/recording.har'), + { url: '**/api/**', notFound: 'fallback' }, + ); + + await page.goto('/perfherder/graphs'); + + // Open the Add Test Data modal + await page.locator('button[title="Add test data"]').click(); + + // Open the Framework dropdown inside the modal and count its items + const frameworkDropdown = page.locator('div[title="Framework"]'); + await frameworkDropdown.locator('button').click(); + + await expect(frameworkDropdown.locator('a.dropdown-item')).toHaveCount(9); + }); + + test('Clicking on Table View / Graphs view button should toggle between views', async ({ + page, + }) => { + const harPath = path.join( + RECORDINGS_DIR, + 'Clicking-on-Table-View_3574591457/Graphs-view-button-should-toggle-between-views_4072224546/recording.har', + ); + + await page.routeFromHAR(harPath, { + url: '**/api/**', + notFound: 'fallback', + }); + + // The performance/summary query params have changed since the HAR was + // recorded (e.g. the replicates param was added), so exact-URL HAR + // matching misses them. Serve those responses by signature instead. + // Registered after routeFromHAR, so this route takes precedence. + const har = JSON.parse(fs.readFileSync(harPath, 'utf8')); + await page.route('**/api/performance/summary/**', (route) => { + const signature = new URL(route.request().url()).searchParams.get( + 'signature', + ); + const entry = har.log.entries.find( + (e) => + e.request.url.includes('/api/performance/summary/') && + e.request.url.includes(`signature=${signature}&`), + ); + if (entry) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: entry.response.content.text, + }); + } + return route.fallback(); + }); + + await page.goto( + '/perfherder/graphs?highlightAlerts=1&highlightChangelogData=1&highlightCommonAlerts=0&series=mozilla-central,3140832,1,1&series=mozilla-central,3140831,1,1&timerange=86400', + ); + + const toggleButton = page.locator( + 'button[title="Toggle between table view and graphs view"]', + ); + + await expect(toggleButton).toContainText('Table View'); + + // Wait for pending data fetches to settle; the loading overlay + // intercepts pointer events while present. + await expect(page.locator('.loading')).toHaveCount(0); + + await toggleButton.click(); + + await expect(toggleButton).toContainText('Graphs View'); + }); +}); diff --git a/tests/ui/integration/graphs-view/graphs_view_integration_test.jsx b/tests/ui/integration/graphs-view/graphs_view_integration_test.jsx deleted file mode 100644 index cc2804554a8..00000000000 --- a/tests/ui/integration/graphs-view/graphs_view_integration_test.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import path from 'node:path'; - -import { Polly } from '@pollyjs/core'; -import PuppeteerAdapter from '@pollyjs/adapter-puppeteer'; -import FsPersister from '@pollyjs/persister-fs'; -import { setupPolly } from 'setup-polly-jest'; - -Polly.register(PuppeteerAdapter); -Polly.register(FsPersister); - -describe('GraphsViewRecord Test Pupeteer', () => { - const context = setupPolly({ - adapters: ['puppeteer'], - adapterOptions: { - puppeteer: { page }, - }, - persister: 'fs', - persisterOptions: { - fs: { - recordingsDir: path.resolve(__dirname, '../recordings'), - }, - }, - recordIfMissing: true, - matchRequestsBy: { - headers: { - exclude: ['user-agent'], - }, - }, - }); - - beforeEach(async () => { - jest.setTimeout(60000); - - await page.setRequestInterception(true); - await page.setDefaultNavigationTimeout(3000); - await page.goto(`${URL}/perfherder/graphs`); - }); - - test('Record requests', async () => { - expect(context.polly).not.toBeNull(); - - // Set selector Add test data - const addTestDataSelector = 'button[title="Add test data"]'; - - // Wait for selector to appear in the page - await page.waitForSelector(addTestDataSelector); - - // Click button Add test data - await page.click(addTestDataSelector, { clickCount: 1 }); - - // Check details from Add Test Data Modal - await page.waitForSelector('div[title="Framework"]'); - - const frameworks = await page.$$eval( - 'div[title="Framework"] a.dropdown-item', - (element) => element.length, - ); - - expect(frameworks).toBe(9); - - // Wait for all requests to resolve - await context.polly.flush(); - }); - - test('Clicking on Table View / Graphs view button should toggle between views', async () => { - expect(context.polly).not.toBeNull(); - - await page.goto( - `${URL}/perfherder/graphs?highlightAlerts=1&highlightChangelogData=1&highlightCommonAlerts=0&series=mozilla-central,3140832,1,1&series=mozilla-central,3140831,1,1&timerange=86400`, - ); - - const toggleButton = - 'button[title="Toggle between table view and graphs view"]'; - - await page.waitForSelector(toggleButton); - - const toggleButtonText = await page.$eval( - toggleButton, - (element) => element.innerText, - ); - - expect(toggleButtonText).toBe('Table View'); - - await page.click(toggleButton, { clickCount: 1 }); - - await page.waitForSelector(toggleButton); - - const toggleButtonTextAfterClick = await page.$eval( - toggleButton, - (element) => element.innerText, - ); - - expect(toggleButtonTextAfterClick).toBe('Graphs View'); - - await context.polly.flush(); - }); -}); diff --git a/tests/ui/integration/job-view/jobs_view.spec.js b/tests/ui/integration/job-view/jobs_view.spec.js new file mode 100644 index 00000000000..f08d3fc0b65 --- /dev/null +++ b/tests/ui/integration/job-view/jobs_view.spec.js @@ -0,0 +1,176 @@ +/** + * Integration tests for the Jobs view: rendering the push list, + * selecting a job, viewing the details panel, and quick filtering. + * + * API responses are served from the JSON fixtures in tests/ui/mock/ + * (the same fixtures the Jest unit tests use), so the tests are + * deterministic and independent of any backend. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { test, expect } = require('@playwright/test'); + +const MOCK_DIR = path.resolve(__dirname, '../../mock'); +const loadFixture = (file) => + JSON.parse(fs.readFileSync(path.join(MOCK_DIR, file), 'utf8')); + +const repositories = loadFixture('repositories.json'); +const pushList = loadFixture('push_list.json'); +const jobList = loadFixture('job_list/job_1.json'); +const taskDefinition = loadFixture('task_definition.json'); + +// The job list endpoint returns rows of values keyed by job_property_names; +// zip them into objects for the /jobs/{id}/ detail endpoint. +const jobsById = new Map( + jobList.results.map((row) => { + const job = Object.fromEntries( + jobList.job_property_names.map((name, i) => [name, row[i]]), + ); + return [job.id, job]; + }), +); + +// The busted build job on the first push in push_list.json. +const BUILD_JOB = [...jobsById.values()].find( + (job) => job.job_type_symbol === 'B', +); + +const json = (body) => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +async function mockJobsViewApi(page) { + await page.route('**/revision.txt', (route) => + route.fulfill({ status: 200, contentType: 'text/plain', body: 'abc123' }), + ); + await page.route('**/api/repository/', (route) => + route.fulfill(json(repositories)), + ); + await page.route('**/api/user/', (route) => route.fulfill(json([]))); + await page.route('**/api/failureclassification/', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/performance/framework/', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/performance/tag/', (route) => + route.fulfill(json([])), + ); + + // Initial push list; polling and other push queries get empty results. + await page.route('**/api/project/autoland/push/**', (route) => { + const url = new URL(route.request().url()); + if (url.searchParams.get('count') === '10') { + return route.fulfill(json(pushList)); + } + return route.fulfill(json({ results: [] })); + }); + + // Job list per push. + await page.route('**/api/jobs/**', (route) => route.fulfill(json(jobList))); + + // Details panel endpoints for the selected job. + await page.route('**/api/project/autoland/jobs/**', (route) => { + const { pathname } = new URL(route.request().url()); + if ( + pathname.endsWith('/text_log_errors/') || + pathname.endsWith('/bug_suggestions/') + ) { + return route.fulfill(json([])); + } + const match = pathname.match(/\/jobs\/(\d+)\/$/); + const job = match && jobsById.get(Number(match[1])); + if (job) { + return route.fulfill(json(job)); + } + return route.fulfill(json([])); + }); + await page.route('**/api/project/autoland/note/**', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/project/autoland/bug-job-map/**', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/project/autoland/performance/job-data/**', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/project/autoland/job-log-url/**', (route) => + route.fulfill(json([])), + ); + + // External services. + await page.route( + 'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/**', + (route) => + route.fulfill( + json({ result: { status: 'open', reason: '', tree: 'autoland' } }), + ), + ); + await page.route('https://firefox-ci-tc.services.mozilla.com/**', (route) => { + const { pathname } = new URL(route.request().url()); + if (pathname.endsWith('/artifacts')) { + return route.fulfill(json({ artifacts: [] })); + } + if (pathname.includes(`/api/queue/v1/task/${BUILD_JOB.task_id}`)) { + return route.fulfill(json(taskDefinition)); + } + return route.fulfill({ status: 404, body: '' }); + }); + await page.route('https://bugzilla.mozilla.org/rest/bug**', (route) => + route.fulfill(json({ bugs: [] })), + ); +} + +test.describe('Jobs View', () => { + test.beforeEach(async ({ page }) => { + await mockJobsViewApi(page); + await page.goto('/jobs?repo=autoland'); + }); + + test('renders the push list with job buttons', async ({ page }) => { + await expect(page.getByTestId('push-header').first()).toBeVisible(); + + await expect( + page.getByTestId('job-btn').filter({ hasText: 'B' }).first(), + ).toBeVisible(); + }); + + test('selecting a job shows the details panel', async ({ page }) => { + const buildJob = page + .getByTestId('job-btn') + .filter({ hasText: 'B' }) + .first(); + await buildJob.click(); + + await expect(page.locator('.job-btn.selected-job').first()).toBeVisible(); + await expect(page).toHaveURL( + new RegExp(`selectedTaskRun=${BUILD_JOB.task_id}`), + ); + + const detailsPanel = page.locator('#details-panel'); + await expect(detailsPanel).toBeVisible(); + await expect(detailsPanel).toContainText(BUILD_JOB.job_type_name); + }); + + test('quick filter narrows the displayed jobs', async ({ page }) => { + const buildJobs = page.getByTestId('job-btn').filter({ hasText: 'B' }); + const yamlJobs = page.getByTestId('job-btn').filter({ hasText: 'yaml' }); + + await expect(buildJobs.first()).toBeVisible(); + await expect(yamlJobs.first()).toBeVisible(); + + const quickFilter = page.locator('#quick-filter'); + await quickFilter.fill('yaml'); + await quickFilter.press('Enter'); + + await expect(page).toHaveURL(/searchStr=yaml/); + + // Non-matching jobs are removed from the push list; matching ones remain. + await expect(buildJobs).toHaveCount(0); + await expect(yamlJobs.first()).toBeVisible(); + }); +}); diff --git a/tests/ui/integration/logviewer/logviewer.spec.js b/tests/ui/integration/logviewer/logviewer.spec.js new file mode 100644 index 00000000000..75822886e41 --- /dev/null +++ b/tests/ui/integration/logviewer/logviewer.spec.js @@ -0,0 +1,334 @@ +/** + * Integration tests for the Logviewer page using the custom log viewer + * (ClassicLogViewer + react-virtuoso). + * + * Uses addInitScript to mock fetch() at the browser JS level, + * providing deterministic responses without depending on external services. + */ + +const { test, expect } = require('@playwright/test'); + +const MOCK_LOG_LINES = Array.from( + { length: 200 }, + (_, i) => + `[taskcluster 2025-01-01T00:00:00.000Z] Line ${i + 1}: sample log output for testing purposes`, +); + +const MOCK_JOB = { + id: 12345, + push_id: 100, + task_id: 'mock-task-id-abc123', + retry_id: 0, + result: 'testfailed', + state: 'completed', + job_group_name: 'Mochitests', + platform: 'linux64', + searchStr: 'mock test job', + logs: [ + { + name: 'live_backing_log', + url: 'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/mock-task-id-abc123/runs/0/artifacts/public/logs/live_backing.log', + }, + ], +}; + +const MOCK_REPOS = [ + { + id: 77, + name: 'autoland', + dvcs_type: 'hg', + url: 'https://hg.mozilla.org/integration/autoland', + tc_root_url: 'https://firefox-ci-tc.services.mozilla.com', + active_status: 'active', + }, +]; + +const LOG_URL = + '/logviewer?job_id=12345&repo=autoland&task=mock-task-id-abc123.0'; + +const TOOLBAR_LABEL = '.classic-log-toolbar-label'; +const COPY_BUTTON = 'button[title="Copy selected lines to clipboard"]'; + +/** + * Build a script that mocks window.fetch before the app loads. + * Uses a global key so each new mock replaces the previous one. + */ +function buildFetchMockScript(mockLogText, mockErrors) { + const mockJob = JSON.stringify(MOCK_JOB); + const mockRepos = JSON.stringify(MOCK_REPOS); + const mockLogTextJson = JSON.stringify(mockLogText); + const mockErrorsJson = JSON.stringify(mockErrors); + + return ` + (function() { + // Save the real fetch only once, even if this script runs multiple times + if (!window.__realFetch) { + window.__realFetch = window.fetch; + } + const _realFetch = window.__realFetch; + const MOCK_LOG_TEXT = ${mockLogTextJson}; + const MOCK_JOB = ${mockJob}; + const MOCK_ERRORS = ${mockErrorsJson}; + const MOCK_REPOS = ${mockRepos}; + + function jsonResponse(data) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + window.fetch = function(url, options) { + const urlStr = typeof url === 'string' ? url : url.toString(); + + if (urlStr.includes('live_backing.log')) { + return Promise.resolve(new Response(MOCK_LOG_TEXT, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + })); + } + + if (urlStr.includes('text_log_errors')) { + return Promise.resolve(jsonResponse(MOCK_ERRORS)); + } + + if (urlStr.match(/\\/api\\/jobs\\/\\d+/)) { + return Promise.resolve(jsonResponse(MOCK_JOB)); + } + + if (urlStr.includes('/api/repository')) { + return Promise.resolve(jsonResponse(MOCK_REPOS)); + } + + if (urlStr.includes('/api/push/')) { + return Promise.resolve(jsonResponse({ revision: 'abc123def456' })); + } + + if (urlStr.includes('/artifacts') && !urlStr.includes('live_backing')) { + return Promise.resolve(jsonResponse({ artifacts: [] })); + } + + return _realFetch.apply(this, arguments); + }; + })(); + `; +} + +// No errors = viewport starts at line 1 +const SCRIPT_NO_ERRORS = buildFetchMockScript(MOCK_LOG_LINES.join('\n'), []); + +test.describe('Logviewer', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(SCRIPT_NO_ERRORS); + }); + + test.describe('Log content rendering', () => { + test('renders log lines with line numbers', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('.classic-log-viewer').waitFor(); + + const lineNumbers = page.locator('.classic-log-number'); + await expect(lineNumbers.first()).toBeVisible(); + expect(await lineNumbers.count()).toBeGreaterThan(0); + }); + + test('displays the navigation bar with expected elements', async ({ + page, + }) => { + await page.goto(LOG_URL); + await page.locator('.classic-log-viewer').waitFor(); + + await expect(page.locator('#lv-logo')).toContainText('Logviewer'); + + await expect( + page.locator('a[title="Open the raw log in a new window (Shift+L)"]'), + ).toBeVisible(); + }); + + test('shows the search bar', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('.classic-log-viewer').waitFor(); + + await expect(page.locator('.classic-log-toolbar')).toBeVisible(); + await expect(page.locator('.classic-log-searchbar-input')).toBeVisible(); + }); + }); + + test.describe('Line highlighting', () => { + test('highlights a line when its number is clicked', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="10"]').waitFor(); + + await page.locator('[data-line="10"]').click(); + + await expect(page.locator(TOOLBAR_LABEL)).toHaveText('Line 10'); + }); + + test('selects a range with shift+click', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="5"]').waitFor(); + + await page.locator('[data-line="5"]').click(); + await page.locator(TOOLBAR_LABEL).waitFor(); + + await page.locator('[data-line="15"]').click({ modifiers: ['Shift'] }); + + await expect(page.locator(TOOLBAR_LABEL)).toHaveText(/Lines 5–15 \(11\)/); + }); + + test('updates URL with lineNumber param when line is selected', async ({ + page, + }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="8"]').waitFor(); + + await page.locator('[data-line="8"]').click(); + await page.locator(TOOLBAR_LABEL).waitFor(); + + await expect(page).toHaveURL(/lineNumber=8/); + }); + + test('updates URL with range when shift+click selects multiple lines', async ({ + page, + }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="10"]').waitFor(); + + await page.locator('[data-line="10"]').click(); + await page.locator(TOOLBAR_LABEL).waitFor(); + + await page.locator('[data-line="20"]').click({ modifiers: ['Shift'] }); + + await expect(page).toHaveURL(/lineNumber=10-20/); + }); + }); + + test.describe('Copy Highlighted Lines', () => { + test('shows selection label and copy button only when lines are highlighted', async ({ + page, + }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="10"]').waitFor(); + + // No selection label or copy button before clicking + await expect(page.locator(TOOLBAR_LABEL)).toHaveCount(0); + await expect(page.locator(COPY_BUTTON)).toHaveCount(0); + + await page.locator('[data-line="10"]').click(); + + await expect(page.locator(TOOLBAR_LABEL)).toBeVisible(); + await expect(page.locator(COPY_BUTTON)).toBeVisible(); + }); + + test('copy button extracts correct lines from memory', async ({ + page, + context, + baseURL, + browserName, + }) => { + // Granting clipboard permissions is a Chromium-only API; Playwright's + // Firefox allows clipboard writes in tests without it. + if (browserName === 'chromium') { + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: baseURL, + }); + } + + await page.goto(LOG_URL); + await page.locator('[data-line="5"]').waitFor(); + + // Select lines 5-7 + await page.locator('[data-line="5"]').click(); + await page.locator(TOOLBAR_LABEL).waitFor(); + + await page.locator('[data-line="7"]').click({ modifiers: ['Shift'] }); + + // Verify label shows the 3-line selection + await expect(page.locator(TOOLBAR_LABEL)).toHaveText(/Lines 5–7 \(3\)/); + + // Click the copy button and wait for the success state + await page.locator(COPY_BUTTON).click(); + await expect(page.locator(COPY_BUTTON)).toHaveClass(/btn-success/); + + // Verify the fetch+extraction works by reading from page context. + const result = await page.evaluate(async () => { + const resp = await window.fetch( + 'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/mock-task-id-abc123/runs/0/artifacts/public/logs/live_backing.log', + ); + const text = await resp.text(); + const lines = text.split('\n'); + return lines.slice(4, 7).join('\n'); + }); + + expect(result).toContain('Line 5:'); + expect(result).toContain('Line 6:'); + expect(result).toContain('Line 7:'); + }); + + test('shows correct label for single line selection', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('[data-line="12"]').waitFor(); + + await page.locator('[data-line="12"]').click(); + + await expect(page.locator(TOOLBAR_LABEL)).toHaveText('Line 12'); + }); + }); + + test.describe('Search functionality', () => { + test('finds matches when searching log content', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('.classic-log-viewer').waitFor(); + + const searchInput = page.locator('.classic-log-searchbar-input'); + await searchInput.click(); + await searchInput.pressSequentially('Line 10:', { delay: 50 }); + + const matches = page.locator('.classic-log-searchbar-matches'); + await expect(matches).toBeVisible(); + await expect(matches).not.toContainText('0 match'); + }); + }); + + test.describe('Show/Hide Job Info', () => { + test('toggles job info panel visibility', async ({ page }) => { + await page.goto(LOG_URL); + await page.locator('.classic-log-viewer').waitFor(); + + await expect(page.locator('.run-data')).toHaveCount(0); + + const showButton = page.locator('[data-testid="show-job-info"]'); + await showButton.click(); + + // The panel mounts but has no visible content with the minimal mock + // job, so assert on DOM presence rather than visibility. + await expect(page.locator('.run-data')).toHaveCount(1); + + await showButton.click(); + + await expect(page.locator('.run-data')).toHaveCount(0); + }); + }); + + test.describe('URL-based line navigation', () => { + test('highlights the line specified in the lineNumber URL param', async ({ + page, + }) => { + await page.goto(`${LOG_URL}&lineNumber=8`); + await page.locator('.classic-log-viewer').waitFor(); + + await expect(page.locator(TOOLBAR_LABEL)).toHaveText('Line 8'); + }); + + test('highlights a range specified in the lineNumber URL param', async ({ + page, + }) => { + await page.goto(`${LOG_URL}&lineNumber=10-20`); + await page.locator('.classic-log-viewer').waitFor(); + + await expect(page.locator(TOOLBAR_LABEL)).toHaveText( + /Lines 10–20 \(11\)/, + ); + }); + }); +}); diff --git a/tests/ui/integration/logviewer/logviewer_integration_test.js b/tests/ui/integration/logviewer/logviewer_integration_test.js deleted file mode 100644 index 35856aca2a2..00000000000 --- a/tests/ui/integration/logviewer/logviewer_integration_test.js +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Integration tests for the Logviewer page using the custom log viewer - * (ClassicLogViewer + react-virtuoso). - * - * Uses evaluateOnNewDocument to mock fetch() at the browser JS level, - * providing deterministic responses without depending on external services. - */ - -const MOCK_LOG_LINES = Array.from( - { length: 200 }, - (_, i) => - `[taskcluster 2025-01-01T00:00:00.000Z] Line ${i + 1}: sample log output for testing purposes`, -); - -const MOCK_JOB = { - id: 12345, - push_id: 100, - task_id: 'mock-task-id-abc123', - retry_id: 0, - result: 'testfailed', - state: 'completed', - job_group_name: 'Mochitests', - platform: 'linux64', - searchStr: 'mock test job', - logs: [ - { - name: 'live_backing_log', - url: 'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/mock-task-id-abc123/runs/0/artifacts/public/logs/live_backing.log', - }, - ], -}; - -const MOCK_REPOS = [ - { - id: 77, - name: 'autoland', - dvcs_type: 'hg', - url: 'https://hg.mozilla.org/integration/autoland', - tc_root_url: 'https://firefox-ci-tc.services.mozilla.com', - active_status: 'active', - }, -]; - -const BASE_URL = 'http://localhost:5000'; -const LOG_URL = `${BASE_URL}/logviewer?job_id=12345&repo=autoland&task=mock-task-id-abc123.0`; - -/** - * Build a script that mocks window.fetch before the app loads. - * Uses a global key so each new mock replaces the previous one. - */ -function buildFetchMockScript(mockLogText, mockErrors) { - const mockJob = JSON.stringify(MOCK_JOB); - const mockRepos = JSON.stringify(MOCK_REPOS); - const mockLogTextJson = JSON.stringify(mockLogText); - const mockErrorsJson = JSON.stringify(mockErrors); - - return ` - (function() { - // Save the real fetch only once, even if this script runs multiple times - if (!window.__realFetch) { - window.__realFetch = window.fetch; - } - const _realFetch = window.__realFetch; - const MOCK_LOG_TEXT = ${mockLogTextJson}; - const MOCK_JOB = ${mockJob}; - const MOCK_ERRORS = ${mockErrorsJson}; - const MOCK_REPOS = ${mockRepos}; - - function jsonResponse(data) { - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - - window.fetch = function(url, options) { - const urlStr = typeof url === 'string' ? url : url.toString(); - - if (urlStr.includes('live_backing.log')) { - return Promise.resolve(new Response(MOCK_LOG_TEXT, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - })); - } - - if (urlStr.includes('text_log_errors')) { - return Promise.resolve(jsonResponse(MOCK_ERRORS)); - } - - if (urlStr.match(/\\/api\\/jobs\\/\\d+/)) { - return Promise.resolve(jsonResponse(MOCK_JOB)); - } - - if (urlStr.includes('/api/repository')) { - return Promise.resolve(jsonResponse(MOCK_REPOS)); - } - - if (urlStr.includes('/api/push/')) { - return Promise.resolve(jsonResponse({ revision: 'abc123def456' })); - } - - if (urlStr.includes('/artifacts') && !urlStr.includes('live_backing')) { - return Promise.resolve(jsonResponse({ artifacts: [] })); - } - - return _realFetch.apply(this, arguments); - }; - })(); - `; -} - -// No errors = viewport starts at line 1 -const SCRIPT_NO_ERRORS = buildFetchMockScript( - MOCK_LOG_LINES.join('\n'), - [], -); - -describe('Logviewer', () => { - beforeEach(async () => { - await page.evaluateOnNewDocument(SCRIPT_NO_ERRORS); - }); - - describe('Log content rendering', () => { - it('renders log lines with line numbers', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - const lineNumbers = await page.$$('.classic-log-number'); - expect(lineNumbers.length).toBeGreaterThan(0); - }); - - it('displays the navigation bar with expected elements', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - const logviewerText = await page.$eval( - '#lv-logo', - (el) => el.textContent, - ); - expect(logviewerText).toContain('Logviewer'); - - const rawLogLink = await page.$( - 'a[title="Open the raw log in a new window (Shift+L)"]', - ); - expect(rawLogLink).not.toBeNull(); - }); - - it('shows the search bar', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - const searchBar = await page.$('.classic-log-searchbar'); - expect(searchBar).not.toBeNull(); - - const searchInput = await page.$('.classic-log-searchbar-input'); - expect(searchInput).not.toBeNull(); - }); - }); - - describe('Line highlighting', () => { - it('highlights a line when its number is clicked', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="10"]', { timeout: 10000 }); - - await page.click('[data-line="10"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toContain('Line 10 selected'); - }); - - it('selects a range with shift+click', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="5"]', { timeout: 10000 }); - - await page.click('[data-line="5"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - await page.keyboard.down('Shift'); - await page.click('[data-line="15"]'); - await page.keyboard.up('Shift'); - - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toMatch(/Lines 5.*15 selected/); - expect(barText).toContain('11 lines'); - }); - - it('updates URL with lineNumber param when line is selected', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="8"]', { timeout: 10000 }); - - await page.click('[data-line="8"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - const url = page.url(); - expect(url).toContain('lineNumber=8'); - }); - - it('updates URL with range when shift+click selects multiple lines', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="10"]', { timeout: 10000 }); - - await page.click('[data-line="10"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - await page.keyboard.down('Shift'); - await page.click('[data-line="20"]'); - await page.keyboard.up('Shift'); - - await page.waitForFunction( - () => window.location.search.includes('lineNumber=10-20'), - { timeout: 5000 }, - ); - - const url = page.url(); - expect(url).toContain('lineNumber=10-20'); - }); - }); - - describe('Copy Highlighted Lines', () => { - it('shows copy bar only when lines are highlighted', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="10"]', { timeout: 10000 }); - - // No copy bar before clicking - let copyBar = await page.$('.copy-highlight-bar'); - expect(copyBar).toBeNull(); - - await page.click('[data-line="10"]'); - - copyBar = await page.waitForSelector('.copy-highlight-bar', { - timeout: 5000, - }); - expect(copyBar).not.toBeNull(); - }); - - it('copy button extracts correct lines from memory', async () => { - const context = browser.defaultBrowserContext(); - await context.overridePermissions(BASE_URL, [ - 'clipboard-read', - 'clipboard-write', - ]); - - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="5"]', { timeout: 10000 }); - - // Select lines 5-7 - await page.click('[data-line="5"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - await page.keyboard.down('Shift'); - await page.click('[data-line="7"]'); - await page.keyboard.up('Shift'); - - // Verify bar shows 3 lines - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toContain('3 lines'); - - // Click the copy button - await page.click('.copy-highlight-bar button'); - - // Wait for button text to change from "Copy" (either to "Copied!" or "Copying...") - await page.waitForFunction( - () => { - const btn = document.querySelector('.copy-highlight-bar button'); - return btn && !btn.textContent.includes('Copy'); - }, - { timeout: 10000 }, - ); - - // The button should show either "Copied!" (clipboard worked) or - // transition through "Copying..." (clipboard may be blocked in headless). - // Either way, verify the fetch+extraction worked by reading from page context. - const result = await page.evaluate(async () => { - const resp = await window.fetch( - 'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/mock-task-id-abc123/runs/0/artifacts/public/logs/live_backing.log', - ); - const text = await resp.text(); - const lines = text.split('\n'); - return lines.slice(4, 7).join('\n'); - }); - - expect(result).toContain('Line 5:'); - expect(result).toContain('Line 6:'); - expect(result).toContain('Line 7:'); - }); - - it('shows correct label for single line selection', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('[data-line="12"]', { timeout: 10000 }); - - await page.click('[data-line="12"]'); - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toBe('Line 12 selected'); - }); - }); - - describe('Search functionality', () => { - it('finds matches when searching log content', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - const searchInput = await page.waitForSelector( - '.classic-log-searchbar-input', - { timeout: 5000 }, - ); - await searchInput.click(); - await searchInput.type('Line 10:', { delay: 50 }); - - await page.waitForFunction( - () => { - const matches = document.querySelector( - '.classic-log-searchbar-matches', - ); - return matches && !matches.textContent.includes('0 match'); - }, - { timeout: 10000 }, - ); - - const matchText = await page.$eval( - '.classic-log-searchbar-matches', - (el) => el.textContent, - ); - expect(matchText).not.toContain('0 match'); - }); - }); - - describe('Show/Hide Job Info', () => { - it('toggles job info panel visibility', async () => { - await page.goto(LOG_URL, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - let runData = await page.$('.run-data'); - expect(runData).toBeNull(); - - const showButton = await page.waitForSelector( - '[data-testid="show-job-info"]', - { timeout: 5000 }, - ); - await showButton.click(); - - runData = await page.waitForSelector('.run-data', { timeout: 5000 }); - expect(runData).not.toBeNull(); - - await showButton.click(); - - await page.waitForFunction( - () => !document.querySelector('.run-data'), - { timeout: 5000 }, - ); - runData = await page.$('.run-data'); - expect(runData).toBeNull(); - }); - }); - - describe('URL-based line navigation', () => { - it('highlights the line specified in the lineNumber URL param', async () => { - const urlWithLine = `${LOG_URL}&lineNumber=8`; - await page.goto(urlWithLine, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - await page.waitForSelector('.copy-highlight-bar', { timeout: 10000 }); - - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toContain('Line 8 selected'); - }); - - it('highlights a range specified in the lineNumber URL param', async () => { - const urlWithRange = `${LOG_URL}&lineNumber=10-20`; - await page.goto(urlWithRange, { waitUntil: 'networkidle2' }); - await page.waitForSelector('.classic-log-viewer', { timeout: 10000 }); - - await page.waitForSelector('.copy-highlight-bar', { timeout: 5000 }); - - const barText = await page.$eval( - '.copy-highlight-label', - (el) => el.textContent, - ); - expect(barText).toMatch(/Lines 10.*20 selected/); - }); - }); -}); diff --git a/tests/ui/integration/test-setup.js b/tests/ui/integration/test-setup.js deleted file mode 100644 index 947439fc04e..00000000000 --- a/tests/ui/integration/test-setup.js +++ /dev/null @@ -1,2 +0,0 @@ -// Entry point for Jest tests -import '@testing-library/jest-dom/jest-globals'; From c202d84c4ba2efeae876a07d40976cb5d05bb9f5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 19 Aug 2026 13:19:12 -0400 Subject: [PATCH 12/17] Bug 2062730 - Return machine_name with each performance summary datum (#9776) This allows showing per-machine data in the graphs without requiring separate requests for each job's job details. The potential costs of this change are: - Increased database query time - Increased response size The query already looks at job submit times so it's already looking at the right table, so any query time regressions should be minor. (Claude took a look in more detail and had more elaborate justifications but I didn't understand them so I'm not copying them here.) The response size grows, but gzip mostly takes care of it because there aren't a lot of different machine names. I measured a 1050-row response as an example, and it grew by 16.8% raw and 3.2% gzipped ("0.6 bytes per row"). --- tests/webapp/api/test_performance_data_api.py | 23 +++++++++++++++++++ treeherder/webapp/api/performance_data.py | 5 ++++ .../webapp/api/performance_serializers.py | 14 ++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/webapp/api/test_performance_data_api.py b/tests/webapp/api/test_performance_data_api.py index 1383bfbb783..f0ab69bee50 100644 --- a/tests/webapp/api/test_performance_data_api.py +++ b/tests/webapp/api/test_performance_data_api.py @@ -583,6 +583,29 @@ def test_perf_summary_data_includes_submit_time( assert {row["submit_time"] for row in data} == expected +@pytest.mark.parametrize("replicates", ["true", "false"]) +def test_perf_summary_data_includes_machine_name( + client, test_perf_signature, test_perf_data, replicates +): + """ + Check that "machine_name" is present on each datum. + + We test both `replicates=true` `replicates=false` because the + two modes use different code to create the datum objects. + """ + query_params = summary_query_params(test_perf_signature, test_perf_data, replicates=replicates) + + response = client.get(reverse("performance-summary") + query_params) + assert response.status_code == 200 + + data = response.json()[0]["data"] + assert len(data) == len(test_perf_data) + + expected_names = {datum.job.machine.name for datum in test_perf_data} + assert expected_names, "fixture jobs should have machines" + assert {row["machine_name"] for row in data} == expected_names + + def test_perf_summary_should_alert_is_false_edge_case( client, test_perf_signature, test_perf_signature_2, test_perf_data ): diff --git a/treeherder/webapp/api/performance_data.py b/treeherder/webapp/api/performance_data.py index d7441845de3..3859393b26f 100644 --- a/treeherder/webapp/api/performance_data.py +++ b/treeherder/webapp/api/performance_data.py @@ -1027,6 +1027,7 @@ def list(self, request): push_revision, replicate_value, submit_time, + machine_name, ) in data.values_list( "value", "job_id", @@ -1036,6 +1037,7 @@ def list(self, request): "push__revision", "performancedatumreplicate__value", "job__submit_time", + "job__machine__name", ).order_by("push_timestamp", "push_id", "job_id"): if replicate_value is not None: item["data"].append( @@ -1047,6 +1049,7 @@ def list(self, request): "push_timestamp": push_timestamp, "push__revision": push_revision, "job__submit_time": submit_time, + "job__machine__name": machine_name, } ) elif value is not None: @@ -1059,6 +1062,7 @@ def list(self, request): "push_timestamp": push_timestamp, "push__revision": push_revision, "job__submit_time": submit_time, + "job__machine__name": machine_name, } ) else: @@ -1070,6 +1074,7 @@ def list(self, request): "push_timestamp", "push__revision", "job__submit_time", + "job__machine__name", ).order_by("push_timestamp", "push_id", "job_id") item["option_name"] = option_collection_map[item["option_collection_id"]] diff --git a/treeherder/webapp/api/performance_serializers.py b/treeherder/webapp/api/performance_serializers.py index b01ccd69b9c..974200dd635 100644 --- a/treeherder/webapp/api/performance_serializers.py +++ b/treeherder/webapp/api/performance_serializers.py @@ -489,10 +489,22 @@ class PerformanceDatumSerializer(serializers.ModelSerializer): submit_time = serializers.DateTimeField( required=False, allow_null=True, default=None, source="job__submit_time" ) + machine_name = serializers.CharField( + required=False, allow_null=True, default=None, source="job__machine__name" + ) class Meta: model = PerformanceDatum - fields = ["job_id", "id", "value", "push_timestamp", "push_id", "revision", "submit_time"] + fields = [ + "job_id", + "id", + "value", + "push_timestamp", + "push_id", + "revision", + "submit_time", + "machine_name", + ] class PerformanceSummarySerializer(serializers.ModelSerializer): From 5d0a2dc8895254777a2d64e5a89067b673450088 Mon Sep 17 00:00:00 2001 From: Sebastian Hengst Date: Wed, 19 Aug 2026 20:35:56 +0200 Subject: [PATCH 13/17] Bug 2062817 - use new treestatus https://lando.moz.tools/treestatus/ (#9777) --- tests/ui/job-view/SecondaryNavBar_test.jsx | 2 +- treeherder/middleware.py | 2 +- ui/helpers/constants.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ui/job-view/SecondaryNavBar_test.jsx b/tests/ui/job-view/SecondaryNavBar_test.jsx index 41268f81844..9088f293e58 100644 --- a/tests/ui/job-view/SecondaryNavBar_test.jsx +++ b/tests/ui/job-view/SecondaryNavBar_test.jsx @@ -17,7 +17,7 @@ const mockNavigate = jest.fn(); beforeEach(() => { fetchMock.get( - 'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/trees/firefox-autoland', + 'https://lando.moz.tools/api/treestatus/trees/firefox-autoland', { result: { message_of_the_day: '', diff --git a/treeherder/middleware.py b/treeherder/middleware.py index fdeb5d9cd44..c413a9af953 100644 --- a/treeherder/middleware.py +++ b/treeherder/middleware.py @@ -18,7 +18,7 @@ "font-src 'self' https://fonts.gstatic.com", # The `data:` is required for images that were inlined by webpack's url-loader (as an optimisation). "img-src 'self' data:", - "connect-src 'self' https://community-tc.services.mozilla.com https://firefox-ci-tc.services.mozilla.com https://*.taskcluster-artifacts.net https://taskcluster-artifacts.net https://lando.services.mozilla.com https://treestatus.prod.lando.prod.cloudops.mozgcp.net https://bugzilla.mozilla.org https://auth.mozilla.auth0.com https://stage.taskcluster.nonprod.cloudops.mozgcp.net https://insights-api.newrelic.com https://prototype.treeherder.allizom.org https://treeherder.allizom.org", + "connect-src 'self' https://community-tc.services.mozilla.com https://firefox-ci-tc.services.mozilla.com https://*.taskcluster-artifacts.net https://taskcluster-artifacts.net https://lando.moz.tools https://bugzilla.mozilla.org https://auth.mozilla.auth0.com https://stage.taskcluster.nonprod.cloudops.mozgcp.net https://insights-api.newrelic.com https://prototype.treeherder.allizom.org https://treeherder.allizom.org", "frame-src 'self'", ] diff --git a/ui/helpers/constants.js b/ui/helpers/constants.js index b3f9f3b90b8..c964efd7186 100644 --- a/ui/helpers/constants.js +++ b/ui/helpers/constants.js @@ -17,8 +17,8 @@ export const thHosts = { default: { host: null, treestatus: { - uiUrl: 'https://lando.services.mozilla.com/treestatus/', - apiUrl: 'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/', + uiUrl: 'https://lando.moz.tools/treestatus/', + apiUrl: 'https://lando.moz.tools/api/treestatus/', }, }, }; From 1a9be005b5a2e178729c5b7ffd8ceb945d042d1a Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Wed, 19 Aug 2026 13:46:11 -0700 Subject: [PATCH 14/17] Silently recover a lapsed backend session on page load (#9798) Since the Django session was capped at AUTH_MAX_SESSION_AGE_SECONDS (#9688), any user whose renewal heartbeat stops for more than ~45 minutes (laptop asleep, browser closed overnight) loses their session. On the next page load the frontend saw the anonymous backend response and fully logged the user out -- wiping the auth0-spa-js refresh-token cache -- forcing an interactive re-login even though the refresh token was still perfectly valid. Add AuthService.recoverSession(), which uses the refresh token to silently obtain new tokens and re-establish the backend session. The page-load flow in Login now attempts this recovery before treating the user as logged out, so ordinary users stay logged in for the life of their refresh token (roughly a day or more of inactivity) while the security property of the session cap is preserved: a revoked user's refresh token fails at Auth0, recovery returns null, and they are logged out within the cap window as before. --- tests/ui/shared/AuthService.test.js | 52 ++++++++++++ tests/ui/shared/Login.test.jsx | 125 ++++++++++++++++++++++++++++ ui/shared/auth/AuthService.js | 30 +++++++ ui/shared/auth/Login.jsx | 18 +++- 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 tests/ui/shared/Login.test.jsx diff --git a/tests/ui/shared/AuthService.test.js b/tests/ui/shared/AuthService.test.js index 0f43fa6ec89..968f2361865 100644 --- a/tests/ui/shared/AuthService.test.js +++ b/tests/ui/shared/AuthService.test.js @@ -362,6 +362,58 @@ describe('AuthService', () => { }); }); + describe('recoverSession', () => { + it('saves credentials and returns the user when renew succeeds', async () => { + const authResult = { accessToken: 'new-token' }; + const recoveredUser = { email: 'test@mozilla.com', is_staff: false }; + mockRenew.mockResolvedValue(authResult); + authService.saveCredentialsFromAuthResult = jest + .fn() + .mockResolvedValue(recoveredUser); + + const result = await authService.recoverSession(); + + expect(mockRenew).toHaveBeenCalled(); + expect(authService.saveCredentialsFromAuthResult).toHaveBeenCalledWith( + authResult, + ); + expect(result).toEqual(recoveredUser); + }); + + it('returns null when renew fails (e.g. refresh token revoked)', async () => { + mockRenew.mockRejectedValue(new Error('login_required')); + + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await authService.recoverSession(); + + expect(result).toBeNull(); + console.warn.mockRestore(); + }); + + it('returns null when renew returns a falsy result', async () => { + mockRenew.mockResolvedValue(null); + + const result = await authService.recoverSession(); + + expect(result).toBeNull(); + }); + + it('does not clear the stored session on failure (caller decides)', async () => { + localStorage.setItem('userSession', '{"accessToken":"tok"}'); + mockRenew.mockRejectedValue(new Error('network')); + + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await authService.recoverSession(); + + expect(localStorage.getItem('userSession')).toBe( + '{"accessToken":"tok"}', + ); + console.warn.mockRestore(); + }); + }); + describe('logout', () => { it('clears renewalLock from localStorage', () => { localStorage.setItem('renewalLock', Date.now().toString()); diff --git a/tests/ui/shared/Login.test.jsx b/tests/ui/shared/Login.test.jsx new file mode 100644 index 00000000000..0634d786ef5 --- /dev/null +++ b/tests/ui/shared/Login.test.jsx @@ -0,0 +1,125 @@ +/** + * Unit tests for the Login component's page-load session handling. + * + * The Django session is capped (AUTH_MAX_SESSION_AGE_SECONDS) so it lapses + * whenever the renewal heartbeat stops for longer than the cap (laptop + * asleep, browser closed overnight). The Auth0 refresh token usually remains + * valid much longer, so on page load the component must attempt a silent + * session recovery before treating the user as logged out. + */ +import { render, waitFor } from '@testing-library/react'; + +import Login from '../../../ui/shared/auth/Login'; +import UserModel from '../../../ui/models/user'; + +const mockRecoverSession = jest.fn(); +const mockLogout = jest.fn(); +const mockResetRenewalTimer = jest.fn(); + +jest.mock('../../../ui/shared/auth/AuthService', () => + jest.fn().mockImplementation(() => ({ + recoverSession: (...args) => mockRecoverSession(...args), + logout: (...args) => mockLogout(...args), + resetRenewalTimer: (...args) => mockResetRenewalTimer(...args), + })), +); + +jest.mock('../../../ui/helpers/auth', () => ({ + loggedOutUser: { + isStaff: false, + username: '', + email: '', + isLoggedIn: false, + }, +})); + +jest.mock('../../../ui/models/user'); + +const storedSession = JSON.stringify({ + accessToken: 'tok', + idToken: 'id', + fullName: 'Test User', + renewAfter: new Date(Date.now() + 15 * 60 * 1000).toISOString(), +}); + +describe('Login page-load session handling', () => { + let setUser; + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + setUser = jest.fn(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + console.log.mockRestore(); + }); + + const renderLogin = () => + render(); + + it('sets the user as logged in when the backend session is still valid', async () => { + localStorage.setItem('userSession', storedSession); + UserModel.get.mockResolvedValue({ email: 'test@mozilla.com' }); + + renderLogin(); + + await waitFor(() => + expect(setUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@mozilla.com', + isLoggedIn: true, + }), + ), + ); + expect(mockRecoverSession).not.toHaveBeenCalled(); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('recovers the session silently when the backend session lapsed but a userSession remains', async () => { + localStorage.setItem('userSession', storedSession); + // Backend session lapsed: anonymous response with no email + UserModel.get.mockResolvedValue({ email: '' }); + mockRecoverSession.mockResolvedValue({ + email: 'test@mozilla.com', + isStaff: false, + }); + + renderLogin(); + + await waitFor(() => expect(mockRecoverSession).toHaveBeenCalled()); + await waitFor(() => + expect(setUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@mozilla.com', + isLoggedIn: true, + }), + ), + ); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('logs out when silent recovery fails', async () => { + localStorage.setItem('userSession', storedSession); + UserModel.get.mockResolvedValue({ email: '' }); + mockRecoverSession.mockResolvedValue(null); + + renderLogin(); + + await waitFor(() => expect(mockRecoverSession).toHaveBeenCalled()); + await waitFor(() => expect(mockLogout).toHaveBeenCalled()); + expect(setUser).toHaveBeenCalledWith( + expect.objectContaining({ isLoggedIn: false }), + ); + }); + + it('logs out without attempting recovery when no userSession exists', async () => { + UserModel.get.mockResolvedValue({ email: '' }); + + renderLogin(); + + await waitFor(() => expect(mockLogout).toHaveBeenCalled()); + expect(mockRecoverSession).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/shared/auth/AuthService.js b/ui/shared/auth/AuthService.js index 1aa9aa17535..b90ca2b41de 100644 --- a/ui/shared/auth/AuthService.js +++ b/ui/shared/auth/AuthService.js @@ -208,6 +208,34 @@ export default class AuthService { } } + /** + * Re-establish a lapsed backend session using the Auth0 refresh token. + * + * The Django session is capped (AUTH_MAX_SESSION_AGE_SECONDS) so it lapses + * whenever the renewal heartbeat stops for longer than the cap (laptop + * asleep, browser closed overnight). The refresh token usually remains + * valid much longer, so a silent renewal can log the user back in without + * any interaction. Returns the logged-in user on success, or null if the + * refresh token can no longer be used (e.g. revoked SSO access), in which + * case the caller should log the user out. + */ + async recoverSession() { + try { + authLog('Attempting silent session recovery...'); + const authResult = await renew(); + if (!authResult) { + authWarn('Silent session recovery returned no credentials'); + return null; + } + const user = await this.saveCredentialsFromAuthResult(authResult); + authInfo('Session recovered silently for:', user.email); + return user; + } catch (err) { + authWarn('Silent session recovery failed:', err.error || err.message); + return null; + } + } + logout() { authInfo('Logging out user'); localStorage.removeItem('userSession'); @@ -229,5 +257,7 @@ export default class AuthService { localStorage.setItem('userSession', JSON.stringify(userSession)); localStorage.setItem('user', JSON.stringify(user)); + + return user; } } diff --git a/ui/shared/auth/Login.jsx b/ui/shared/auth/Login.jsx index be3035de95c..f642dd1964a 100644 --- a/ui/shared/auth/Login.jsx +++ b/ui/shared/auth/Login.jsx @@ -67,12 +67,24 @@ const Login = ({ setUser, user = { isLoggedIn: false }, notify }) => { window.addEventListener('storage', handleStorageEvent); // Ask the back-end if a user is logged in on page load - UserModel.get().then((currentUser) => { + UserModel.get().then(async (currentUser) => { if (currentUser.email && localStorage.getItem('userSession')) { setLoggedIn(currentUser); - } else { - setLoggedOut(); + return; + } + // The backend session is capped (AUTH_MAX_SESSION_AGE_SECONDS) and may + // have lapsed while the Auth0 refresh token is still valid (laptop + // asleep, browser closed overnight). Attempt a silent renewal before + // treating the user as logged out; it fails fast for a user whose SSO + // access was actually revoked. + if (localStorage.getItem('userSession')) { + const recoveredUser = await authServiceRef.current.recoverSession(); + if (recoveredUser) { + setLoggedIn(recoveredUser); + return; + } } + setLoggedOut(); }); return () => { From f478b0496bff12ce6c6142ccf7b59b27391fb593 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 20 Aug 2026 20:38:11 +0530 Subject: [PATCH 15/17] Bug-2038705: Migrate the get_commit to pygithub (#9739) Migrate the get_commit method in treeeherder/utils/github.py to use PyGithub object's. --- tests/changelog/test_collector.py | 20 +++-- tests/changelog/test_tasks.py | 13 +++ tests/utils/test_github.py | 142 +++++++++++++++++++++++++++++- treeherder/utils/github.py | 22 ++++- 4 files changed, 188 insertions(+), 9 deletions(-) diff --git a/tests/changelog/test_collector.py b/tests/changelog/test_collector.py index c215a52d4c0..dc519f5f0c8 100644 --- a/tests/changelog/test_collector.py +++ b/tests/changelog/test_collector.py @@ -36,18 +36,12 @@ def _commit(): }, } - def commit(request): - return 200, {}, json.dumps(_commit()) - def commits(request): return 200, {}, json.dumps([_commit()]) responses.add_callback( responses.GET, COMMITS, callback=commits, content_type="application/json" ) - responses.add_callback( - responses.GET, COMMIT_INFO, callback=commit, content_type="application/json" - ) @responses.activate @@ -68,8 +62,22 @@ def test_collect(mock_pygithub_get_repo): mock_release.html_url = "mock_release_url" mock_release.author = mock_author + # Mock the file and commit object + mock_file1 = mock.Mock() + mock_file1.filename = "file1" + mock_file2 = mock.Mock() + mock_file2.filename = "file2" + + mock_commit = mock.Mock() + mock_commit.files = [mock_file1, mock_file2] + mock_commit.commit.committer.date = now.isoformat() + mock_parent = mock.Mock() + mock_parent.sha = "mock_parent_sha" + mock_commit.parents = [mock_parent] + mock_repo = mock.Mock() mock_repo.get_releases.return_value = [mock_release] + mock_repo.get_commit.return_value = mock_commit mock_pygithub_get_repo.return_value = mock_repo prepare_responses() diff --git a/tests/changelog/test_tasks.py b/tests/changelog/test_tasks.py index 2ac82638847..c71eda7ad08 100644 --- a/tests/changelog/test_tasks.py +++ b/tests/changelog/test_tasks.py @@ -25,8 +25,21 @@ def test_update_changelog(mock_pygithub_get_repo): mock_release.html_url = "mock_release_url" mock_release.author = mock_author + mock_file1 = mock.Mock() + mock_file1.filename = "file1" + mock_file2 = mock.Mock() + mock_file2.filename = "file2" + + mock_commit = mock.Mock() + mock_commit.files = [mock_file1, mock_file2] + mock_commit.commit.committer.date = now.isoformat() + mock_parent = mock.Mock() + mock_parent.sha = "mock_parent_sha" + mock_commit.parents = [mock_parent] + mock_repo = mock.Mock() mock_repo.get_releases.return_value = [mock_release] + mock_repo.get_commit.return_value = mock_commit mock_pygithub_get_repo.return_value = mock_repo prepare_responses() diff --git a/tests/utils/test_github.py b/tests/utils/test_github.py index aada7663a08..44027cc83d8 100644 --- a/tests/utils/test_github.py +++ b/tests/utils/test_github.py @@ -1,10 +1,61 @@ from datetime import UTC, datetime from unittest.mock import patch +import pytest + # Import the function to be tested from treeherder.utils.github import get_releases +# Mock GitCommit and it's related classes +class MockCommitParent: + def __init__(self, sha): + self.sha = sha + + +class MockCommitFile: + def __init__(self, filename): + self.filename = filename + + +class MockCommitter: + def __init__(self, date): + self.date = date + + +class MockInnerCommit: + def __init__(self, committer_date): + self.committer = MockCommitter(committer_date) + + +class MockCommit: + def __init__(self, sha, committer_date, parents=None, files=None): + self.sha = sha + self.commit = MockInnerCommit(committer_date) + self.parents = [MockCommitParent(p_sha) for p_sha in parents] if parents else [] + self.files = [MockCommitFile(f_name) for f_name in files] if files else [] + + +@pytest.fixture +def github_commit_mock(): + """ + A factory fixture that patches the github object, sets up a MockRepository, + and returns a helper function to easily register commits. + """ + with patch("treeherder.utils.github.github") as mock_github: + mock_repo = MockRepository() + mock_github.get_repo.return_value = mock_repo + + def _register(sha, committer_date, parents=None, files=None): + commit_obj = MockCommit( + sha=sha, committer_date=committer_date, parents=parents, files=files + ) + mock_repo._commits[sha] = commit_obj + return mock_github, mock_repo, commit_obj + + yield _register + + # Helper for MockGitRelease class MockAuthor: def __init__(self, login): @@ -63,8 +114,9 @@ def __repr__(self): # Mock Repository class to simulate PyGithub's Repository objects class MockRepository: - def __init__(self, releases): - self._releases = releases + def __init__(self, releases=None, commits=None): + self._releases = releases or [] + self._commits = commits or {} def get_releases(self): # PyGithub's get_releases returns an iterable (PaginatedList), @@ -72,6 +124,9 @@ def get_releases(self): # Returning a list directly simulates this behavior for the mock. return self._releases + def get_commit(self, sha): + return self._commits[sha] + @patch("treeherder.utils.github.github") def test_get_releases_no_params(mock_github): @@ -293,3 +348,86 @@ def test_get_releases_with_number_and_since_params(mock_github): ] assert len(result_s3) == 3 assert result_s3 == expected_s3 + + +def test_get_commit_standard(github_commit_mock): + """ + Test get_commit returns a dictionary representing a standard commit with files, parents, and committer date. + """ + owner = "test-owner" + repo = "test-repo" + sha = "abc123commitsha" + date_str = "2023-01-01T12:00:00Z" + + mock_github, _, _ = github_commit_mock( + sha=sha, + committer_date=date_str, + parents=["parentsha1", "parentsha2"], + files=["file1.py", "file2.py"], + ) + + from treeherder.utils.github import get_commit + + result = get_commit(owner, repo, sha) + + # Assertions + mock_github.get_repo.assert_called_once_with(f"{owner}/{repo}") + assert result == { + "files": [{"filename": "file1.py"}, {"filename": "file2.py"}], + "commit": {"committer": {"date": date_str}}, + "parents": [{"sha": "parentsha1"}, {"sha": "parentsha2"}], + } + + +def test_get_commit_initial_commit(github_commit_mock): + """ + Test get_commit handles an initial/root commit with no parents. + """ + owner = "test-owner" + repo = "test-repo" + sha = "initialcommitsha" + date_str = "2023-01-01T00:00:00Z" + + github_commit_mock( + sha=sha, + committer_date=date_str, + parents=[], + files=["README.md"], + ) + + from treeherder.utils.github import get_commit + + result = get_commit(owner, repo, sha) + + assert result == { + "files": [{"filename": "README.md"}], + "commit": {"committer": {"date": date_str}}, + "parents": [], + } + + +def test_get_commit_no_files(github_commit_mock): + """ + Test get_commit handles a commit with no files changed. + """ + owner = "test-owner" + repo = "test-repo" + sha = "nofilescommitsha" + date_str = "2023-01-02T10:00:00Z" + + github_commit_mock( + sha=sha, + committer_date=date_str, + parents=["parentsha"], + files=[], + ) + + from treeherder.utils.github import get_commit + + result = get_commit(owner, repo, sha) + + assert result == { + "files": [], + "commit": {"committer": {"date": date_str}}, + "parents": [{"sha": "parentsha"}], + } diff --git a/treeherder/utils/github.py b/treeherder/utils/github.py index 0256abf01d2..4ab9ec9878c 100644 --- a/treeherder/utils/github.py +++ b/treeherder/utils/github.py @@ -89,7 +89,27 @@ def get_all_commits(owner, repo, params=None): def get_commit(owner, repo, sha, params=None): - return fetch_api(f"repos/{owner}/{repo}/commits/{sha}", params) + """ + Retrieve GitHub commit for a given sha. + Returns a standardized dictionary representing a commit. + """ + repo_object = pygithub_get_repo(owner, repo) + commit = repo_object.get_commit(sha) + commit_dict = {} + + # Append file objects required by collector.py + commit_dict["files"] = [] + for file in commit.files: + f = {} + f["filename"] = file.filename + commit_dict["files"].append(f) + + # Append object required by ingest.py + commit_dict["commit"] = {"committer": {"date": commit.commit.committer.date}} + commit_dict["parents"] = [] + for parent in commit.parents: + commit_dict["parents"].append({"sha": parent.sha}) + return commit_dict def get_pull_request(owner, repo, pr_id): From 866639d0c78be12b08a596d0a3f83b915104d0e1 Mon Sep 17 00:00:00 2001 From: Sebastian Hengst Date: Fri, 21 Aug 2026 19:49:47 +0200 Subject: [PATCH 16/17] use /logs endpoint for Treestatus UI links, old one is 404 (#9799) --- ui/job-view/headerbars/WatchedRepo.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/job-view/headerbars/WatchedRepo.jsx b/ui/job-view/headerbars/WatchedRepo.jsx index 9ec1f552e25..0e1e5342399 100644 --- a/ui/job-view/headerbars/WatchedRepo.jsx +++ b/ui/job-view/headerbars/WatchedRepo.jsx @@ -174,7 +174,7 @@ function WatchedRepo({ repoName, unwatchRepo, repo, setCurrentRepoTreeStatus }) )} From 29370f31f10dce7a57fef3bd9ff1eb6e79e233f5 Mon Sep 17 00:00:00 2001 From: Andrej Glavic Date: Mon, 24 Aug 2026 10:51:01 -0400 Subject: [PATCH 17/17] Bug 1901066 - Make graph tooltip view link to treeherder filter to just the single job (#9684) --- .../graphs-view/graphs_view_test.jsx | 29 +++++++++++++++++++ ui/perfherder/graphs/GraphTooltip.jsx | 28 +++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/ui/perfherder/graphs-view/graphs_view_test.jsx b/tests/ui/perfherder/graphs-view/graphs_view_test.jsx index 081aec38419..8182d970895 100644 --- a/tests/ui/perfherder/graphs-view/graphs_view_test.jsx +++ b/tests/ui/perfherder/graphs-view/graphs_view_test.jsx @@ -33,6 +33,21 @@ import { fetchMock.mock(`begin:${getApiUrl(endpoints.changelog)}`, changelogData); +// Mock the single-job endpoint so that request resolves in tests +fetchMock.mock(/\/jobs\/\d+\/$/, { + id: 1, + job_type_name: 'test-linux64/opt-talos-tp5o', + job_type_symbol: 'tp5o', + job_group_name: 'Talos performance tests', + platform: 'linux64', + platform_option: 'opt', + result: 'success', + state: 'completed', + submit_timestamp: 0, + start_timestamp: 0, + end_timestamp: 0, +}); + const graphData = createGraphData( testData, alertSummaries, @@ -339,6 +354,20 @@ test('Using select query param displays tooltip for correct datapoint', async () expect(platform).toHaveTextContent(testData[0].platform); }); +test('Job link includes searchStr query param to filter to the single job', async () => { + const { getByRole } = await graphsViewControls(graphData, false); + + const jobLink = await waitFor(() => getByRole('link', { name: 'job' })); + + // The tokens come from the mocked job's searchStr, so the Job View filters + // down to this job instead of showing every job on the push. + await waitFor(() => + expect(decodeURIComponent(jobLink.search)).toContain( + 'searchStr=Linux,opt,Talos,performance,tests,test-linux64/opt-talos-tp5o,tp5o', + ), + ); +}); + test("Alert's ID can be copied to clipboard from tooltip", async () => { const selectedDataPoint = { signature_id: testData[0].signature_id, diff --git a/ui/perfherder/graphs/GraphTooltip.jsx b/ui/perfherder/graphs/GraphTooltip.jsx index f4a7743ad8d..6268edf8627 100644 --- a/ui/perfherder/graphs/GraphTooltip.jsx +++ b/ui/perfherder/graphs/GraphTooltip.jsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import countBy from 'lodash/countBy'; import { Button } from 'react-bootstrap'; @@ -115,11 +115,37 @@ const GraphTooltip = ({ }); } + // Only a clicked graph point displays the tooltip jobs hyperlink + const [fetchedJob, setFetchedJob] = useState(null); + const jobSearchStr = + fetchedJob?.jobId === dataPointDetails.jobId ? fetchedJob.searchStr : ''; + + useEffect(() => { + if (!lockTooltip || !dataPointDetails.jobId) return; + + const controller = new AbortController(); + JobModel.get( + testDetails.repository_name, + dataPointDetails.jobId, + controller.signal, + ) + .then((job) => + setFetchedJob({ + jobId: dataPointDetails.jobId, + searchStr: job.searchStr || '', + }), + ) + .catch(() => {}); + + return () => controller.abort(); + }, [lockTooltip, dataPointDetails.jobId, testDetails.repository_name]); + const jobsUrl = getJobsUrl({ repo: testDetails.repository_name, revision: dataPointDetails.revision, selectedJob: dataPointDetails.jobId, group_state: 'expanded', + ...(jobSearchStr ? { searchStr: jobSearchStr.split(' ') } : {}), }); const createAlert = async () => {