diff --git a/.github/actions/setup-ci-environment/action.yml b/.github/actions/setup-ci-environment/action.yml new file mode 100644 index 00000000000..564c28bafac --- /dev/null +++ b/.github/actions/setup-ci-environment/action.yml @@ -0,0 +1,39 @@ +name: Setup CI Environment +description: Set up Python, Node.js, optional ffmpeg, and install dependencies. + +inputs: + node-version: + description: Node.js version to use. + required: true + arch: + description: Target architecture for dependency installation. + required: true + install-ffmpeg: + description: Whether to install ffmpeg on Windows. + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Use Node.js ${{ inputs.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: yarn + + - name: Install ffmpeg + if: ${{ runner.os == 'Windows' && inputs.install-ffmpeg == 'true' }} + shell: bash + run: choco install ffmpeg --yes --no-progress + + - name: Install and build dependencies + shell: bash + run: yarn + env: + npm_config_arch: ${{ inputs.arch }} + TARGET_ARCH: ${{ inputs.arch }} diff --git a/.github/actions/setup-windows-signing/action.yml b/.github/actions/setup-windows-signing/action.yml new file mode 100644 index 00000000000..7b09408aa39 --- /dev/null +++ b/.github/actions/setup-windows-signing/action.yml @@ -0,0 +1,35 @@ +name: Setup Windows Signing +description: Install Azure Code Signing prerequisites and authenticate. + +inputs: + enabled: + description: Whether Windows signing setup should run. + required: false + default: 'false' + azure-client-id: + description: Azure Code Signing client ID. + required: false + azure-tenant-id: + description: Azure Code Signing tenant ID. + required: false + +runs: + using: composite + steps: + - name: Install Azure Code Signing Client + if: ${{ runner.os == 'Windows' && inputs.enabled == 'true' }} + shell: pwsh + run: | + $acsZip = Join-Path $env:RUNNER_TEMP "acs.zip" + $acsDir = Join-Path $env:RUNNER_TEMP "acs" + Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -OutFile $acsZip -Verbose + Expand-Archive $acsZip -Destination $acsDir -Force -Verbose + Copy-Item -Path "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\*" -Include signtool.exe,signtool.exe.manifest,Microsoft.Windows.Build.Signing.mssign32.dll.manifest,mssign32.dll,Microsoft.Windows.Build.Signing.wintrust.dll.manifest,wintrust.dll,Microsoft.Windows.Build.Appx.AppxSip.dll.manifest,AppxSip.dll,Microsoft.Windows.Build.Appx.AppxPackaging.dll.manifest,AppxPackaging.dll,Microsoft.Windows.Build.Appx.OpcServices.dll.manifest,OpcServices.dll -Destination "node_modules\electron-winstaller\vendor" -Verbose + + - name: Azure Login (OIDC) + if: ${{ runner.os == 'Windows' && inputs.enabled == 'true' }} + uses: azure/login@v2 + with: + client-id: ${{ inputs.azure-client-id }} + tenant-id: ${{ inputs.azure-tenant-id }} + allow-no-subscriptions: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8eaad1bba..e98fa820cf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,19 +137,10 @@ jobs: repository: ${{ inputs.repository || github.repository }} ref: ${{ inputs.ref }} submodules: recursive - - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v6 + - uses: ./.github/actions/setup-ci-environment with: node-version: ${{ env.NODE_VERSION }} - cache: yarn - - name: Install and build dependencies - run: yarn - env: - npm_config_arch: ${{ matrix.arch }} - TARGET_ARCH: ${{ matrix.arch }} + arch: ${{ matrix.arch }} - name: Validate macOS version if: runner.os == 'macOS' run: yarn validate-macos-version @@ -189,22 +180,12 @@ jobs: run: yarn test:unit - name: Run script tests run: yarn test:script - - name: Install Azure Code Signing Client - if: ${{ runner.os == 'Windows' }} - run: | - $acsZip = Join-Path $env:RUNNER_TEMP "acs.zip" - $acsDir = Join-Path $env:RUNNER_TEMP "acs" - Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -OutFile $acsZip -Verbose - Expand-Archive $acsZip -Destination $acsDir -Force -Verbose - # Replace ancient signtool in electron-winstall with one that supports ACS - Copy-Item -Path "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\*" -Include signtool.exe,signtool.exe.manifest,Microsoft.Windows.Build.Signing.mssign32.dll.manifest,mssign32.dll,Microsoft.Windows.Build.Signing.wintrust.dll.manifest,wintrust.dll,Microsoft.Windows.Build.Appx.AppxSip.dll.manifest,AppxSip.dll,Microsoft.Windows.Build.Appx.AppxPackaging.dll.manifest,AppxPackaging.dll,Microsoft.Windows.Build.Appx.OpcServices.dll.manifest,OpcServices.dll -Destination "node_modules\electron-winstaller\vendor" -Verbose - - name: Azure Login (OIDC) - if: ${{ runner.os == 'Windows' && inputs.sign }} - uses: azure/login@v2 + - if: runner.os == 'Windows' + uses: ./.github/actions/setup-windows-signing with: - client-id: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} - allow-no-subscriptions: true + enabled: ${{ inputs.sign }} + azure-client-id: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} - name: Package production app run: yarn package env: @@ -233,6 +214,137 @@ jobs: dist/bundle-size.json if-no-files-found: error + e2e-smoke: + name: E2E Smoke ${{ matrix.friendlyName }} ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + friendlyName: macOS + arch: arm64 + - os: windows-2022 + friendlyName: Windows + arch: x64 + timeout-minutes: 60 + environment: ${{ inputs.environment }} + env: + RELEASE_CHANNEL: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref }} + submodules: recursive + - uses: ./.github/actions/setup-ci-environment + with: + node-version: ${{ env.NODE_VERSION }} + arch: ${{ matrix.arch }} + install-ffmpeg: 'true' + - name: Build production app + run: yarn build:prod + env: + DESKTOP_E2E_UPDATES_URL: http://127.0.0.1:51789/update + DESKTOP_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_OAUTH_CLIENT_ID }} + DESKTOP_OAUTH_CLIENT_SECRET: + ${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }} + KEY_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }} + npm_config_arch: ${{ matrix.arch }} + TARGET_ARCH: ${{ matrix.arch }} + - name: Prepare testing environment + run: yarn test:setup + env: + npm_config_arch: ${{ matrix.arch }} + - if: runner.os == 'Windows' + uses: ./.github/actions/setup-windows-signing + with: + enabled: ${{ inputs.sign }} + azure-client-id: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} + - name: Package production app + run: yarn package + env: + npm_config_arch: ${{ matrix.arch }} + AZURE_TENANT_ID: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} + - name: Install app on macOS + if: runner.os == 'macOS' + run: | + rm -rf "/Applications/GitHub Desktop Plus.app" + ditto "dist/GitHub Desktop Plus-darwin-arm64/GitHub Desktop Plus.app" "/Applications/GitHub Desktop Plus.app" + echo "DESKTOP_E2E_APP_PATH=/Applications/GitHub Desktop Plus.app/Contents/MacOS/GitHub Desktop Plus" >> "$GITHUB_ENV" + - name: Install app on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + function Write-SquirrelLogs { + $logPaths = @( + "$env:LOCALAPPDATA\SquirrelSetup.log", + "$env:LOCALAPPDATA\GitHubDesktopPlus\SquirrelSetup.log" + ) + + foreach ($logPath in $logPaths) { + if (Test-Path $logPath) { + Write-Host "Showing log: $logPath" + Get-Content $logPath -Tail 200 + } + } + } + + $setupExe = Get-ChildItem "dist/GitHubDesktopPlus-*-windows-${{ matrix.arch }}.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + + if (-not $setupExe) { + throw "Unable to locate Windows installer executable" + } + + $installer = Start-Process -FilePath $setupExe -ArgumentList "/S" -PassThru + + try { + Wait-Process -Id $installer.Id -Timeout 300 -ErrorAction Stop + } catch { + Write-SquirrelLogs + throw "Windows installer timed out after 300 seconds" + } + + Get-Process GitHubDesktopPlus -ErrorAction SilentlyContinue | Stop-Process -Force + + $installedExe = $null + for ($attempt = 0; $attempt -lt 30 -and -not $installedExe; $attempt++) { + $installedExe = Get-ChildItem "$env:LOCALAPPDATA\GitHubDesktopPlus\app-*\GitHubDesktopPlus.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + + if (-not $installedExe) { + Start-Sleep -Seconds 2 + } + } + + if (-not $installedExe) { + Write-SquirrelLogs + throw "Unable to locate installed GitHub Desktop executable" + } + + Add-Content -Path $env:GITHUB_ENV -Value "DESKTOP_E2E_APP_PATH=$installedExe" + - name: Run packaged E2E smoke tests + run: yarn test:e2e:run:packaged + - name: Upload E2E artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-${{matrix.friendlyName}}-${{matrix.arch}} + path: playwright-videos/** + if-no-files-found: warn + release_github: name: Create GitHub release needs: [build, compute_version] diff --git a/.gitignore b/.gitignore index d6ecbdedefc..6f55d57dbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ junit*.xml *.swp tslint-rules/ script/release_notes.txt +playwright-videos/ diff --git a/app/app-info.ts b/app/app-info.ts index e9eb3184975..a9771adee02 100644 --- a/app/app-info.ts +++ b/app/app-info.ts @@ -46,7 +46,7 @@ export function getReplacements() { __DEV__: isDevBuild, __DEV_SECRETS__: isDevBuild || !process.env.DESKTOP_OAUTH_CLIENT_SECRET, __RELEASE_CHANNEL__: s(channel), - __UPDATES_URL__: s(getUpdatesURL()), + __UPDATES_URL__: s(process.env.DESKTOP_E2E_UPDATES_URL ?? getUpdatesURL()), __SHA__: s(getSHA()), 'process.platform': s(process.platform), 'process.env.NODE_ENV': s(process.env.NODE_ENV || 'development'), diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 8e6e2a63053..b4491cf1e08 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1563,6 +1563,8 @@ export class App extends React.Component { dispatcher={this.props.dispatcher} repository={popup.repository} branch={popup.branch} + accounts={this.state.accounts} + cachedRepoRulesets={this.state.cachedRepoRulesets} onDismissed={onPopupDismissedFn} /> ) diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 890fbae62e2..b7d4203a3bc 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -373,7 +373,7 @@ export class BranchList extends React.Component { private onRenderNewButton = () => { return this.props.canCreateNewBranch ? ( - diff --git a/app/src/ui/create-branch/create-branch-dialog.tsx b/app/src/ui/create-branch/create-branch-dialog.tsx index a49e94559b6..cbf10aeb99b 100644 --- a/app/src/ui/create-branch/create-branch-dialog.tsx +++ b/app/src/ui/create-branch/create-branch-dialog.tsx @@ -1,9 +1,6 @@ import * as React from 'react' -import { - Repository, - isRepositoryWithGitHubRepository, -} from '../../models/repository' +import { Repository } from '../../models/repository' import { Dispatcher } from '../dispatcher' import { Branch, StartPoint } from '../../models/branch' import { Row } from '../lib/row' @@ -31,12 +28,13 @@ import { CommitOneLine } from '../../models/commit' import { PopupType } from '../../models/popup' import { RepositorySettingsTab } from '../repository-settings/repository-settings' import { isRepositoryWithForkedGitHubRepository } from '../../models/repository' -import { API, APIRepoRuleType, IAPIRepoRuleset } from '../../lib/api' +import { IAPIRepoRuleset } from '../../lib/api' import { Account } from '../../models/account' -import { getAccountForRepository } from '../../lib/get-account-for-repository' -import { InputError } from '../lib/input-description/input-error' -import { InputWarning } from '../lib/input-description/input-warning' -import { parseRepoRules, useRepoRulesLogic } from '../../lib/helpers/repo-rules' +import { + IBranchRuleError, + checkBranchNameRules, + renderBranchNameRuleError, +} from '../lib/branch-name-rule-validation' import { IBranchNamePreset } from '../../models/branch-preset' interface ICreateBranchProps { @@ -75,7 +73,7 @@ interface ICreateBranchProps { } interface ICreateBranchState { - readonly currentError: { error: Error; isWarning: boolean } | null + readonly currentError: IBranchRuleError | null readonly branchName: string readonly startPoint: StartPoint @@ -226,37 +224,6 @@ export class CreateBranch extends React.Component< } } - private renderBranchNameErrors() { - const { currentError } = this.state - if (!currentError) { - return null - } - - if (currentError.isWarning) { - return ( - - - {currentError.error.message} - - - ) - } else { - return ( - - - {currentError.error.message} - - - ) - } - } - private renderBranchNamePresets() { const branchNamePresets = this.state.branchNamePresets if (branchNamePresets.length === 0) { @@ -322,7 +289,11 @@ export class CreateBranch extends React.Component< onKeyDown={this.onKeyDown} /> - {this.renderBranchNameErrors()} + {renderBranchNameRuleError( + this.state.currentError, + this.ERRORS_ID, + this.state.branchName + )} {this.renderBranchNamePresets()} @@ -408,114 +379,29 @@ export class CreateBranch extends React.Component< }) } - /** - * Checks repo rules to see if the provided branch name is valid for the - * current user and repository. The "get all rules for a branch" endpoint - * is called first, and if a "creation" or "branch name" rule is found, - * then those rulesets are checked to see if the current user can bypass - * them. - */ private checkBranchRules = async (branchName: string) => { if ( this.state.branchName !== branchName || - this.props.accounts.length === 0 || - !isRepositoryWithGitHubRepository(this.props.repository) || branchName === '' || this.state.currentError !== null ) { return } - const account = getAccountForRepository( + const result = await checkBranchNameRules( + branchName, this.props.accounts, - this.props.repository + this.props.repository, + this.props.cachedRepoRulesets ) - if ( - account === null || - !useRepoRulesLogic(account, this.props.repository) - ) { - return - } - - const api = API.fromAccount(account) - const branchRules = await api.fetchRepoRulesForBranch( - this.props.repository.gitHubRepository.owner.login, - this.props.repository.gitHubRepository.name, - branchName - ) - - // Make sure user branch name hasn't changed during api call + // Make sure user branch name hasn't changed during async calls if (this.state.branchName !== branchName) { return } - // filter the rules to only the relevant ones and get their IDs. use a Set to dedupe. - const toCheck = new Set( - branchRules - .filter( - r => - r.type === APIRepoRuleType.Creation || - r.type === APIRepoRuleType.BranchNamePattern - ) - .map(r => r.ruleset_id) - ) - - // there are no relevant rules for this branch name, so return - if (toCheck.size === 0) { - return - } - - // check for actual failures - const { branchNamePatterns, creationRestricted } = await parseRepoRules( - branchRules, - this.props.cachedRepoRulesets, - this.props.repository - ) - - // Make sure user branch name hasn't changed during parsing of repo rules - // (async due to a config retrieval of users with commit signing repo rules) - if (this.state.branchName !== branchName) { - return - } - - const { status } = branchNamePatterns.getFailedRules(branchName) - - // Only possible kind of failures is branch name pattern failures and creation restriction - if (creationRestricted !== true && status === 'pass') { - return - } - - // check cached rulesets to see which ones the user can bypass - let cannotBypass = false - for (const id of toCheck) { - const rs = this.props.cachedRepoRulesets.get(id) - - if (rs?.current_user_can_bypass !== 'always') { - // the user cannot bypass, so stop checking - cannotBypass = true - break - } - } - - if (cannotBypass) { - this.setState({ - currentError: { - error: new Error( - `Branch name '${branchName}' is restricted by repo rules.` - ), - isWarning: false, - }, - }) - } else { - this.setState({ - currentError: { - error: new Error( - `Branch name '${branchName}' is restricted by repo rules, but you can bypass them. Proceed with caution!` - ), - isWarning: true, - }, - }) + if (result !== null) { + this.setState({ currentError: result }) } } diff --git a/app/src/ui/lib/branch-name-rule-validation.tsx b/app/src/ui/lib/branch-name-rule-validation.tsx new file mode 100644 index 00000000000..e3bd4167180 --- /dev/null +++ b/app/src/ui/lib/branch-name-rule-validation.tsx @@ -0,0 +1,145 @@ +import * as React from 'react' + +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { API, APIRepoRuleType, IAPIRepoRuleset } from '../../lib/api' +import { Account } from '../../models/account' +import { getAccountForRepository } from '../../lib/get-account-for-repository' +import { parseRepoRules, useRepoRulesLogic } from '../../lib/helpers/repo-rules' +import { InputError } from './input-description/input-error' +import { InputWarning } from './input-description/input-warning' +import { Row } from './row' + +/** The result of a branch name rule check. */ +export interface IBranchRuleError { + readonly error: Error + readonly isWarning: boolean +} + +/** + * Checks repo rules to see if the provided branch name is valid for the + * current user and repository. The "get all rules for a branch" endpoint + * is called first, and if a "creation" or "branch name" rule is found, + * then those rulesets are checked to see if the current user can bypass + * them. + * + * Returns `null` if the branch name passes all rules or if validation + * cannot be performed (e.g. no accounts, non-GitHub repo). + */ +export async function checkBranchNameRules( + branchName: string, + accounts: ReadonlyArray, + repository: Repository, + cachedRepoRulesets: ReadonlyMap +): Promise { + if ( + accounts.length === 0 || + !isRepositoryWithGitHubRepository(repository) || + branchName === '' + ) { + return null + } + + const account = getAccountForRepository(accounts, repository) + + if (account === null || !useRepoRulesLogic(account, repository)) { + return null + } + + const api = API.fromAccount(account) + const branchRules = await api.fetchRepoRulesForBranch( + repository.gitHubRepository.owner.login, + repository.gitHubRepository.name, + branchName + ) + + // filter the rules to only the relevant ones and get their IDs. use a Set to dedupe. + const toCheck = new Set( + branchRules + .filter( + r => + r.type === APIRepoRuleType.Creation || + r.type === APIRepoRuleType.BranchNamePattern + ) + .map(r => r.ruleset_id) + ) + + // there are no relevant rules for this branch name + if (toCheck.size === 0) { + return null + } + + // check for actual failures + const { branchNamePatterns, creationRestricted } = await parseRepoRules( + branchRules, + cachedRepoRulesets, + repository + ) + + const { status } = branchNamePatterns.getFailedRules(branchName) + + if (creationRestricted !== true && status === 'pass') { + return null + } + + // check cached rulesets to see which ones the user can bypass + let cannotBypass = false + for (const id of toCheck) { + const rs = cachedRepoRulesets.get(id) + + if (rs?.current_user_can_bypass !== 'always') { + cannotBypass = true + break + } + } + + if (cannotBypass) { + return { + error: new Error( + `Branch name '${branchName}' is restricted by repo rules.` + ), + isWarning: false, + } + } + + return { + error: new Error( + `Branch name '${branchName}' is restricted by repo rules, but you can bypass them. Proceed with caution!` + ), + isWarning: true, + } +} + +/** + * Renders an error or warning row for branch name rule violations. + * Returns `null` if there is no error. + */ +export function renderBranchNameRuleError( + currentError: IBranchRuleError | null, + errorsId: string, + trackedUserInput: string +): React.ReactElement | null { + if (currentError === null) { + return null + } + + if (currentError.isWarning) { + return ( + + + {currentError.error.message} + + + ) + } + + return ( + + + {currentError.error.message} + + + ) +} diff --git a/app/src/ui/rename-branch/rename-branch-dialog.tsx b/app/src/ui/rename-branch/rename-branch-dialog.tsx index 8738b448ee6..dd404e7e6c5 100644 --- a/app/src/ui/rename-branch/rename-branch-dialog.tsx +++ b/app/src/ui/rename-branch/rename-branch-dialog.tsx @@ -7,29 +7,62 @@ import { Dialog, DialogContent, DialogFooter } from '../dialog' import { renderBranchHasRemoteWarning } from '../lib/branch-name-warnings' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { RefNameTextBox } from '../lib/ref-name-text-box' +import { IAPIRepoRuleset } from '../../lib/api' +import { Account } from '../../models/account' +import { + IBranchRuleError, + checkBranchNameRules, + renderBranchNameRuleError, +} from '../lib/branch-name-rule-validation' interface IRenameBranchProps { readonly dispatcher: Dispatcher readonly onDismissed: () => void readonly repository: Repository readonly branch: Branch + readonly accounts: ReadonlyArray + readonly cachedRepoRulesets: ReadonlyMap } interface IRenameBranchState { readonly newName: string + readonly currentError: IBranchRuleError | null } export class RenameBranch extends React.Component< IRenameBranchProps, IRenameBranchState > { + private branchRulesDebounceId: number | null = null + + private readonly ERRORS_ID = 'rename-branch-name-errors' + public constructor(props: IRenameBranchProps) { super(props) - this.state = { newName: props.branch.name } + this.state = { newName: props.branch.name, currentError: null } + } + + public componentDidMount() { + // Validate the pre-filled branch name on dialog open so existing + // rule violations are shown immediately. + if (this.state.newName !== '') { + this.checkBranchRules(this.state.newName) + } + } + + public componentWillUnmount() { + if (this.branchRulesDebounceId !== null) { + window.clearTimeout(this.branchRulesDebounceId) + } } public render() { + const disabled = + this.state.newName.length === 0 || + (!!this.state.currentError && !this.state.currentError.isWarning) + const hasError = !!this.state.currentError + return ( + + {renderBranchNameRuleError( + this.state.currentError, + this.ERRORS_ID, + this.state.newName + )} @@ -58,7 +98,45 @@ export class RenameBranch extends React.Component< } private onNameChange = (name: string) => { - this.setState({ newName: name }) + this.setState({ newName: name, currentError: null }) + + if (this.branchRulesDebounceId !== null) { + window.clearTimeout(this.branchRulesDebounceId) + } + + if (name !== '') { + this.branchRulesDebounceId = window.setTimeout( + this.checkBranchRules, + 500, + name + ) + } + } + + private checkBranchRules = async (branchName: string) => { + if ( + this.state.newName !== branchName || + branchName === '' || + this.state.currentError !== null + ) { + return + } + + const result = await checkBranchNameRules( + branchName, + this.props.accounts, + this.props.repository, + this.props.cachedRepoRulesets + ) + + // Make sure user branch name hasn't changed during async calls + if (this.state.newName !== branchName) { + return + } + + if (result !== null) { + this.setState({ currentError: result }) + } } private renameBranch = () => { diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 135a1c757bd..424ce354152 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -549,6 +549,9 @@ dialog { display: inline; } } + &#rename-branch { + width: 400px; + } &#push-branch-commits { width: 450px; } diff --git a/app/test/e2e/app-launch.e2e.ts b/app/test/e2e/app-launch.e2e.ts new file mode 100644 index 00000000000..359911dbf6d --- /dev/null +++ b/app/test/e2e/app-launch.e2e.ts @@ -0,0 +1,473 @@ +/** + * E2E tests for GitHub Desktop using Playwright + Electron. + * + * These tests launch the real production-built app, interact with it + * via Playwright, and verify core functionality end-to-end. Video and + * trace recording behavior is configured in the shared Electron fixtures + * (see ./e2e-fixtures), not directly in playwright.config.ts. + */ + +import { + test, + expect, + controlMockServer, + getMockRequests, + dismissMoveToApplicationsDialog, + terminateWindowsUpdaterProcesses, +} from './e2e-fixtures' +import { + smokeRepoFileContents, + smokeRepoFileName, + smokeRepoPath, + getSmokeRepoCurrentBranch, + getSmokeRepoHeadMessage, + getSmokeRepoStatus, +} from './test-helpers' +import { getVersion } from '../../package-info' +import type { Locator, Page } from '@playwright/test' + +// All tests run sequentially in the same Electron session. +test.describe.configure({ mode: 'serial' }) + +async function failIfAppErrorDialogIsVisible(page: Page) { + const appErrorDialog = page.locator('dialog#app-error') + const isVisible = await appErrorDialog.isVisible().catch(() => false) + + if (!isVisible) { + return + } + + const title = + (await appErrorDialog.locator('.dialog-header h1').textContent()) ?? '' + const description = + (await page + .locator('#app-error-description') + .textContent() + .catch(() => null)) ?? '' + + throw new Error( + `App error dialog blocked the E2E flow. Title: ${title.trim()} Description: ${description.trim()}` + ) +} + +function isMockUpdateRequest(url: string) { + return ( + url.includes('/update') || + url.includes('/RELEASES') || + url.endsWith('.nupkg') || + url.startsWith('/download/') + ) +} + +function getReleaseChannelForE2E() { + const configuredChannel = process.env.RELEASE_CHANNEL + + if (configuredChannel !== undefined) { + return configuredChannel + } + + const version = getVersion() + + if (version.includes('test')) { + return 'test' + } + + if (version.includes('beta')) { + return 'beta' + } + + return 'production' +} + +const releaseChannel = getReleaseChannelForE2E() +const shouldAutoCheckForUpdatesOnLaunch = + releaseChannel !== 'development' && releaseChannel !== 'test' + +async function clickCheckForUpdatesIfAvailable(target: Page | Locator) { + const checkBtn = target.locator( + 'button.button-component:has-text("Check for Updates")' + ) + + if ( + (await checkBtn.isVisible().catch(() => false)) && + (await checkBtn.isEnabled().catch(() => false)) + ) { + await checkBtn.click() + } +} + +// ── Smoke tests ───────────────────────────────────────────────────── + +test.describe('GitHub Desktop - App Launch', () => { + test('should launch, complete welcome flow, commit, and switch branches', async ({ + mainWindow: page, + }) => { + // Wait for the React app to mount + await page.waitForFunction( + () => + (document.getElementById('desktop-app-container')?.innerHTML.length ?? + 0) > 100, + null, + { timeout: 30000 } + ) + + // ── Welcome flow ──────────────────────────────────────────────── + const skipButton = page.locator('a.skip-button') + await skipButton.waitFor({ state: 'visible', timeout: 30000 }) + await skipButton.click() + + const nameInput = page.locator('input[placeholder="Your Name"]') + await nameInput.waitFor({ state: 'visible', timeout: 15000 }) + if ((await nameInput.inputValue()) === '') { + await nameInput.fill('GitHub Desktop E2E') + } + + const emailInput = page.locator( + 'input[placeholder="your-email@example.com"]' + ) + if ((await emailInput.inputValue()) === '') { + await emailInput.fill('desktop-e2e@example.com') + } + + await page.locator('button:has-text("Finish")').click() + await page.waitForSelector('#welcome', { state: 'hidden', timeout: 15000 }) + + await dismissMoveToApplicationsDialog(page) + + // ── Repository view ───────────────────────────────────────────── + const repoFile = page + .locator(`//*[contains(normalize-space(), "${smokeRepoFileName}")]`) + .first() + const addButton = page + .locator( + '//*[contains(normalize-space(), "Add an Existing Repository from your Local Drive") or contains(normalize-space(), "Add an Existing Repository from your local drive")]' + ) + .first() + const addRepositoryDialog = page.locator('dialog#add-existing-repository') + + await Promise.race([ + repoFile.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}), + addRepositoryDialog + .waitFor({ state: 'visible', timeout: 15000 }) + .catch(() => {}), + addButton.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}), + ]) + + await failIfAppErrorDialogIsVisible(page) + + if (!(await repoFile.isVisible().catch(() => false))) { + if (!(await addRepositoryDialog.isVisible().catch(() => false))) { + await addButton.click() + } + + await addRepositoryDialog.waitFor({ state: 'visible', timeout: 15000 }) + const pathInput = addRepositoryDialog.locator( + 'input[placeholder="repository path"]' + ) + await pathInput.waitFor({ state: 'visible', timeout: 15000 }) + if ((await pathInput.inputValue()) !== smokeRepoPath) { + await pathInput.fill(smokeRepoPath) + } + await addRepositoryDialog + .locator( + 'button:has-text("Add Repository"), button:has-text("Add repository")' + ) + .click() + } + + await repoFile.waitFor({ state: 'visible', timeout: 15000 }) + await repoFile.click() + + // ── Diff ──────────────────────────────────────────────────────── + const diffContainer = page.locator('.diff-container') + await diffContainer.waitFor({ state: 'visible', timeout: 15000 }) + await expect(diffContainer).toContainText(smokeRepoFileContents, { + timeout: 15000, + }) + + // ── Commit ────────────────────────────────────────────────────── + const commitButton = page.locator( + '[aria-label="Create commit"] .commit-button' + ) + await commitButton.waitFor({ state: 'visible', timeout: 15000 }) + await dismissMoveToApplicationsDialog(page) + await commitButton.click() + + await expect + .poll(() => getSmokeRepoHeadMessage(), { timeout: 15000 }) + .toBe(`Create ${smokeRepoFileName}`) + await expect.poll(() => getSmokeRepoStatus(), { timeout: 15000 }).toBe('') + + // ── Create branch ─────────────────────────────────────────────── + const initialBranch = getSmokeRepoCurrentBranch() + const smokeBranch = 'smoke-branch' + + await dismissMoveToApplicationsDialog(page) + await page.locator('.branch-button button').click() + + const newBranchBtn = page.locator('.new-branch-button') + await newBranchBtn.waitFor({ state: 'visible', timeout: 15000 }) + await newBranchBtn.click() + + const createBranchDialog = page.locator('#create-branch') + await createBranchDialog.waitFor({ state: 'visible', timeout: 15000 }) + const branchNameInput = createBranchDialog.locator('input').first() + await branchNameInput.evaluate((el, value) => { + const input = el as HTMLInputElement + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + )?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }, smokeBranch) + + await createBranchDialog + .locator( + 'button:has-text("Create Branch"), button:has-text("Create branch")' + ) + .click() + + await expect + .poll(() => getSmokeRepoCurrentBranch(), { timeout: 15000 }) + .toBe(smokeBranch) + + // ── Switch back ───────────────────────────────────────────────── + await dismissMoveToApplicationsDialog(page) + await page.locator('.branch-button button').click() + + await page + .locator( + `//div[contains(@class, "branches-list-item")]//div[contains(@class, "name") and normalize-space()="${initialBranch}"]` + ) + .click() + + await expect + .poll(() => getSmokeRepoCurrentBranch(), { timeout: 15000 }) + .toBe(initialBranch) + await expect.poll(() => getSmokeRepoStatus(), { timeout: 15000 }).toBe('') + }) +}) + +// ── Auto-update tests ─────────────────────────────────────────────── + +test.describe('Auto-update', () => { + test.skip(true, 'Auto-update has been removed in GitHub Desktop Plus.') + + test.describe('startup update check', () => { + test('sends an update check to the mock server on launch', async ({ + mockServer, + }) => { + test.skip( + !shouldAutoCheckForUpdatesOnLaunch, + `Startup update checks are disabled for the ${releaseChannel} channel.` + ) + + await expect + .poll( + async () => { + const reqs = await getMockRequests() + return reqs.some(r => isMockUpdateRequest(r.url)) + }, + { timeout: 30000, intervals: [1000] } + ) + .toBe(true) + }) + + test('does not show update banner when no update is available', async ({ + mainWindow: page, + }) => { + const banner = page.locator('#update-available') + await expect(banner).not.toBeVisible() + }) + }) + + test.describe('About dialog', () => { + test('shows the current version', async ({ mainWindow: page }) => { + await page.evaluate(() => { + require('electron').ipcRenderer.emit('menu-event', {}, 'show-about') + }) + + const aboutDialog = page.locator('#about') + await aboutDialog.waitFor({ state: 'visible', timeout: 5000 }) + + const versionText = await aboutDialog + .locator('.selectable-text') + .textContent() + expect(versionText).toMatch(/Version \d+\.\d+\.\d+/) + }) + + test('shows up-to-date status after no-update check', async ({ + mainWindow: page, + }) => { + if (!shouldAutoCheckForUpdatesOnLaunch) { + await clickCheckForUpdatesIfAvailable(page) + } + + const updateStatus = page.locator('#about .update-status') + await updateStatus.waitFor({ state: 'visible', timeout: 10000 }) + await expect + .poll( + async () => { + return ((await updateStatus.textContent()) ?? '').toLowerCase() + }, + { timeout: 15000, intervals: [1000] } + ) + .toContain('you have the latest version') + }) + + test('closes the About dialog', async ({ mainWindow: page }) => { + await page.locator('#about button[type="submit"]').click() + await page.locator('#about').waitFor({ state: 'hidden', timeout: 5000 }) + }) + }) + + test.describe('update available', () => { + test('switches mock server to return an update', async ({}) => { + await controlMockServer('reset-requests') + await controlMockServer('set-behavior/update-available') + const behavior = await controlMockServer('behavior') + expect(behavior).toBe('update-available') + }) + + test('triggers an update check and the app processes it', async ({ + mainWindow: page, + }) => { + await page.evaluate(() => { + require('electron').ipcRenderer.emit('menu-event', {}, 'show-about') + }) + + const aboutDialog = page.locator('#about') + await aboutDialog.waitFor({ state: 'visible', timeout: 5000 }) + + await clickCheckForUpdatesIfAvailable(aboutDialog) + + // Wait for status change + const updateStatus = aboutDialog.locator('.update-status') + await expect + .poll( + async () => { + if (!(await updateStatus.isVisible().catch(() => false))) { + return '' + } + return ((await updateStatus.textContent()) ?? '').toLowerCase() + }, + { timeout: 15000, intervals: [1000] } + ) + .toMatch(/checking|downloading|ready to be installed/) + + // Close dialog + await page.locator('#about button[type="submit"]').click() + await aboutDialog + .waitFor({ state: 'hidden', timeout: 5000 }) + .catch(() => {}) + }) + + test('sent update check requests to the mock server', async ({}) => { + await expect + .poll( + async () => { + const reqs = await getMockRequests() + return reqs.filter( + r => r.method === 'GET' && isMockUpdateRequest(r.url) + ).length + }, + { timeout: 15000, intervals: [1000] } + ) + .toBeGreaterThanOrEqual(1) + }) + + test('shows installing-update warning when quitting during download', async ({ + mainWindow: page, + }) => { + await page.evaluate(() => { + require('electron').ipcRenderer.emit('menu-event', {}, 'show-about') + }) + + const aboutDialog = page.locator('#about') + await aboutDialog.waitFor({ state: 'visible', timeout: 5000 }) + + await clickCheckForUpdatesIfAvailable(aboutDialog) + + const updateStatus = aboutDialog.locator('.update-status') + await expect + .poll( + async () => { + if (!(await updateStatus.isVisible().catch(() => false))) { + return '' + } + + return ((await updateStatus.textContent()) ?? '').toLowerCase() + }, + { timeout: 15000, intervals: [1000] } + ) + .toContain('downloading update') + + await page.locator('#about button[type="submit"]').click() + await aboutDialog + .waitFor({ state: 'hidden', timeout: 5000 }) + .catch(() => {}) + + await page.evaluate(() => { + require('electron').ipcRenderer.send('quit-app') + }) + + const dialog = page.locator('#installing-update') + await dialog.waitFor({ state: 'visible', timeout: 5000 }) + + await expect(dialog.locator('.updating-message')).toContainText( + 'Do not close GitHub Desktop while the update is in progress' + ) + + // Reset mock and trigger quit again to test Quit Anyway + await controlMockServer('set-behavior/no-update') + await controlMockServer('reset-requests') + + await page.evaluate(() => { + require('electron').ipcRenderer.send('quit-app') + }) + + const quitBtn = dialog.locator( + '.button-group.destructive button[type="button"]' + ) + await quitBtn.waitFor({ state: 'visible', timeout: 5000 }) + + // Save the trace now — the next click will kill the app and make + // the browser context unavailable for the fixture teardown. + const tracePath = require('path').join( + __dirname, + '..', + '..', + '..', + 'playwright-videos', + `trace-${Date.now()}.zip` + ) + await page + .context() + .tracing.stop({ path: tracePath }) + .catch(() => {}) + + // Get PID before quitting so we can verify the process exits + const rendererPid: number = await page.evaluate(() => process.pid) + + await quitBtn.click() + + // Poll the OS to confirm the renderer process exited + await expect + .poll( + () => { + try { + process.kill(rendererPid, 0) + return false + } catch { + return true + } + }, + { timeout: 10000, intervals: [200] } + ) + .toBe(true) + + terminateWindowsUpdaterProcesses() + }) + }) +}) diff --git a/app/test/e2e/e2e-fixtures.ts b/app/test/e2e/e2e-fixtures.ts new file mode 100644 index 00000000000..e5d39a925b3 --- /dev/null +++ b/app/test/e2e/e2e-fixtures.ts @@ -0,0 +1,256 @@ +/* eslint-disable no-sync */ + +/** + * Shared Playwright fixtures for GitHub Desktop e2e tests. + * + * Provides: + * - `app` — the ElectronApplication instance + * - `mainWindow` — the main BrowserWindow page + * - `mockServer` — the mock update server (with control helpers) + * + * All fixtures are scoped to the **worker** so the app launches once + * and all tests in the file share the same session (one Electron + * session runs all specs sequentially). + */ + +import fs from 'fs' +import http from 'http' +import os from 'os' +import path from 'path' +import { spawnSync } from 'child_process' +import { + test as base, + type ElectronApplication, + type Page, +} from '@playwright/test' +import { _electron as electron } from 'playwright' +import { ensureSmokeTestRepository, smokeRepoPath } from './test-helpers' +import { + createMockUpdateServer, + MOCK_CONTROL_URL, + type IMockUpdateServer, + type UpdateBehavior, +} from './mock-update-server' +import { getDistPath, getExecutableName } from '../../../script/dist-info' +import { getProductName } from '../../package-info' + +const projectRoot = path.resolve(__dirname, '..', '..', '..') +const userDataDir = path.join(os.tmpdir(), 'github-desktop-pw-e2e') +const fakeHomeDir = path.join(os.tmpdir(), 'github-desktop-pw-fake-home') +const installedAppExecutablePath = process.env.DESKTOP_E2E_APP_PATH +// `packaged` is the default because CI runs the suite against packaged or +// installed production artifacts. `unpackaged` exists for local iteration so +// the same tests can run against the staged app in `out/main.js` without +// requiring packaging or signing. +const e2eAppMode = process.env.DESKTOP_E2E_APP_MODE ?? 'packaged' +const unpackagedAppEntryPoint = path.join(projectRoot, 'out', 'main.js') + +function getPackagedAppExecutablePath() { + const distPath = getDistPath() + + if (process.platform === 'darwin') { + const productName = getProductName() + return path.join( + distPath, + `${productName}.app`, + 'Contents', + 'MacOS', + productName + ) + } + + if (process.platform === 'win32') { + return path.join(distPath, `${getExecutableName()}.exe`) + } + + return path.join(distPath, getExecutableName()) +} + +const e2eAppExecutablePath = + installedAppExecutablePath ?? getPackagedAppExecutablePath() + +function getE2ELaunchOptions() { + if (installedAppExecutablePath !== undefined) { + return { + executablePath: installedAppExecutablePath, + args: [`--user-data-dir=${userDataDir}`, `--cli-open=${smokeRepoPath}`], + missingPath: installedAppExecutablePath, + } + } + + if (e2eAppMode === 'unpackaged') { + return { + args: [ + unpackagedAppEntryPoint, + `--user-data-dir=${userDataDir}`, + `--cli-open=${smokeRepoPath}`, + ], + missingPath: unpackagedAppEntryPoint, + } + } + + return { + executablePath: e2eAppExecutablePath, + args: [`--user-data-dir=${userDataDir}`, `--cli-open=${smokeRepoPath}`], + missingPath: e2eAppExecutablePath, + } +} + +export function terminateWindowsUpdaterProcesses() { + if (process.platform !== 'win32') { + return + } + + for (const imageName of ['Update.exe', 'GitHubDesktopPlus.exe']) { + spawnSync('taskkill', ['/F', '/T', '/IM', imageName], { + stdio: 'ignore', + windowsHide: true, + }) + } +} + +// ── Helpers exposed to tests ──────────────────────────────────────── + +type MockControlAction = + | `set-behavior/${UpdateBehavior}` + | 'reset-requests' + | 'requests' + | 'behavior' + +export function controlMockServer(action: MockControlAction): Promise { + return new Promise((resolve, reject) => { + http + .get(`${MOCK_CONTROL_URL}/${action}`, res => { + let data = '' + res.on('data', (chunk: string) => (data += chunk)) + res.on('end', () => resolve(data)) + }) + .on('error', reject) + }) +} + +export async function getMockRequests(): Promise< + ReadonlyArray<{ method: string; url: string }> +> { + return JSON.parse(await controlMockServer('requests')) +} + +export async function dismissMoveToApplicationsDialog(page: Page) { + if (process.platform !== 'darwin') { + return + } + + const btn = page.locator( + 'button:has-text("Not Now"), button:has-text("Not now")' + ) + if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) { + await btn.click() + await btn.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) + } +} + +// ── Fixtures ──────────────────────────────────────────────────────── + +type E2EFixtures = { + app: ElectronApplication + mainWindow: Page + mockServer: IMockUpdateServer +} + +export const test = base.extend<{}, E2EFixtures>({ + // Worker-scoped: one Electron app per test file. + // Depends on mockServer so the update server is ready before launch. + app: [ + async ({ mockServer }, use) => { + // Setup directories + ensureSmokeTestRepository() + + const launchOptions = getE2ELaunchOptions() + + if (!fs.existsSync(launchOptions.missingPath)) { + throw new Error( + `E2E app not found at ${launchOptions.missingPath}. Run the matching E2E build step first.` + ) + } + + fs.rmSync(userDataDir, { recursive: true, force: true }) + fs.mkdirSync(userDataDir, { recursive: true }) + fs.rmSync(fakeHomeDir, { recursive: true, force: true }) + fs.mkdirSync(fakeHomeDir, { recursive: true }) + + const app = await electron.launch({ + ...launchOptions, + env: { + ...process.env, + GIT_CONFIG_GLOBAL: path.join(fakeHomeDir, '.gitconfig'), + GIT_CONFIG_SYSTEM: path.join(fakeHomeDir, '.gitconfig-system'), + XDG_CONFIG_HOME: path.join(fakeHomeDir, '.config'), + SSH_AUTH_SOCK: '', + GIT_SSH_COMMAND: 'false', + }, + recordVideo: { + dir: path.join(projectRoot, 'playwright-videos'), + size: { width: 1280, height: 800 }, + }, + timeout: 30000, + }) + + await use(app) + + terminateWindowsUpdaterProcesses() + await app.close().catch(() => {}) + terminateWindowsUpdaterProcesses() + await new Promise(resolve => setTimeout(resolve, 1000)) + }, + { scope: 'worker' }, + ], + + mainWindow: [ + async ({ app }, use) => { + const page = await app.firstWindow() + + page.on('console', message => { + const text = message.text() + if (message.type() === 'error' || text.includes('Uncaught exception')) { + console.log(`[e2e:console:${message.type()}] ${text}`) + } + }) + + page.on('pageerror', error => { + const details = error.stack ?? error.message + console.log(`[e2e:pageerror] ${details}`) + }) + + // Start tracing for this worker session + await page.context().tracing.start({ + screenshots: true, + snapshots: true, + }) + + await use(page) + + // Save trace on teardown + const tracePath = path.join( + projectRoot, + 'playwright-videos', + `trace-${Date.now()}.zip` + ) + await page + .context() + .tracing.stop({ path: tracePath }) + .catch(() => {}) + }, + { scope: 'worker' }, + ], + + mockServer: [ + async ({}, use) => { + const server = await createMockUpdateServer() + await use(server) + await server.close() + }, + { scope: 'worker' }, + ], +}) + +export { expect } from '@playwright/test' diff --git a/app/test/e2e/mock-update-server.ts b/app/test/e2e/mock-update-server.ts new file mode 100644 index 00000000000..1ce70cec3cd --- /dev/null +++ b/app/test/e2e/mock-update-server.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-sync */ + +import http from 'http' +import type net from 'net' +import { + getWindowsFullNugetPackageName, + getWindowsIdentifierName, +} from '../../../script/dist-info' + +/** Fixed port used for the mock update server during e2e tests. */ +export const MOCK_UPDATE_PORT = 51789 +export const MOCK_UPDATE_URL = `http://127.0.0.1:${MOCK_UPDATE_PORT}/update` + +/** + * URL that e2e tests can use to control the mock server behaviour at runtime + * via simple GET requests (e.g. `/_control/set-behavior/update-available`). + */ +export const MOCK_CONTROL_URL = `http://127.0.0.1:${MOCK_UPDATE_PORT}/_control` + +const currentWindowsPackageName = getWindowsFullNugetPackageName() +const nextWindowsPackageName = `${getWindowsIdentifierName()}-99.0.0-full.nupkg` +const fakeSha = '0123456789012345678901234567890123456789' +const fakePackageSize = '999999999' + +function isWindowsFeedRequest(url: string) { + return url.includes('/RELEASES') || url.endsWith('.nupkg') +} + +function getWindowsNoUpdateReleases() { + return `${fakeSha} ${currentWindowsPackageName} ${fakePackageSize}` +} + +function getWindowsUpdateAvailableReleases() { + return `${fakeSha} ${nextWindowsPackageName} ${fakePackageSize}` +} + +export type UpdateBehavior = 'no-update' | 'update-available' + +export interface IMockUpdateServer { + readonly server: http.Server + readonly url: string + + /** All requests received by the mock server (excluding control requests). */ + readonly requests: Array<{ method: string; url: string }> + + /** Change how the server responds to update checks. */ + setBehavior(behavior: UpdateBehavior): void + + /** Reset the captured request log. */ + resetRequests(): void + + close(): Promise +} + +/** + * Create a mock update server that mimics the responses from + * central.github.com for Squirrel (macOS) and Squirrel.Windows. + * + * By default, it responds with "no update available" (HTTP 204). + * + * In `update-available` mode, the JSON (macOS) or RELEASES (Windows) + * feed tells Squirrel that an update exists. When Squirrel then requests + * the update payload (zip or `.nupkg`), the mock server responds with + * HTTP 200 and intentionally keeps the download response open without + * completing. This is enough to verify that the app correctly processes + * the update feed and transitions into (and remains in) the expected + * "update available" / "downloading" states during tests. + * + * Full binary verification is not possible in dev builds because + * Squirrel.Mac requires the update zip to satisfy the running app's code + * signing designated requirements — something only production-signed + * builds can provide. + */ +export function createMockUpdateServer(): Promise { + return new Promise((resolve, reject) => { + let behavior: UpdateBehavior = 'no-update' + const requests: Array<{ method: string; url: string }> = [] + const sockets = new Set() + + const server = http.createServer((req, res) => { + const url = req.url ?? '/' + + // ── Control plane ───────────────────────────────────────────── + if (url.startsWith('/_control/')) { + const action = url.replace('/_control/', '') + + if (action === 'set-behavior/no-update') { + behavior = 'no-update' + res.writeHead(200) + res.end('ok') + return + } + + if (action === 'set-behavior/update-available') { + behavior = 'update-available' + res.writeHead(200) + res.end('ok') + return + } + + if (action === 'reset-requests') { + requests.length = 0 + res.writeHead(200) + res.end('ok') + return + } + + if (action === 'requests') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(requests)) + return + } + + if (action === 'behavior') { + res.writeHead(200) + res.end(behavior) + return + } + + res.writeHead(404) + res.end('unknown control action') + return + } + + // ── Update plane ────────────────────────────────────────────── + requests.push({ method: req.method ?? 'GET', url }) + + // Serve fake download URLs by hanging forever — send headers but never + // finish the body. This keeps the updater in "downloading" state + // without ever completing or failing validation. + if (url.startsWith('/download/') || url.endsWith('.nupkg')) { + res.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': '999999999', + }) + // Intentionally never call res.end() — the connection stays + // open until the server is shut down or the client disconnects. + return + } + + if (req.method === 'HEAD') { + // Priority update status check. + res.writeHead(200, { 'x-prioritize-update': 'false' }) + res.end() + return + } + + if (isWindowsFeedRequest(url)) { + const body = + behavior === 'update-available' + ? getWindowsUpdateAvailableReleases() + : getWindowsNoUpdateReleases() + + if (url.includes('/RELEASES')) { + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': Buffer.byteLength(body), + }) + res.end(body) + return + } + } + + if (behavior === 'no-update') { + res.writeHead(204) + res.end() + return + } + + if (behavior === 'update-available') { + // Squirrel.Mac JSON feed. The download URL points back to this server's + // /download/ handler which hangs forever, keeping the app in + // "downloading" state without completing or erroring. + const body = JSON.stringify({ + url: `http://127.0.0.1:${MOCK_UPDATE_PORT}/download/update.zip`, + name: '99.0.0', + notes: 'E2E test update', + pub_date: new Date().toISOString(), + }) + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }) + res.end(body) + return + } + + res.writeHead(204) + res.end() + }) + + server.on('error', reject) + server.on('connection', socket => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + server.listen(MOCK_UPDATE_PORT, '127.0.0.1', () => { + const instance: IMockUpdateServer = { + server, + url: MOCK_UPDATE_URL, + requests, + setBehavior(b: UpdateBehavior) { + behavior = b + }, + resetRequests() { + requests.length = 0 + }, + close() { + return new Promise((res, rej) => { + for (const socket of sockets) { + socket.destroy() + } + + server.close(err => (err ? rej(err) : res())) + }) + }, + } + resolve(instance) + }) + }) +} diff --git a/app/test/e2e/playwright.config.ts b/app/test/e2e/playwright.config.ts new file mode 100644 index 00000000000..d131a06d286 --- /dev/null +++ b/app/test/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-sync */ + +import path from 'path' +import { defineConfig } from '@playwright/test' + +const projectRoot = path.resolve(__dirname, '..', '..', '..') + +// eslint-disable-next-line no-restricted-syntax +export default defineConfig({ + testDir: __dirname, + testMatch: '*.e2e.ts', + timeout: 120_000, + retries: 0, + workers: 1, + + outputDir: path.join(projectRoot, 'playwright-videos'), + + // Video recording and tracing are configured in the Electron- + // specific fixtures (see e2e-fixtures.ts) rather than here, + // because @playwright/test `use.video` / `use.trace` only apply + // to browser contexts, not Electron apps. +}) diff --git a/app/test/e2e/test-helpers.ts b/app/test/e2e/test-helpers.ts new file mode 100644 index 00000000000..7dd5c3e593d --- /dev/null +++ b/app/test/e2e/test-helpers.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-sync */ + +import { execFileSync } from 'child_process' +import fs from 'fs' +import os from 'os' +import path from 'path' + +export const smokeRepoPath = path.join( + os.tmpdir(), + 'github-desktop-e2e-smoke-repository' +) +export const smokeRepoName = path.basename(smokeRepoPath) +export const smokeRepoFileName = 'smoke-change.txt' +export const smokeRepoFileContents = + 'This file should appear in the changes list.' + +export function ensureSmokeTestRepository() { + fs.rmSync(smokeRepoPath, { recursive: true, force: true }) + fs.mkdirSync(smokeRepoPath, { recursive: true }) + + runGit(['init'], smokeRepoPath) + runGit(['config', 'user.name', 'GitHub Desktop E2E'], smokeRepoPath) + runGit(['config', 'user.email', 'desktop-e2e@example.com'], smokeRepoPath) + + fs.writeFileSync( + path.join(smokeRepoPath, 'README.md'), + '# GitHub Desktop Smoke Repo\n' + ) + + runGit(['add', 'README.md'], smokeRepoPath) + runGit(['commit', '-m', 'Initial commit'], smokeRepoPath) + + fs.writeFileSync( + path.join(smokeRepoPath, smokeRepoFileName), + `${smokeRepoFileContents}\n` + ) +} + +function runGit(args: ReadonlyArray, cwd: string) { + execFileSync('git', [...args], { + cwd, + stdio: 'ignore', + }) +} + +function readGitOutput(args: ReadonlyArray, cwd: string) { + return execFileSync('git', [...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() +} + +export function getSmokeRepoStatus() { + return readGitOutput(['status', '--short'], smokeRepoPath) +} + +export function getSmokeRepoHeadMessage() { + return readGitOutput(['log', '-1', '--pretty=%s'], smokeRepoPath) +} + +export function getSmokeRepoCurrentBranch() { + return readGitOutput(['branch', '--show-current'], smokeRepoPath) +} diff --git a/docs/technical/e2e-smoke-tests.md b/docs/technical/e2e-smoke-tests.md new file mode 100644 index 00000000000..d22c4ab500d --- /dev/null +++ b/docs/technical/e2e-smoke-tests.md @@ -0,0 +1,239 @@ +# E2E Smoke Tests + +This document explains the end-to-end smoke test harness added for GitHub +Desktop, what it covers, how it runs locally and in CI, and which repository +files make up the implementation. + +## Overview + +The smoke suite uses Playwright's Electron support to launch the real Desktop +application and drive a small set of critical user paths. The focus is not broad +UI coverage. The suite is intended to protect the app's highest-risk integration +boundaries: + +- app startup and first-run flow +- opening an existing repository +- committing and switching branches +- updater state transitions and platform-specific update behavior + +The suite is deliberately small and runs serially in one Electron session. That +keeps it practical for CI and useful for iterative development. + +## What The Suite Covers + +The current smoke spec lives in `app/test/e2e/app-launch.e2e.ts` and covers: + +- launching Desktop and completing the welcome flow +- opening the smoke repository from disk +- verifying the changed file appears in the diff +- creating a commit +- creating a branch and switching back +- verifying startup update checks against the mock update server +- verifying About dialog update states +- verifying update-available and quit-during-download behavior + +These tests intentionally mix UI assertions with repository-level verification by +checking Git state directly in the smoke repository. + +## File Layout + +The main files added or changed for the E2E harness are: + +- `app/test/e2e/app-launch.e2e.ts` + - the smoke suite itself +- `app/test/e2e/e2e-fixtures.ts` + - Playwright/Electron fixtures, launch mode selection, tracing, video output, + and Windows updater cleanup +- `app/test/e2e/mock-update-server.ts` + - an in-process HTTP server used to simulate updater responses for macOS and + Windows +- `app/test/e2e/test-helpers.ts` + - helpers for creating and validating the smoke test repository +- `app/test/e2e/playwright.config.ts` + - Playwright configuration for the smoke suite + +## How The App Is Launched + +The fixture supports three effective launch modes. + +### Installed app + +If `DESKTOP_E2E_APP_PATH` is set, the fixture launches the executable at that +path. This is the mode used by CI after the app has been packaged and installed. + +This matters most on Windows, where Squirrel update behavior depends on running +from a real installed application layout. + +### Packaged app + +If `DESKTOP_E2E_APP_PATH` is not set and `DESKTOP_E2E_APP_MODE` is not set to +`unpackaged`, the fixture resolves the packaged app path from `dist` and launches +that executable directly. + +This is the default E2E mode because CI uses production-like packaged artifacts. + +### Unpackaged app + +If `DESKTOP_E2E_APP_MODE=unpackaged`, the fixture launches `out/main.js` +instead of a packaged executable. + +This mode exists for local iteration. It avoids the need to fully package and +sign the app just to run the smoke suite while still using a production webpack +bundle and staged resources. + +## Local Commands + +The branch adds two ways to run the suite locally. + +### Packaged mode + +```bash +yarn test:e2e:packaged +``` + +This builds a packaged production app and then runs the E2E suite. + +### Unpackaged mode + +```bash +yarn test:e2e:unpackaged +``` + +This builds a production-configured staged app in `out/` and runs the same E2E +suite against `out/main.js`. + +The unpackaged build path uses `DESKTOP_SKIP_PACKAGE=1` so `script/build.ts` +stages the app without invoking the final packaging step. + +### Default alias + +```bash +yarn test:e2e +``` + +This remains the packaged path and is equivalent to `yarn test:e2e:packaged`. + +## Smoke Repository Setup + +The suite uses a throwaway local Git repository created in the system temp +directory. + +`app/test/e2e/test-helpers.ts` is responsible for: + +- creating the repository +- initializing Git +- configuring a local author name and email +- creating an initial commit +- adding an uncommitted smoke file used by the tests + +The tests then verify Desktop's behavior both through the UI and by checking the +repository state directly with Git commands. + +## Updater Testing + +Updater behavior is tested through a local HTTP server defined in +`app/test/e2e/mock-update-server.ts`. + +### Build-time updater URL override + +`app/app-info.ts` now uses `DESKTOP_E2E_UPDATES_URL` when present. This is what +lets E2E builds point the app at the local mock update server instead of the +real update service. + +### macOS behavior + +For macOS, the mock server serves a Squirrel.Mac-style JSON response. + +- `no-update` returns HTTP 204 +- `update-available` returns a JSON payload with a fake zip URL + +The fake download stays open rather than completing, which keeps the app in the +"Downloading update…" state without requiring a valid signed update archive. + +### Windows behavior + +For Windows, the mock server serves Squirrel.Windows-style responses. + +- requests for `RELEASES` receive a text manifest +- requests for `.nupkg` receive a fake long-lived binary response + +This is necessary because Windows updater behavior does not use the macOS JSON +contract. + +### Mock server control plane + +The mock server exposes a simple control surface under `/_control/`. + +The tests use it to: + +- switch between `no-update` and `update-available` +- reset the captured request log +- inspect which requests the app made + +## CI Design + +The `e2e-smoke` job in `.github/workflows/ci.yml` runs separately from the main +`build` job. + +It currently: + +- checks out the repository +- uses the shared CI environment setup action +- builds the production app with `DESKTOP_E2E_UPDATES_URL` pointed at the mock + server +- uses the shared Windows signing action when needed +- packages the app +- installs the app on macOS and Windows +- exports `DESKTOP_E2E_APP_PATH` +- runs `yarn test:e2e:run:packaged` +- uploads `playwright-videos` as artifacts so traces and videos are retained + +The CI job intentionally tests production-like packaged or installed artifacts. +This catches failures that do not show up when running only webpack output. + +## Windows-Specific Notes + +Windows needed a few extra pieces to keep the suite stable. + +- the smoke suite only runs updater coverage against an installed app path on + Windows +- the workflow installs the generated Squirrel setup executable silently and + discovers the installed `GitHubDesktop.exe` path +- installer failures dump Squirrel log files for diagnosis +- the E2E fixtures kill lingering `Update.exe` and `GitHubDesktop.exe` process + trees during teardown to avoid hangs and races with the mock server + +## Videos, Traces, and Diagnostics + +The suite writes output to `playwright-videos`. + +Artifacts include: + +- Playwright videos recorded through the Electron fixture +- trace zip files saved from the Playwright context + +The workflow uploads that directory as an artifact so CI failures can be +inspected after the run completes. + +The fixture also forwards renderer console errors and page errors to the job log +to make failures easier to diagnose when the app dies before Playwright can make +useful assertions. + +## Limitations And Tradeoffs + +- The suite is intentionally narrow. It protects critical integration paths, not + the entire UI surface. +- Updater tests simulate update availability rather than downloading valid + signed release artifacts. +- Windows updater behavior is realistic only when running from an installed app, + which is why CI uses installer-based setup there. +- Local unpackaged mode is meant for fast iteration, not as a perfect substitute + for packaged/install-time validation. + +## When To Use Which Mode + +- Use `yarn test:e2e:unpackaged` when iterating locally on the smoke suite or + nearby app behavior. +- Use `yarn test:e2e:packaged` when you need parity with the packaged runtime. +- Rely on the CI `e2e-smoke` job for the final check against production-like + packaged and installed artifacts. diff --git a/package.json b/package.json index 8fdea40e3fa..19f1a4dd279 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,15 @@ "test:unit": "node script/test.mjs", "test:setup": "ts-node -P script/tsconfig.json script/test-setup.ts", "test:docker": "./script/testing-docker/run.sh", + "test:e2e": "yarn test:e2e:packaged", + "test:e2e:packaged": "yarn test:e2e:build:packaged && yarn test:e2e:run:packaged", + "test:e2e:unpackaged": "yarn test:e2e:build:unpackaged && yarn test:e2e:run:unpackaged", + "test:e2e:build": "yarn test:e2e:build:packaged", + "test:e2e:build:packaged": "cross-env DESKTOP_E2E_UPDATES_URL=http://127.0.0.1:51789/update NODE_ENV=production RELEASE_CHANNEL=production DESKTOP_E2E=1 yarn build:prod", + "test:e2e:build:unpackaged": "cross-env DESKTOP_E2E_UPDATES_URL=http://127.0.0.1:51789/update NODE_ENV=production RELEASE_CHANNEL=production DESKTOP_SKIP_PACKAGE=1 yarn build:prod", + "test:e2e:run": "yarn test:e2e:run:packaged", + "test:e2e:run:packaged": "npx playwright test --config app/test/e2e/playwright.config.ts", + "test:e2e:run:unpackaged": "cross-env DESKTOP_E2E_APP_MODE=unpackaged npx playwright test --config app/test/e2e/playwright.config.ts", "postinstall": "ts-node -P script/tsconfig.json script/post-install.ts", "start": "cross-env NODE_ENV=development ts-node -P script/tsconfig.json script/start.ts", "start:prod": "cross-env NODE_ENV=production ts-node -P script/tsconfig.json script/start.ts", @@ -105,6 +114,7 @@ }, "devDependencies": { "@github/markdownlint-github": "^0.1.0", + "@playwright/test": "^1.58.2", "@types/byline": "^4.2.31", "@types/classnames": "^2.2.2", "@types/codemirror": "^5.60.15", @@ -160,6 +170,7 @@ "node-test-github-reporter": "^1.2.0", "patch-package": "^8.0.0", "postinstall-postinstall": "^2.1.0", + "playwright": "^1.58.2", "reserved-words": "^0.1.2", "tsconfig-paths": "^3.9.0", "tsx": "^4.19.3", diff --git a/script/build.ts b/script/build.ts index ab22413ad30..ab26ffe21ac 100755 --- a/script/build.ts +++ b/script/build.ts @@ -62,6 +62,7 @@ const isPublishableBuild = isPublishable() const isNonProductionRelease = getChannel() !== 'production' const isDevelopmentBuild = getChannel() === 'development' const useAdHocSigning = isGitHubDesktopPlus || isDevelopmentBuild +const shouldSkipPackaging = process.env.DESKTOP_SKIP_PACKAGE === '1' const projectRoot = path.join(__dirname, '..') const entitlementsSuffix = useAdHocSigning ? '-dev' : '' @@ -117,6 +118,11 @@ verifyInjectedSassVariables(outRoot) }) }) .then(() => { + if (shouldSkipPackaging) { + console.log('Skipping packaging…') + return [outRoot] + } + console.log('Packaging…') return packageApp() }) diff --git a/script/default-to-tls12-on-appveyor.reg b/script/default-to-tls12-on-appveyor.reg deleted file mode 100644 index 9a1ae5cb253..00000000000 --- a/script/default-to-tls12-on-appveyor.reg +++ /dev/null @@ -1,7 +0,0 @@ -Windows Registry Editor Version 5.00 - -[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework\v4.0.30319] -"SchUseStrongCrypto"=dword:00000001 - -[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319] -"SchUseStrongCrypto"=dword:00000001 diff --git a/script/post-install.ts b/script/post-install.ts index 2b31ac02c8b..58a4f531e76 100644 --- a/script/post-install.ts +++ b/script/post-install.ts @@ -15,6 +15,20 @@ const options: SpawnSyncOptions = { stdio: 'inherit', } +const captureOutputOptions: SpawnSyncOptions = { + cwd: root, + encoding: 'utf8', +} + +// Some Windows CI runners do not expose an `npx` executable on PATH, so +// invoke the locally installed Playwright CLI through the current Node binary. +// Resolve from the exported package root since `playwright/cli` is not exported. +const playwrightPackagePath = require.resolve('playwright/package.json') +const playwrightCliPath = Path.join( + Path.dirname(playwrightPackagePath), + 'cli.js' +) + /** Check if the caller has set the OFFLINe environment variable */ function isOffline() { return process.env.OFFLINE === '1' @@ -77,6 +91,31 @@ findYarnVersion(path => { process.exit(result.status || 1) } + // Capture output here so CI failures include the Playwright-specific error. + result = spawnSync( + process.execPath, + [playwrightCliPath, 'install', 'ffmpeg'], + captureOutputOptions + ) + + if (result.status !== 0) { + console.error( + 'Error: failed to install Playwright ffmpeg (video recording may not work)', + '\nplatform:', + process.platform, + '\nstatus:', + result.status, + '\nsignal:', + result.signal, + '\nerror:', + result.error, + '\nstdout:', + result.stdout, + '\nstderr:', + result.stderr + ) + } + if (process.platform === 'linux') { result = spawnSync('node', getYarnArgs([path, 'patch-package']), options) diff --git a/script/testing-docker/Dockerfile b/script/testing-docker/Dockerfile index 7c97bccf843..a817067288d 100644 --- a/script/testing-docker/Dockerfile +++ b/script/testing-docker/Dockerfile @@ -4,7 +4,7 @@ RUN apt-get -y update RUN apt-get -y install git make g++ curl libsecret-1-dev libnss3 libdbus-1-3 libatk1.0 libatk-bridge2.0-dev libcups2-dev libdrm-dev libgtk-3-dev libasound2-dev # Install Node.js -RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - +RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - RUN apt-get -y install nodejs RUN corepack enable @@ -18,6 +18,6 @@ RUN echo 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/b COPY ./entrypoint.sh /entrypoint.sh # To allow the image to run without internet connection -RUN corepack pack yarn@1.21.1 +RUN corepack pack yarn@1.22.22 CMD ["/entrypoint.sh"] diff --git a/script/testing-docker/entrypoint.sh b/script/testing-docker/entrypoint.sh index a25c25a9885..ea524988ac6 100755 --- a/script/testing-docker/entrypoint.sh +++ b/script/testing-docker/entrypoint.sh @@ -5,8 +5,8 @@ set -e cd /app +yarn test:setup yarn test:unit -yarn test:eslint yarn test:script echo '-------------------' diff --git a/yarn.lock b/yarn.lock index 5a7eecf7c25..6f951abd5fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -958,6 +958,13 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@playwright/test@^1.58.2": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + "@polka/url@^1.0.0-next.24": version "1.0.0-next.25" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817" @@ -4399,6 +4406,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" @@ -6864,6 +6876,20 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2, playwright@^1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + plist@^3.0.0, plist@^3.0.4, plist@^3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/plist/-/plist-3.0.6.tgz#7cfb68a856a7834bca6dbfe3218eb9c7740145d3"