mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge remote-tracking branch 'upstream/v0.12.0rc' into fix/light-mode-agent-output
This commit is contained in:
@@ -142,6 +142,8 @@ if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
║ ║
|
||||
║ 💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function loadBacklogPlan(projectPath: string): Promise<StoredBacklo
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
const raw = await secureFs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw as string) as StoredBacklogPlan;
|
||||
if (!parsed?.result?.changes) {
|
||||
if (!Array.isArray(parsed?.result?.changes)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
|
||||
@@ -17,7 +17,13 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { logger, setRunningState, getErrorMessage, saveBacklogPlan } from './common.js';
|
||||
import {
|
||||
logger,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
saveBacklogPlan,
|
||||
} from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
|
||||
@@ -225,5 +231,6 @@ ${userPrompt}`;
|
||||
throw error;
|
||||
} finally {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +147,21 @@ export function createApplyHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the plan before responding
|
||||
try {
|
||||
await clearBacklogPlan(projectPath);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[BacklogPlan] Failed to clear backlog plan after apply:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
// Don't throw - operation succeeded, just cleanup failed
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
appliedChanges,
|
||||
});
|
||||
|
||||
await clearBacklogPlan(projectPath);
|
||||
} catch (error) {
|
||||
logError(error, 'Apply backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import {
|
||||
getAbortController,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
@@ -12,6 +18,7 @@ export function createStopHandler() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,10 +16,27 @@ import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
checkGitHubRemote,
|
||||
type GitHubRemoteStatus,
|
||||
} from '../../github/routes/check-github-remote.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
/**
|
||||
* Cache for GitHub remote status per project path.
|
||||
* This prevents repeated "no git remotes found" warnings when polling
|
||||
* projects that don't have a GitHub remote configured.
|
||||
*/
|
||||
interface GitHubRemoteCacheEntry {
|
||||
status: GitHubRemoteStatus;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -121,23 +138,63 @@ async function scanWorktreesDirectory(
|
||||
return discovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached GitHub remote status for a project, or check and cache it.
|
||||
* Returns null if gh CLI is not available.
|
||||
*/
|
||||
async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteStatus | null> {
|
||||
// Check if gh CLI is available first
|
||||
const ghAvailable = await isGhCliAvailable();
|
||||
if (!ghAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cached = githubRemoteCache.get(projectPath);
|
||||
|
||||
// Return cached result if still valid
|
||||
if (cached && now - cached.checkedAt < GITHUB_REMOTE_CACHE_TTL_MS) {
|
||||
return cached.status;
|
||||
}
|
||||
|
||||
// Check GitHub remote and cache the result
|
||||
const status = await checkGitHubRemote(projectPath);
|
||||
githubRemoteCache.set(projectPath, {
|
||||
status,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch open PRs from GitHub and create a map of branch name to PR info.
|
||||
* This allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
// Check if gh CLI is available
|
||||
const ghAvailable = await isGhCliAvailable();
|
||||
if (!ghAvailable) {
|
||||
// Check GitHub remote status (uses cache to avoid repeated warnings)
|
||||
const remoteStatus = await getGitHubRemoteStatus(projectPath);
|
||||
|
||||
// If gh CLI not available or no GitHub remote, return empty silently
|
||||
if (!remoteStatus || !remoteStatus.hasGitHubRemote) {
|
||||
return prMap;
|
||||
}
|
||||
|
||||
// Use -R flag with owner/repo for more reliable PR fetching
|
||||
const repoFlag =
|
||||
remoteStatus.owner && remoteStatus.repo
|
||||
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
|
||||
: '';
|
||||
|
||||
// Fetch open PRs from GitHub
|
||||
const { stdout } = await execAsync(
|
||||
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000',
|
||||
`gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
|
||||
{ cwd: projectPath, env: execEnv, timeout: 15000 }
|
||||
);
|
||||
|
||||
@@ -170,9 +227,10 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
export function createListHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, includeDetails } = req.body as {
|
||||
const { projectPath, includeDetails, forceRefreshGitHub } = req.body as {
|
||||
projectPath: string;
|
||||
includeDetails?: boolean;
|
||||
forceRefreshGitHub?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -180,6 +238,12 @@ export function createListHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear GitHub remote cache if force refresh requested
|
||||
// This allows users to re-check for GitHub remote after adding one
|
||||
if (forceRefreshGitHub) {
|
||||
githubRemoteCache.delete(projectPath);
|
||||
}
|
||||
|
||||
if (!(await isGitRepo(projectPath))) {
|
||||
res.json({ success: true, worktrees: [] });
|
||||
return;
|
||||
|
||||
@@ -220,12 +220,34 @@
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "Development",
|
||||
"icon": "public/logo_larger.png",
|
||||
"maintainer": "webdevcody@gmail.com",
|
||||
"executableName": "automaker"
|
||||
"executableName": "automaker",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"synopsis": "AI-powered autonomous development studio"
|
||||
},
|
||||
"rpm": {
|
||||
"depends": [
|
||||
"gtk3",
|
||||
"libnotify",
|
||||
"nss",
|
||||
"libXScrnSaver",
|
||||
"libXtst",
|
||||
"xdg-utils",
|
||||
"at-spi2-core",
|
||||
"libuuid"
|
||||
],
|
||||
"compression": "xz",
|
||||
"vendor": "AutoMaker Team"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('AnalysisView');
|
||||
|
||||
@@ -638,7 +638,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
for (const detectedFeature of detectedFeatures) {
|
||||
await api.features.create(currentProject.path, {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
status: 'backlog',
|
||||
|
||||
@@ -39,7 +39,10 @@ export function useWorktrees({
|
||||
logger.warn('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
// Pass forceRefreshGitHub when this is a manual refresh (not silent polling)
|
||||
// This clears the GitHub remote cache so users can re-detect after adding a remote
|
||||
const forceRefreshGitHub = !silent;
|
||||
const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
@@ -137,7 +137,7 @@ export function GitHubIssuesView() {
|
||||
.join('\n');
|
||||
|
||||
const feature = {
|
||||
id: `issue-${issue.number}-${crypto.randomUUID()}`,
|
||||
id: `issue-${issue.number}-${generateUUID()}`,
|
||||
title: issue.title,
|
||||
description,
|
||||
category: 'From GitHub',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
@@ -345,7 +345,7 @@ export function InterviewView() {
|
||||
|
||||
// Create initial feature in the features folder
|
||||
const initialFeature: Feature = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
category: 'Core',
|
||||
description: 'Initial project setup',
|
||||
status: 'backlog' as const,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
EventHookHttpAction,
|
||||
} from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
interface EventHookDialogProps {
|
||||
open: boolean;
|
||||
@@ -109,7 +110,7 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
|
||||
const handleSave = () => {
|
||||
const hook: EventHook = {
|
||||
id: editingHook?.id || crypto.randomUUID(),
|
||||
id: editingHook?.id || generateUUID(),
|
||||
name: name.trim() || undefined,
|
||||
trigger,
|
||||
enabled: editingHook?.enabled ?? true,
|
||||
|
||||
@@ -166,6 +166,7 @@ export function PhaseModelSelector({
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledDynamicModelIds,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
@@ -383,13 +384,16 @@ export function PhaseModelSelector({
|
||||
const staticModels = [...OPENCODE_MODELS];
|
||||
|
||||
// Add dynamic models (convert ModelDefinition to ModelOption)
|
||||
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
|
||||
provider: 'opencode' as const,
|
||||
}));
|
||||
// Only include dynamic models that are enabled by the user
|
||||
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels
|
||||
.filter((model) => enabledDynamicModelIds.includes(model.id))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
|
||||
provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
// Merge, avoiding duplicates (static models take precedence for same ID)
|
||||
// In practice, static and dynamic IDs don't overlap
|
||||
@@ -397,7 +401,7 @@ export function PhaseModelSelector({
|
||||
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
|
||||
|
||||
return [...staticModels, ...uniqueDynamic];
|
||||
}, [dynamicOpencodeModels]);
|
||||
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||
|
||||
// Group models (filtering out disabled providers)
|
||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||
|
||||
@@ -611,16 +611,16 @@ export function OpencodeModelConfiguration({
|
||||
Dynamic
|
||||
</Badge>
|
||||
</div>
|
||||
{models.length > 0 && (
|
||||
{filteredModels.length > 0 && (
|
||||
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
|
||||
<Checkbox
|
||||
checked={getSelectionState(
|
||||
models.map((model) => model.id),
|
||||
filteredModels.map((model) => model.id),
|
||||
enabledDynamicModelIds
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleProviderDynamicModels(
|
||||
models.map((model) => model.id),
|
||||
filteredModels.map((model) => model.id),
|
||||
checked
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1596,10 +1596,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return { success: true, worktrees: [] };
|
||||
},
|
||||
|
||||
listAll: async (projectPath: string, includeDetails?: boolean) => {
|
||||
listAll: async (
|
||||
projectPath: string,
|
||||
includeDetails?: boolean,
|
||||
forceRefreshGitHub?: boolean
|
||||
) => {
|
||||
console.log('[Mock] Listing all worktrees:', {
|
||||
projectPath,
|
||||
includeDetails,
|
||||
forceRefreshGitHub,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1724,8 +1724,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/worktree/status', { projectPath, featureId }),
|
||||
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
||||
this.post('/api/worktree/list', { projectPath, includeDetails }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean, forceRefreshGitHub?: boolean) =>
|
||||
this.post('/api/worktree/list', { projectPath, includeDetails, forceRefreshGitHub }),
|
||||
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/create', {
|
||||
projectPath,
|
||||
|
||||
@@ -124,3 +124,39 @@ export const isMac =
|
||||
: typeof navigator !== 'undefined' &&
|
||||
(/Mac/.test(navigator.userAgent) ||
|
||||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 string.
|
||||
*
|
||||
* Uses crypto.randomUUID() when available (secure contexts: HTTPS or localhost).
|
||||
* Falls back to crypto.getRandomValues() for non-secure contexts (e.g., Docker via HTTP).
|
||||
*
|
||||
* @returns A RFC 4122 compliant UUID v4 string (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
// Use native randomUUID if available (secure contexts: HTTPS or localhost)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback using crypto.getRandomValues() (works in all modern browsers, including non-secure contexts)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Set version (4) and variant (RFC 4122) bits
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
|
||||
|
||||
// Convert to hex string with proper UUID format
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// Last resort fallback using Math.random() - less secure but ensures functionality
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
3
apps/ui/src/types/electron.d.ts
vendored
3
apps/ui/src/types/electron.d.ts
vendored
@@ -705,7 +705,8 @@ export interface WorktreeAPI {
|
||||
// List all worktrees with details (for worktree selector)
|
||||
listAll: (
|
||||
projectPath: string,
|
||||
includeDetails?: boolean
|
||||
includeDetails?: boolean,
|
||||
forceRefreshGitHub?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
worktrees?: Array<{
|
||||
|
||||
@@ -130,8 +130,8 @@ test.describe('Feature Manual Review Flow', () => {
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Verify we're on the correct project (project name appears in sidebar button)
|
||||
await expect(page.getByRole('button', { name: new RegExp(projectName) })).toBeVisible({
|
||||
// Verify we're on the correct project (project switcher button shows project name)
|
||||
await expect(page.getByTestId(`project-switcher-project-${projectName}`)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ test.describe('Project Creation', () => {
|
||||
}
|
||||
|
||||
// Wait for project to be set as current and visible on the page
|
||||
// The project name appears in the sidebar project selector button
|
||||
await expect(page.getByRole('button', { name: new RegExp(projectName) })).toBeVisible({
|
||||
// The project name appears in the project switcher button
|
||||
await expect(page.getByTestId(`project-switcher-project-${projectName}`)).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
|
||||
@@ -156,9 +156,9 @@ test.describe('Open Project', () => {
|
||||
}
|
||||
|
||||
// Wait for a project to be set as current and visible on the page
|
||||
// The project name appears in the sidebar project selector button
|
||||
// The project name appears in the project switcher button
|
||||
if (targetProjectName) {
|
||||
await expect(page.getByRole('button', { name: new RegExp(targetProjectName) })).toBeVisible({
|
||||
await expect(page.getByTestId(`project-switcher-project-${targetProjectName}`)).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user