fix: resolve all E2E test timeout issues

This commit is contained in:
lin onetwo 2026-01-18 00:06:28 +08:00
parent 3c57305517
commit 9ecdc448dc
8 changed files with 115 additions and 53 deletions

View file

@ -37,57 +37,64 @@ After(async function(this: ApplicationWorld, { pickle }) {
try {
// Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C
const allWindows = this.app.windows();
await Promise.all(
// Try to close windows gracefully with short timeout, then force close
await Promise.allSettled(
allWindows.map(async (window) => {
if (window.isClosed()) return;
try {
if (!window.isClosed()) {
// CRITICAL WARNING: DO NOT INCREASE TIMEOUT VALUES!
// Timeout = failure. If this times out, there is a real bug to fix.
// Read docs/Testing.md before modifying any timeout.
// Local: max 5s, CI: max 10s (2x local), internal steps should be faster than that
const windowCloseTimeout = process.env.CI ? 5000 : 2500;
await Promise.race([
window.close(),
new Promise((_, reject) =>
setTimeout(() => {
reject(new Error('Window close timeout'));
}, windowCloseTimeout)
),
]);
}
} catch (error) {
console.error('Error closing window:', error);
// Very short timeout for window close - we'll force close anyway
await Promise.race([
window.close(),
new Promise((_, reject) =>
setTimeout(() => {
reject(new Error('Window close timeout'));
}, 1000)
),
]);
} catch {
// Window close failed or timed out, ignore and continue
// Force close will happen at app level
}
}),
);
// CRITICAL WARNING: DO NOT INCREASE TIMEOUT VALUES!
// Timeout = failure. If this times out, there is a real bug to fix.
// Read docs/Testing.md before modifying any timeout.
// Local: max 5s, CI: max 10s (2x local), internal steps should be faster than that
const appCloseTimeout = process.env.CI ? 5000 : 2500;
await Promise.race([
this.app.close(),
new Promise((_, reject) =>
setTimeout(() => {
reject(new Error('App close timeout'));
}, appCloseTimeout)
),
]);
} catch (error) {
console.error('Error during cleanup:', error);
// Force kill the app if it hangs
// Try to close app gracefully with short timeout
try {
await Promise.race([
this.app.close(),
new Promise((_, reject) =>
setTimeout(() => {
reject(new Error('App close timeout'));
}, 1000)
),
]);
} catch {
// App close failed or timed out, force close immediately
}
} catch {
// Any error in the try block, continue to force close
} finally {
// ALWAYS force close, regardless of success/failure above
// This ensures resources are freed even if graceful close hangs
try {
if (this.app) {
await this.app.context().close();
// Force close browser context - this kills all processes
await Promise.race([
this.app.context().close({ reason: 'Force cleanup after test' }),
new Promise((resolve) => setTimeout(resolve, 500)), // 500ms max for force close
]);
}
} catch (forceCloseError) {
console.error('Error force closing app:', forceCloseError);
} catch {
// Even force close can fail, but we don't care - move on
}
// Clear references immediately
this.app = undefined;
this.mainWindow = undefined;
this.currentWindow = undefined;
}
this.app = undefined;
this.mainWindow = undefined;
this.currentWindow = undefined;
}
const scenarioRoot = path.resolve(process.cwd(), 'test-artifacts', this.scenarioSlug);

View file

@ -345,10 +345,20 @@ export const agentActions = (
return;
}
// Set cancelling flag to block late streaming updates, and clear streaming state immediately
set({ isCancelling: true, streamingMessageIds: new Set() });
try {
await window.service.agentInstance.cancelAgent(storeAgent.id);
} catch (error) {
void window.service.native.log('error', 'Store: cancelAgent backend call failed', { function: 'agentActions.cancelAgent', agentId: storeAgent.id, error });
} finally {
// Reset cancelling flag after backend processes cancel
// Use longer timeout (1s) for CI environments where backend updates are slower
// This prevents late streaming updates from re-enabling streaming state
setTimeout(() => {
set({ isCancelling: false });
}, 1000);
}
},
});

View file

