diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 79f39d4de..9d8ea49e1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,43 +1,36 @@ -# Pull Request Template - -## Description - - -## Motivation - - -## Type of Change -- [ ] New Feature -- [ ] Bug Fix -- [ ] Refactor / Code Cleanup -- [ ] Documentation Update -- [ ] Other (please specify) - -## Related Issue(s) - - -## Screenshots / Video - - -**Screenshot** (if applicable): - -```markdown -![Screenshot Description](path/to/screenshot.png) -``` - -**Video** (if applicable): - -```html - -``` +## Summary + + +## Related issue + + + +Fixes # + +## Type of change +- [ ] Bug fix +- [ ] Feature +- [ ] Enhancement +- [ ] Documentation +- [ ] Refactor / maintenance +- [ ] Performance +- [ ] Security + +## Release impact +- [ ] Patch +- [ ] Minor +- [ ] Major / breaking change +- [ ] No release note needed + +## Desktop impact +- [ ] Windows +- [ ] macOS +- [ ] Linux +- [ ] Installer / packaging +- [ ] Not platform-specific + +## Screenshots / video + ## Testing - - -## Checklist -- [ ] I have performed a self-review of my code. -- [ ] I have added any necessary screenshots or videos. -- [ ] I have linked related issue(s) and updated the changelog if applicable. - ---- -*Thank you for contributing!* + diff --git a/.github/workflows/merged-pr-bookkeeping.yml b/.github/workflows/merged-pr-bookkeeping.yml new file mode 100644 index 000000000..8b0671325 --- /dev/null +++ b/.github/workflows/merged-pr-bookkeeping.yml @@ -0,0 +1,252 @@ +name: Merged PR issue bookkeeping + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + mark-linked-issues-fixed: + name: Mark linked issues fixed in main + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Update closing issues + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullRequest = context.payload.pull_request; + const pullNumber = pullRequest.number; + + const fixedInMainLabel = { + name: "status: fixed in main", + color: "0E8A16", + description: "Work is merged into main but may not be in a downloadable release yet.", + }; + const pendingReleaseLabel = { + name: "status: pending release", + color: "FBCA04", + description: "Merged change is waiting for a packaged desktop release.", + }; + const labelsToRemove = ["status: in progress", "status: needs triage"]; + const nextReleaseMilestoneTitle = "Next Release"; + + async function ensureLabel(label) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) throw error; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Created label '${label.name}'.`); + } catch (createError) { + if (createError.status !== 422) throw createError; + core.info(`Label '${label.name}' already exists.`); + } + } + } + + async function ensureMilestone(title) { + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const existing = milestones.find((milestone) => milestone.title === title); + if (existing?.state === "open") return existing; + if (existing) { + const reopened = await github.rest.issues.updateMilestone({ + owner, + repo, + milestone_number: existing.number, + state: "open", + }); + core.info(`Reopened milestone '${title}'.`); + return reopened.data; + } + + try { + const created = await github.rest.issues.createMilestone({ + owner, + repo, + title, + description: "Merged changes queued for the next packaged desktop release.", + }); + core.info(`Created milestone '${title}'.`); + return created.data; + } catch (error) { + if (error.status !== 422) throw error; + const refreshed = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const milestone = refreshed.find((item) => item.title === title); + if (!milestone) throw error; + return milestone; + } + } + + async function getClosingIssueRefs() { + const query = ` + query($owner: String!, $repo: String!, $pullNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullNumber) { + closingIssuesReferences(first: 100, after: $cursor) { + nodes { + number + repository { + name + owner { + login + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const issueRefs = new Map(); + let cursor = null; + let hasNextPage = true; + + while (hasNextPage) { + const result = await github.graphql(query, { + owner, + repo, + pullNumber, + cursor, + }); + const refs = result.repository.pullRequest.closingIssuesReferences; + for (const issue of refs.nodes) { + const issueOwner = issue.repository.owner.login; + const issueRepo = issue.repository.name; + issueRefs.set(`${issueOwner}/${issueRepo}#${issue.number}`, { + owner: issueOwner, + repo: issueRepo, + number: issue.number, + }); + } + hasNextPage = refs.pageInfo.hasNextPage; + cursor = refs.pageInfo.endCursor; + } + + return [...issueRefs.values()]; + } + + async function hasBookkeepingComment(issueNumber, marker) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.some((comment) => comment.body && comment.body.includes(marker)); + } + + await ensureLabel(fixedInMainLabel); + await ensureLabel(pendingReleaseLabel); + const fallbackMilestone = pullRequest.milestone || await ensureMilestone(nextReleaseMilestoneTitle); + + const issueRefs = await getClosingIssueRefs(); + if (issueRefs.length === 0) { + core.info(`PR #${pullNumber} did not declare closing issue references. Nothing to update.`); + return; + } + + for (const issueRef of issueRefs) { + if ( + issueRef.owner.toLowerCase() !== owner.toLowerCase() || + issueRef.repo.toLowerCase() !== repo.toLowerCase() + ) { + core.warning( + `Skipping cross-repository closing reference ${issueRef.owner}/${issueRef.repo}#${issueRef.number}; ` + + `this workflow only updates issues in ${owner}/${repo}.`, + ); + continue; + } + + const issueNumber = issueRef.number; + const issueResponse = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + const issue = issueResponse.data; + const existingLabels = issue.labels.map((label) => + typeof label === "string" ? label : label.name, + ); + const milestoneTitle = issue.milestone?.title || fallbackMilestone.title; + const milestoneNumber = issue.milestone?.number || fallbackMilestone.number; + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [fixedInMainLabel.name, pendingReleaseLabel.name], + }); + + for (const label of labelsToRemove) { + if (!existingLabels.includes(label)) continue; + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: label, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + milestone: milestoneNumber, + state: "closed", + state_reason: "completed", + }); + + const marker = ``; + if (!(await hasBookkeepingComment(issueNumber, marker))) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: [ + marker, + `Fixed by #${pullNumber} and merged into \`main\`.`, + "", + `This change is assigned to the \`${milestoneTitle}\` release milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked as \`${pendingReleaseLabel.name}\` until a packaged release containing it is published.`, + ].join("\n"), + }); + } + + core.info(`Updated issue #${issueNumber} for merged PR #${pullNumber}.`); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5f30d4f5..d4d82f7b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,32 @@ Thank you for considering contributing to this project! By contributing, you hel If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/EtienneLescot/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. +## Issue lifecycle + +Issues are closed when the corresponding fix or feature is merged into `main`. + +For desktop users, this does not always mean the change is already available in the latest downloadable release. When relevant, closed issues are marked as `status: fixed in main` and `status: pending release`. + +Once a GitHub Release containing the change is published, the issue can be marked as `status: released`. + +The next version number is not always known when a PR is merged. In that case, issues are assigned to the `Next Release` milestone. When preparing a release, this milestone can be renamed to the actual version, such as `v1.6.0` or `v2.0.0`, and a new `Next Release` milestone can be created. + +When a PR fully resolves an issue, link it with a GitHub closing keyword: + +```txt +Fixes #123 +Closes #123 +Resolves #123 +``` + +If a PR only partially addresses an issue, use a non-closing reference instead: + +```txt +Refs #123 +Part of #123 +Related to #123 +``` + ## Style Guide - Write clear, concise, and descriptive commit messages. diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 18d44f1ae..a4972be61 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -43,6 +43,8 @@ interface Window { }>; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; + onSourceSelectorClosed: (callback: () => void) => () => void; requestCameraAccess: () => Promise<{ success: boolean; granted: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1eb569efe..d02c6cdec 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1336,6 +1336,10 @@ export function registerIpcHandlers( selectedDesktopSource = null; } } + const mainWin = getMainWindow(); + if (mainWin && !mainWin.isDestroyed()) { + mainWin.webContents.send("selected-source-changed", selectedSource); + } const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { sourceSelectorWin.close(); diff --git a/electron/main.ts b/electron/main.ts index bfb949ed7..11d545fdf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -411,6 +411,9 @@ function createSourceSelectorWindowWrapper() { sourceSelectorWindow = createSourceSelectorWindow(); sourceSelectorWindow.on("closed", () => { sourceSelectorWindow = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("source-selector-closed"); + } }); return sourceSelectorWindow; } diff --git a/electron/preload.ts b/electron/preload.ts index 96cc831fd..fefcac044 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => { + const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source); + ipcRenderer.on("selected-source-changed", listener); + return () => ipcRenderer.removeListener("selected-source-changed", listener); + }, + onSourceSelectorClosed: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("source-selector-closed", listener); + return () => ipcRenderer.removeListener("source-selector-closed", listener); + }, requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx new file mode 100644 index 000000000..2bf9c2dbe --- /dev/null +++ b/src/components/launch/LaunchWindow.test.tsx @@ -0,0 +1,286 @@ +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui/tooltip"; +import { LaunchWindow } from "./LaunchWindow"; + +type SelectedSourceChangedListener = Parameters< + Window["electronAPI"]["onSelectedSourceChanged"] +>[0]; + +const recorderState = vi.hoisted(() => ({ + value: { + recording: false, + paused: false, + elapsedSeconds: 0, + toggleRecording: vi.fn(), + togglePaused: vi.fn(), + canPauseRecording: false, + restartRecording: vi.fn(), + cancelRecording: vi.fn(), + microphoneEnabled: false, + setMicrophoneEnabled: vi.fn(), + microphoneDeviceId: undefined, + setMicrophoneDeviceId: vi.fn(), + setMicrophoneDeviceName: vi.fn(), + webcamEnabled: false, + setWebcamEnabled: vi.fn(async () => true), + webcamDeviceId: undefined, + setWebcamDeviceId: vi.fn(), + setWebcamDeviceName: vi.fn(), + systemAudioEnabled: false, + setSystemAudioEnabled: vi.fn(), + cursorCaptureMode: "editable-overlay", + setCursorCaptureMode: vi.fn(), + }, +})); + +let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; +let sourceSelectorClosedListeners: Array<() => void> = []; + +vi.mock("../../hooks/useScreenRecorder", () => ({ + useScreenRecorder: () => recorderState.value, +})); + +vi.mock("../../hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: () => ({ + devices: [], + selectedDeviceId: "default", + setSelectedDeviceId: vi.fn(), + }), +})); + +vi.mock("../../hooks/useCameraDevices", () => ({ + useCameraDevices: () => ({ + devices: [], + selectedDeviceId: "", + setSelectedDeviceId: vi.fn(), + isLoading: false, + error: null, + }), +})); + +vi.mock("../../hooks/useAudioLevelMeter", () => ({ + useAudioLevelMeter: () => ({ level: 0 }), +})); + +vi.mock("../../lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +vi.mock("@/native", () => ({ + nativeBridgeClient: { + system: { + getPlatform: vi.fn(async () => "darwin"), + }, + }, +})); + +vi.mock("@/i18n/loader", () => ({ + getAvailableLocales: () => ["en"], + getLocaleName: () => "English", +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ + locale: "en", + setLocale: vi.fn(), + systemLocaleSuggestion: null, + acceptSystemLocaleSuggestion: vi.fn(), + dismissSystemLocaleSuggestion: vi.fn(), + resolveSystemLocaleSuggestion: vi.fn(), + }), + useScopedT: () => (key: string) => { + const translations: Record = { + "sourceSelector.defaultSourceName": "Screen", + "recording.selectSource": "Please select a source to record", + "tooltips.useVerticalTray": "Use vertical tray", + "tooltips.useHorizontalTray": "Use horizontal tray", + "audio.enableSystemAudio": "Enable system audio", + "audio.disableSystemAudio": "Disable system audio", + "audio.enableMicrophone": "Enable microphone", + "audio.disableMicrophone": "Disable microphone", + "audio.defaultMicrophone": "Default Microphone", + "webcam.enableWebcam": "Enable webcam", + "webcam.disableWebcam": "Disable webcam", + "webcam.defaultCamera": "Default Camera", + "webcam.searching": "Searching...", + "webcam.noneFound": "No camera found", + "webcam.unavailable": "Camera unavailable", + "cursor.useEditableCursor": "Use editable cursor", + "cursor.useSystemCursor": "Use system cursor", + "tooltips.openStudio": "Open Studio", + "tooltips.hideHUD": "Hide HUD", + "tooltips.closeApp": "Close App", + language: "Language", + }; + return translations[key] ?? key; + }, +})); + +function renderLaunchWindow() { + return render( + + + , + ); +} + +function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) { + window.electronAPI = { + ...window.electronAPI, + getSelectedSource, + openSourceSelector: vi.fn(async () => ({ opened: true })), + requestScreenAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + getPlatform: vi.fn(async () => "darwin"), + setHudOverlaySize: vi.fn(), + setHudOverlayIgnoreMouseEvents: vi.fn(), + moveHudOverlayBy: vi.fn(), + hudOverlayHide: vi.fn(), + hudOverlayClose: vi.fn(), + switchToEditor: vi.fn(async () => undefined), + onSelectedSourceChanged: vi.fn((callback) => { + selectedSourceChangedListeners.push(callback); + return () => { + selectedSourceChangedListeners = selectedSourceChangedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + onSourceSelectorClosed: vi.fn((callback) => { + sourceSelectorClosedListeners.push(callback); + return () => { + sourceSelectorClosedListeners = sourceSelectorClosedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + } as typeof window.electronAPI; +} + +const displayOneSource = { + id: "screen:1:0", + name: "Display 1", + display_id: "1", + thumbnail: null, + appIcon: null, +} satisfies ProcessedDesktopSource; + +async function waitForSourceSelectionSubscription() { + await waitFor(() => { + expect(selectedSourceChangedListeners.length).toBeGreaterThan(0); + }); +} + +function emitSelectedSourceChanged(source: ProcessedDesktopSource) { + act(() => { + selectedSourceChangedListeners.forEach((listener) => listener(source)); + }); +} + +function emitSourceSelectorClosed() { + act(() => { + sourceSelectorClosedListeners.forEach((listener) => listener()); + }); +} + +describe("LaunchWindow record button", () => { + beforeEach(() => { + class ResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } + } + vi.stubGlobal("ResizeObserver", ResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + stubElectronAPI(vi.fn(async () => null)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("opens the source selector instead of disabling the primary action when no source is selected", async () => { + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + + expect(recordButton).toBeEnabled(); + expect(recordButton).toHaveAttribute("title", "Please select a source to record"); + + fireEvent.click(recordButton); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("records immediately after source selection when the record button opened the picker", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + + it("does not record after manual source selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("clears record-after-selection intent when the source picker closes without a selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSourceSelectorClosed(); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("starts recording when a source is already selected", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Display 1"); + }); + + fireEvent.click(recordButton); + + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a93514c5a..373aeac55 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -420,21 +420,31 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); - const [selectedSource, setSelectedSource] = useState("Screen"); + const defaultSourceName = t("sourceSelector.defaultSourceName"); + const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [, setRecordPointerDownCount] = useState(0); + const recordAfterSourceSelectionRef = useRef(false); + + const applySelectedSource = useCallback( + (source: ProcessedDesktopSource | null) => { + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + + setSelectedSource(defaultSourceName); + setHasSelectedSource(false); + }, + [defaultSourceName], + ); useEffect(() => { const checkSelectedSource = async () => { if (window.electronAPI) { const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } + applySelectedSource(source); } }; @@ -442,15 +452,51 @@ export function LaunchWindow() { const interval = setInterval(checkSelectedSource, 500); return () => clearInterval(interval); - }, []); + }, [applySelectedSource]); + + useEffect(() => { + const cleanupSourceChanged = window.electronAPI?.onSelectedSourceChanged?.((source) => { + applySelectedSource(source); + if (!recordAfterSourceSelectionRef.current || recording) { + return; + } + + recordAfterSourceSelectionRef.current = false; + toggleRecording(); + }); + const cleanupSelectorClosed = window.electronAPI?.onSourceSelectorClosed?.(() => { + recordAfterSourceSelectionRef.current = false; + }); + + return () => { + cleanupSourceChanged?.(); + cleanupSelectorClosed?.(); + }; + }, [applySelectedSource, recording, toggleRecording]); const openSourceSelector = async () => { if (window.electronAPI) { - await openSourceSelectorWithPermissionRetry({ + return await openSourceSelectorWithPermissionRetry({ openSourceSelector: () => window.electronAPI.openSourceSelector(), requestScreenAccess: () => window.electronAPI.requestScreenAccess(), }); } + + return { opened: false, reason: "electron-api-unavailable" }; + }; + + const handleRecordButtonClick = () => { + if (!hasSelectedSource && !recording) { + recordAfterSourceSelectionRef.current = true; + void openSourceSelector().then((result) => { + if (!result.opened) { + recordAfterSourceSelectionRef.current = false; + } + }); + return; + } + + toggleRecording(); }; const sendHudOverlayHide = () => { @@ -844,32 +890,41 @@ export function LaunchWindow() { {/* Record/Stop group */} - + + {recording && (
{ await hudWindow.waitForLoadState("domcontentloaded"); await dismissLanguagePrompt(hudWindow); - await expect(hudWindow.getByTestId("launch-record-button")).toBeDisabled(); + await expect(hudWindow.getByTestId("launch-record-button")).toBeEnabled(); await expect(hudWindow.getByTestId("launch-source-selector-button")).toBeVisible(); await expect(hudWindow.getByTestId("launch-system-audio-button")).toBeEnabled(); await expect(hudWindow.getByTestId("launch-microphone-button")).toBeEnabled();