Files
2026-07-13 13:05:33 +08:00

285 lines
10 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { ElectronApplication, Locator } from '@stablyai/playwright-test'
const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group'
const tempRoots: string[] = []
function initializeGitRepo(repoPath: string): void {
mkdirSync(repoPath, { recursive: true })
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
cwd: repoPath,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath, stdio: 'pipe' })
writeFileSync(path.join(repoPath, 'README.md'), `# ${path.basename(repoPath)}\n`)
execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' })
}
async function createNestedRepoFixture(): Promise<{
parentPath: string
projectPaths: string[]
groupName: string
}> {
// Why: realpathSync so the projectPaths the test asserts on match the store's
// canonicalized repo.path / projectGroup.parentPath on macOS, where
// os.tmpdir() (/var/...) symlinks to /private/var/... and the app canonicalizes
// imported paths via `git rev-parse --show-toplevel`.
const parentPath = realpathSync(await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-folder-setup-')))
tempRoots.push(parentPath)
const repoNames = ['api-service', 'web-client']
const projectPaths = repoNames.map((name) => path.join(parentPath, name))
for (const repoPath of projectPaths) {
initializeGitRepo(repoPath)
}
return {
parentPath,
projectPaths,
groupName: path.basename(parentPath)
}
}
async function createLargeNestedRepoFixture(): Promise<{
parentPath: string
projectPaths: string[]
groupName: string
selectedProjectPaths: string[]
}> {
// Why: realpathSync so the projectPaths the test asserts on match the store's
// canonicalized repo.path on macOS (os.tmpdir() /var/... symlinks to
// /private/var/...).
const parentPath = realpathSync(
await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-large-folder-setup-'))
)
tempRoots.push(parentPath)
const nestedParent = path.join(
parentPath,
'products-with-long-folder-name',
'platform-domain-with-long-folder-name',
'region-alpha-with-long-folder-name'
)
const projectPaths = Array.from({ length: 87 }, (_, index) => {
const repoName = `service-${String(index + 1).padStart(2, '0')}-with-long-repository-name`
return path.join(nestedParent, repoName)
})
for (const repoPath of projectPaths) {
initializeGitRepo(repoPath)
}
return {
parentPath,
projectPaths,
groupName: path.basename(parentPath),
selectedProjectPaths: [projectPaths[2], projectPaths[41], projectPaths[86]]
}
}
test.afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
async function chooseFolderInNativeDialog(
electronApp: ElectronApplication,
folderPath: string
): Promise<void> {
await electronApp.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [selectedPath],
bookmarks: []
})
}, folderPath)
}
function getImportAsGroupButton(importDialog: Locator): Locator {
// Why: the current accessible action is intentional; accepting retired
// labels would hide deterministic dialog copy drift.
return importDialog.getByRole('button', {
name: IMPORT_AS_GROUP_BUTTON_NAME,
exact: true
})
}
test.describe('Folder setup', () => {
test('imports nested repositories from the add-project dialog as a project group', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const fixture = await createNestedRepoFixture()
await chooseFolderInNativeDialog(electronApp, fixture.parentPath)
await orcaPage
.getByRole('button', { name: /Add Project/i })
.first()
.click()
const dialog = orcaPage.getByRole('dialog', { name: /Add a project/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /Browse folder/i }).click()
const importDialog = orcaPage.getByRole('dialog', {
name: /Import repositories from folder/i
})
await expect(
importDialog.getByRole('heading', { name: /Import repositories from folder/i })
).toBeVisible()
await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible()
await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible()
await expect(getImportAsGroupButton(importDialog)).toBeEnabled()
await getImportAsGroupButton(importDialog).click()
await expect
.poll(
() =>
orcaPage.evaluate(async (args) => {
const state = window.__store?.getState()
if (!state) {
return null
}
const importedRepos = state.repos
.filter((repo) => args.projectPaths.includes(repo.path))
.sort((left, right) => left.displayName.localeCompare(right.displayName))
const group = state.projectGroups.find((entry) => entry.parentPath === args.parentPath)
return {
groupName: group?.name ?? null,
repoNames: importedRepos.map((repo) => repo.displayName),
reposInCreatedGroup:
group !== undefined &&
importedRepos.every((repo) => repo.projectGroupId === group.id),
projectGroupOrders: importedRepos.map((repo) => repo.projectGroupOrder ?? null)
}
}, fixture),
{
timeout: 20_000,
message: 'nested repos were not imported into a project group'
}
)
.toEqual({
groupName: fixture.groupName,
repoNames: ['api-service', 'web-client'],
reposInCreatedGroup: true,
projectGroupOrders: [0, 1]
})
await orcaPage.evaluate(() => {
const state = window.__store?.getState()
state?.closeModal()
state?.setGroupBy('repo')
})
await expect(orcaPage.getByText(fixture.groupName)).toBeVisible()
})
test('imports a small selection from a large nested folder without modal overflow', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const fixture = await createLargeNestedRepoFixture()
await chooseFolderInNativeDialog(electronApp, fixture.parentPath)
await orcaPage
.getByRole('button', { name: /Add Project/i })
.first()
.click()
const dialog = orcaPage.getByRole('dialog', { name: /Add a project/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /Browse folder/i }).click()
const importDialog = orcaPage.getByRole('dialog', {
name: /Import repositories from folder/i
})
await expect(importDialog.getByText(/Found 87 repositories in/)).toBeVisible()
await expect
.poll(async () =>
importDialog.locator('ul').evaluate((list) => {
const dialog = list.closest('[role="dialog"]')
if (!dialog) {
return 0
}
const dialogRight = dialog.getBoundingClientRect().right
return [...list.querySelectorAll('li, label, span')].filter(
(node) => node.getBoundingClientRect().right > dialogRight + 1
).length
})
)
.toBe(0)
await importDialog.getByLabel('Deselect all').click()
for (const projectPath of fixture.selectedProjectPaths) {
const repoName = path.basename(projectPath)
await importDialog
.locator('label')
.filter({ hasText: repoName })
.locator('input[type="checkbox"]')
.check()
}
await getImportAsGroupButton(importDialog).click()
await expect
.poll(
() =>
orcaPage.evaluate(async (args) => {
const state = window.__store?.getState()
if (!state) {
return null
}
const importedRepos = state.repos.filter((repo) =>
args.selectedProjectPaths.includes(repo.path)
)
const fixtureRepos = state.repos.filter((repo) => args.projectPaths.includes(repo.path))
const group = state.projectGroups.find((entry) => entry.parentPath === args.parentPath)
const groupsById = new Map(state.projectGroups.map((entry) => [entry.id, entry]))
const isInGroupSubtree = (groupId: string | null | undefined): boolean => {
let currentId = groupId ?? null
while (currentId) {
if (currentId === group?.id) {
return true
}
currentId = groupsById.get(currentId)?.parentGroupId ?? null
}
return false
}
const worktreeCounts = await Promise.all(
importedRepos.map(async (repo) => {
const result = await window.api.worktrees.listDetected({ repoId: repo.id })
return result.worktrees.length
})
)
return {
importedCount: importedRepos.length,
fixtureImportCount: fixtureRepos.length,
allSelectedInGroup:
group !== undefined &&
importedRepos.every((repo) => isInGroupSubtree(repo.projectGroupId)),
allSelectedHaveWorktrees:
importedRepos.length === args.selectedProjectPaths.length &&
worktreeCounts.every((count) => count > 0)
}
}, fixture),
{
timeout: 20_000,
message: 'selected repos from the large nested scan were not imported into the group'
}
)
.toEqual({
importedCount: 3,
fixtureImportCount: 3,
allSelectedInGroup: true,
allSelectedHaveWorktrees: true
})
})
})