-
Notifications
You must be signed in to change notification settings - Fork 8
[codex] Add reparent E2E scenarios #530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
821de06
add reparent e2e scenarios
edgars-avotins df3e498
fix reparent e2e cleanup
edgars-avotins 2691f13
Fix rebased miners reparent e2e flows
edgars-avotins aabb5de
Harden rebased reparent e2e setup
edgars-avotins e2cc48a
Handle zero-site state in reparent e2e
edgars-avotins 978d414
Handle empty Fleet Sites state in reparent e2e
edgars-avotins 62a122f
Fix reparent E2E setup and waits
edgars-avotins 844563b
Fix reparent E2E candidate typing
edgars-avotins c71a685
Make reparent E2E self-contained in CI
edgars-avotins d6ca057
Refactor reparent E2E around page objects
edgars-avotins 2666a50
Fix reparent spec typecheck
edgars-avotins 6fe4f59
Stabilize reparent e2e flows
edgars-avotins 4584cba
Wait for site and building modals to close
edgars-avotins e29e459
Wait for modal nodes to be removed
edgars-avotins a6cfcd0
Wait for page overlay shell to close
edgars-avotins d4c2f1e
Restore modal wait helper
edgars-avotins 4100180
Scope overlay wait to building modal flow
edgars-avotins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| import { expect, type Page, type Response as PlaywrightResponse } from "@playwright/test"; | ||
| import { type MinersPage } from "../pages/miners"; | ||
| import { type RacksPage } from "../pages/racks"; | ||
| import { type CommonSteps } from "./commonSteps"; | ||
|
|
||
| const LIST_MINERS_RESPONSE = "ListMinerStateSnapshots"; | ||
| const ACTIVE_SITE_STORAGE_KEY = "proto-fleet-multi-site"; | ||
|
|
||
| type PlacementRefSnapshot = { | ||
| label?: string; | ||
| }; | ||
|
|
||
| type VisibleMinerSnapshot = { | ||
| deviceIdentifier: string; | ||
| ipAddress: string; | ||
| rackPosition?: string; | ||
| placement?: { | ||
| site?: PlacementRefSnapshot; | ||
| rack?: PlacementRefSnapshot; | ||
| }; | ||
| }; | ||
|
|
||
| export type ReparentMiner = { | ||
| deviceIdentifier: string; | ||
| ipAddress: string; | ||
| originalRackLabel?: string; | ||
| originalRackPosition?: string; | ||
| originalSiteLabel?: string; | ||
| }; | ||
|
|
||
| export async function installAllSitesInitScript(page: Page) { | ||
| await page.addInitScript( | ||
| ({ storageKey }) => { | ||
| localStorage.setItem( | ||
| storageKey, | ||
| JSON.stringify({ | ||
| state: { | ||
| ui: { | ||
| activeSite: { kind: "all" }, | ||
| }, | ||
| }, | ||
| version: 0, | ||
| }), | ||
| ); | ||
| }, | ||
| { storageKey: ACTIVE_SITE_STORAGE_KEY }, | ||
| ); | ||
| } | ||
|
|
||
| export async function captureMovableMiner({ | ||
| page, | ||
| commonSteps, | ||
| minersPage, | ||
| }: { | ||
| page: Page; | ||
| commonSteps: CommonSteps; | ||
| minersPage: MinersPage; | ||
| }): Promise<ReparentMiner> { | ||
| const snapshots = await loadVisibleMiners({ page, commonSteps }); | ||
| const ipAddresses = await minersPage.getVisibleMinerIpAddresses(); | ||
|
|
||
| for (const ipAddress of ipAddresses) { | ||
| if (!(await minersPage.hasBlankBuildingAndRack(ipAddress))) { | ||
| continue; | ||
| } | ||
|
|
||
| if (await minersPage.hasAuthenticationRequiredIssue(ipAddress)) { | ||
| continue; | ||
| } | ||
|
|
||
| const snapshot = snapshots.find((candidate) => candidate.ipAddress === ipAddress); | ||
| if (!snapshot) { | ||
| continue; | ||
| } | ||
|
|
||
| return { | ||
| deviceIdentifier: snapshot.deviceIdentifier, | ||
| ipAddress, | ||
| originalSiteLabel: normalizePlacementLabel(await minersPage.getMinerColumnText(ipAddress, "site")), | ||
| originalRackLabel: snapshot.placement?.rack?.label, | ||
| originalRackPosition: snapshot.rackPosition, | ||
| }; | ||
| } | ||
|
|
||
| throw new Error("Expected at least one visible miner without building or rack placement for reparent coverage"); | ||
| } | ||
|
|
||
| export function expectSingleDeviceIdentifier(actual: string[] | undefined, expected: string) { | ||
| expect(actual).toEqual([expected]); | ||
| } | ||
|
|
||
| export async function restoreMinerPlacementIfNeeded({ | ||
| page, | ||
| minersPage, | ||
| racksPage, | ||
| miner, | ||
| }: { | ||
| page: Page; | ||
| minersPage: MinersPage; | ||
| racksPage: RacksPage; | ||
| miner: ReparentMiner | undefined; | ||
| }) { | ||
| if (!miner) { | ||
| return; | ||
| } | ||
|
|
||
| if (!miner.originalRackLabel) { | ||
| await restoreMinerSiteIfNeeded({ page, minersPage, miner }); | ||
| return; | ||
| } | ||
|
|
||
| await restoreMinerSiteIfNeeded({ page, minersPage, miner }); | ||
|
|
||
| if (miner.originalRackPosition) { | ||
| await restoreMinerRackSlotIfNeeded({ page, racksPage, miner }); | ||
| return; | ||
| } | ||
|
|
||
| await restoreMinerRackIfNeeded({ page, minersPage, miner }); | ||
| } | ||
|
|
||
| async function loadVisibleMiners({ | ||
| page, | ||
| commonSteps, | ||
| }: { | ||
| page: Page; | ||
| commonSteps: CommonSteps; | ||
| }): Promise<VisibleMinerSnapshot[]> { | ||
| const responsePromise = new Promise<VisibleMinerSnapshot[]>((resolve, reject) => { | ||
| const timeoutId = setTimeout(() => { | ||
| page.off("response", handleResponse); | ||
| reject(new Error(`Timed out waiting for ${LIST_MINERS_RESPONSE}`)); | ||
| }, 10000); | ||
|
|
||
| const handleResponse = async (response: PlaywrightResponse) => { | ||
| if (response.request().method() !== "POST" || !response.url().includes(LIST_MINERS_RESPONSE)) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const body = (await response.json()) as { | ||
| miners?: VisibleMinerSnapshot[]; | ||
| snapshots?: VisibleMinerSnapshot[]; | ||
| }; | ||
| const snapshots = body.snapshots ?? body.miners ?? []; | ||
| if (snapshots.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| clearTimeout(timeoutId); | ||
| page.off("response", handleResponse); | ||
| resolve(snapshots); | ||
| } catch (error) { | ||
| clearTimeout(timeoutId); | ||
| page.off("response", handleResponse); | ||
| reject(error); | ||
| } | ||
| }; | ||
|
|
||
| page.on("response", handleResponse); | ||
| }); | ||
|
|
||
| await commonSteps.goToMinersPage(); | ||
| return await responsePromise; | ||
| } | ||
|
|
||
| function normalizePlacementLabel(value: string | undefined): string | undefined { | ||
| const normalized = value?.trim(); | ||
| if (!normalized || normalized === "—") { | ||
| return undefined; | ||
| } | ||
|
|
||
| return normalized; | ||
| } | ||
|
|
||
| async function restoreMinerSiteIfNeeded({ | ||
| page, | ||
| minersPage, | ||
| miner, | ||
| }: { | ||
| page: Page; | ||
| minersPage: MinersPage; | ||
| miner: ReparentMiner; | ||
| }) { | ||
| if (!miner.originalSiteLabel) { | ||
| return; | ||
| } | ||
|
|
||
| await page.goto("/fleet/miners"); | ||
| await minersPage.waitForMinersListToLoad(); | ||
| await minersPage.clickMinerCheckbox(miner.ipAddress); | ||
| await minersPage.validateActionBarMinerCount(1); | ||
| await minersPage.assignSelectedMinersToSite(miner.originalSiteLabel); | ||
| } | ||
|
|
||
| async function restoreMinerRackSlotIfNeeded({ | ||
| page, | ||
| racksPage, | ||
| miner, | ||
| }: { | ||
| page: Page; | ||
| racksPage: RacksPage; | ||
| miner: ReparentMiner; | ||
| }) { | ||
| if (!miner.originalRackLabel || !miner.originalRackPosition) { | ||
| return; | ||
| } | ||
|
|
||
| const slotNumber = Number(miner.originalRackPosition); | ||
| if (Number.isNaN(slotNumber)) { | ||
| throw new Error(`Could not parse original rack position "${miner.originalRackPosition}"`); | ||
| } | ||
|
|
||
| await page.goto("/fleet/racks"); | ||
| await racksPage.clickViewList(); | ||
| await racksPage.waitForRackListToLoad({ allowEmpty: false }); | ||
| await racksPage.openRackFromList(miner.originalRackLabel); | ||
| await racksPage.clickRackOverviewEmptySlot(slotNumber); | ||
| await racksPage.assignSearchMinerByIpAddress(miner.ipAddress); | ||
| await racksPage.validateRackOverviewAssignedSlots([slotNumber]); | ||
| } | ||
|
|
||
| async function restoreMinerRackIfNeeded({ | ||
| page, | ||
| minersPage, | ||
| miner, | ||
| }: { | ||
| page: Page; | ||
| minersPage: MinersPage; | ||
| miner: ReparentMiner; | ||
| }) { | ||
| if (!miner.originalRackLabel) { | ||
| return; | ||
| } | ||
|
|
||
| await page.goto("/fleet/miners"); | ||
| await minersPage.waitForMinersListToLoad(); | ||
| await minersPage.clickMinerCheckbox(miner.ipAddress); | ||
| await minersPage.validateActionBarMinerCount(1); | ||
| await minersPage.assignSelectedMinersToRack(miner.originalRackLabel); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the Fleet layout mounts,
CompleteSetupalso sendsListMinerStateSnapshotsrequests for auth-needed/pool-needed counts before the Miners table request. This handler resolves on the first non-empty response regardless of its request filter, so in environments with any auth-required miners it can handcaptureMovableMineronly those background snapshots; the later loop skips auth-required rows and throws even though the visible miners list contains valid candidates. Keep listening until the response matches the table request or the visible IPs instead of resolving on any non-empty response.Useful? React with 👍 / 👎.