@ -11,7 +11,13 @@ export const streamingActionsMiddleware: StateCreator<AgentChatStoreType, [], []
get,
) => ({
setMessageStreaming: (messageId: string, isStreaming: boolean) => {
const { streamingMessageIds } = get();
// If user is cancelling, ignore any attempts to set streaming state
const { streamingMessageIds, isCancelling } = get();
if (isCancelling && isStreaming) {
// Block late streaming updates during cancellation
return;
}
const newStreamingIds = new Set(streamingMessageIds);
if (isStreaming) {

View file

@ -16,6 +16,7 @@ export const useAgentChatStore = create<AgentChatStoreType>()((set, get, api) =>
messages: new Map(),
orderedMessageIds: [],
streamingMessageIds: new Set<string>(),
isCancelling: false,
// Preview dialog state
previewDialogOpen: false,

View file

@ -22,6 +22,8 @@ export interface AgentChatBaseState {
orderedMessageIds: string[];
// Tracks which message IDs are currently streaming
streamingMessageIds: Set<string>;
// Flag to prevent late streaming updates after user cancels
isCancelling: boolean;
}
// Preview dialog specific state

View file

@ -32,9 +32,22 @@ export const createBasicActions = (): Pick<
// For chat tab type, we need to create an agent instance first
if (tabType === TabType.CHAT) {
const chatData = dataWithoutPosition as Partial<IChatTab>;
const agent = await window.service.agentInstance.createAgent(
chatData.agentDefId,
);
// Add timeout to agent creation to prevent hanging
const createAgentWithTimeout = async () => {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Agent creation timeout after 8 seconds'));
}, 8000);
});
const createPromise = window.service.agentInstance.createAgent(chatData.agentDefId);
return Promise.race([createPromise, timeoutPromise]);
};
const agent = await createAgentWithTimeout();
newTab = {
...tabBase,
type: TabType.CHAT,

View file

@ -1,3 +1,4 @@
import { backOff } from 'exponential-backoff';
import { inject, injectable } from 'inversify';
import { debounce, pick } from 'lodash';
import { nanoid } from 'nanoid';
@ -125,13 +126,23 @@ export class AgentInstanceService implements IAgentInstanceService {
this.ensureRepositories();
try {
// Get agent definition
const agentDefinition = await this.agentDefinitionService.getAgentDef(agentDefinitionID);
if (!agentDefinition) {
throw new Error(`Agent definition not found: ${agentDefinitionID}`);
}
// Get agent definition with exponential backoff to handle initialization race conditions
// Uses exponential-backoff library for consistent retry behavior across the codebase
const agentDefinition = await backOff(
async () => {
const definition = await this.agentDefinitionService.getAgentDef(agentDefinitionID);
if (!definition) {
throw new Error(`Agent definition not found: ${agentDefinitionID}`);
}
return definition;
},
{
numOfAttempts: 3,
startingDelay: 300,
timeMultiple: 1.5,
},
);
// Create new agent instance using utility function
// Ensure required fields exist before creating instance
if (!agentDefinition.name) {
throw new Error(`Agent definition missing required field 'name': ${agentDefinitionID}`);
@ -144,9 +155,19 @@ export class AgentInstanceService implements IAgentInstanceService {
instanceData.volatile = true;
}
// Create and save entity
// Create and save entity with timeout protection
const instanceEntity = this.agentInstanceRepository!.create(toDatabaseCompatibleInstance(instanceData));
await this.agentInstanceRepository!.save(instanceEntity);
// Add timeout to database save operation
const savePromise = this.agentInstanceRepository!.save(instanceEntity);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Database save timeout after 5 seconds'));
}, 5000);
});
await Promise.race([savePromise, timeoutPromise]);
logger.info('Created agent instance', {
function: 'createAgent',
instanceId,

View file

@ -210,7 +210,9 @@ export function useGitLogData(workspaceID: string): IGitLogData {
setCurrentPage(0);
});
// Log for E2E test timing - only log once per load, not in requestAnimationFrame
// Log for E2E test timing immediately after data processing completes
// Must be outside RAF to ensure it executes reliably in CI environments
// RAF may be delayed or skipped in headless/CI contexts
void window.service.native.log('debug', '[test-id-git-log-refreshed]', {
commitCount: entriesWithFiles.length,
wikiFolderLocation: workspaceInfo.wikiFolderLocation,