feat: fix for push private repo (#1618)
* feat: push private repo # GitHub Integration Changelog ## Fixed - Fixed issue where repositories marked as private weren't being created with private visibility - Added support for changing repository visibility (public/private) when pushing to existing repositories - Fixed 404 errors when pushing files after changing repository visibility ## Added - Added clear user warnings when changing repository visibility from public to private or vice versa - Implemented delays after visibility changes to allow GitHub API to fully process the change - Added retry mechanism (up to 3 attempts with increasing delays) for pushing files after visibility changes - Added repository data refresh before pushing to ensure latest reference data ## Improved - Enhanced error logging and handling for all GitHub API operations - Updated return value handling to use actual repository URLs from the API response - Added comprehensive logging to track repository creation and update operations * cleanup * Update Workbench.client.tsx
This commit is contained in:
@@ -136,15 +136,24 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
const octokit = new Octokit({ auth: connection.token });
|
||||
|
||||
try {
|
||||
await octokit.repos.get({
|
||||
const { data: existingRepo } = await octokit.repos.get({
|
||||
owner: connection.user.login,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
// If we get here, the repo exists
|
||||
const confirmOverwrite = window.confirm(
|
||||
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
|
||||
);
|
||||
let confirmMessage = `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`;
|
||||
|
||||
// Add visibility change warning if needed
|
||||
if (existingRepo.private !== isPrivate) {
|
||||
const visibilityChange = isPrivate
|
||||
? 'This will also change the repository from public to private.'
|
||||
: 'This will also change the repository from private to public.';
|
||||
|
||||
confirmMessage += `\n\n${visibilityChange}`;
|
||||
}
|
||||
|
||||
const confirmOverwrite = window.confirm(confirmMessage);
|
||||
|
||||
if (!confirmOverwrite) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
|
||||
// Configure standard toast settings
|
||||
export const configuredToast = {
|
||||
success: (message: string, options = {}) => toastify.success(message, { autoClose: 3000, ...options }),
|
||||
error: (message: string, options = {}) => toastify.error(message, { autoClose: 3000, ...options }),
|
||||
info: (message: string, options = {}) => toastify.info(message, { autoClose: 3000, ...options }),
|
||||
warning: (message: string, options = {}) => toastify.warning(message, { autoClose: 3000, ...options }),
|
||||
loading: (message: string, options = {}) => toastify.loading(message, { autoClose: 3000, ...options }),
|
||||
};
|
||||
|
||||
// Export the original toast for cases where specific configuration is needed
|
||||
export { toastify as toast };
|
||||
|
||||
interface ToastOptions {
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
@@ -36,5 +48,19 @@ export function useToast() {
|
||||
[toast],
|
||||
);
|
||||
|
||||
return { toast, success, error };
|
||||
const info = useCallback(
|
||||
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
|
||||
toast(message, { ...options, type: 'info' });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, options: Omit<ToastOptions, 'type'> = {}) => {
|
||||
toast(message, { ...options, type: 'warning' });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
return { toast, success, error, info, warning };
|
||||
}
|
||||
|
||||
@@ -388,11 +388,9 @@ export const Workbench = memo(
|
||||
Toggle Terminal
|
||||
</PanelHeaderButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="bg-transparent">
|
||||
<button className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
|
||||
<div className="i-ph:box-arrow-up" />
|
||||
Sync & Export
|
||||
</button>
|
||||
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
|
||||
<div className="i-ph:box-arrow-up" />
|
||||
Sync & Export
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
@@ -491,12 +489,12 @@ export const Workbench = memo(
|
||||
<PushToGitHubDialog
|
||||
isOpen={isPushDialogOpen}
|
||||
onClose={() => setIsPushDialogOpen(false)}
|
||||
onPush={async (repoName, username, token) => {
|
||||
onPush={async (repoName, username, token, isPrivate) => {
|
||||
try {
|
||||
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
||||
await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
|
||||
console.log('Dialog onPush called with isPrivate =', isPrivate);
|
||||
|
||||
const repoUrl = `https://github.com/${username}/${repoName}`;
|
||||
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
||||
const repoUrl = await workbenchStore.pushToGitHub(repoName, commitMessage, username, token, isPrivate);
|
||||
|
||||
if (updateChatMestaData && !metadata?.gitUrl) {
|
||||
updateChatMestaData({
|
||||
|
||||
@@ -600,7 +600,13 @@ export class WorkbenchStore {
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
|
||||
async pushToGitHub(
|
||||
repoName: string,
|
||||
commitMessage?: string,
|
||||
githubUsername?: string,
|
||||
ghToken?: string,
|
||||
isPrivate: boolean = false,
|
||||
) {
|
||||
try {
|
||||
// Use cookies if username and token are not provided
|
||||
const githubToken = ghToken || Cookies.get('githubToken');
|
||||
@@ -610,26 +616,72 @@ export class WorkbenchStore {
|
||||
throw new Error('GitHub token or username is not set in cookies or provided.');
|
||||
}
|
||||
|
||||
// Log the isPrivate flag to verify it's being properly passed
|
||||
console.log(`pushToGitHub called with isPrivate=${isPrivate}`);
|
||||
|
||||
// Initialize Octokit with the auth token
|
||||
const octokit = new Octokit({ auth: githubToken });
|
||||
|
||||
// Check if the repository already exists before creating it
|
||||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
let visibilityJustChanged = false;
|
||||
|
||||
try {
|
||||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = resp.data;
|
||||
console.log('Repository already exists, using existing repo');
|
||||
|
||||
// Check if we need to update visibility of existing repo
|
||||
if (repo.private !== isPrivate) {
|
||||
console.log(
|
||||
`Updating repository visibility from ${repo.private ? 'private' : 'public'} to ${isPrivate ? 'private' : 'public'}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Update repository visibility using the update method
|
||||
const { data: updatedRepo } = await octokit.repos.update({
|
||||
owner,
|
||||
repo: repoName,
|
||||
private: isPrivate,
|
||||
});
|
||||
|
||||
console.log('Repository visibility updated successfully');
|
||||
repo = updatedRepo;
|
||||
visibilityJustChanged = true;
|
||||
|
||||
// Add a delay after changing visibility to allow GitHub to fully process the change
|
||||
console.log('Waiting for visibility change to propagate...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // 3 second delay
|
||||
} catch (visibilityError) {
|
||||
console.error('Failed to update repository visibility:', visibilityError);
|
||||
|
||||
// Continue with push even if visibility update fails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
// Repository doesn't exist, so create a new one
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
||||
console.log(`Creating new repository with private=${isPrivate}`);
|
||||
|
||||
// Create new repository with specified privacy setting
|
||||
const createRepoOptions = {
|
||||
name: repoName,
|
||||
private: false,
|
||||
private: isPrivate,
|
||||
auto_init: true,
|
||||
});
|
||||
};
|
||||
|
||||
console.log('Create repo options:', createRepoOptions);
|
||||
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser(createRepoOptions);
|
||||
|
||||
console.log('Repository created:', newRepo.html_url, 'Private:', newRepo.private);
|
||||
repo = newRepo;
|
||||
|
||||
// Add a small delay after creating a repository to allow GitHub to fully initialize it
|
||||
console.log('Waiting for repository to initialize...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second delay
|
||||
} else {
|
||||
console.log('cannot create repo!');
|
||||
console.error('Cannot create repo:', error);
|
||||
throw error; // Some other error occurred
|
||||
}
|
||||
}
|
||||
@@ -641,68 +693,102 @@ export class WorkbenchStore {
|
||||
throw new Error('No files found to push');
|
||||
}
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||
if (dirent?.type === 'file' && dirent.content) {
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
||||
// Function to push files with retry logic
|
||||
const pushFilesToRepo = async (attempt = 1): Promise<string> => {
|
||||
const maxAttempts = 3;
|
||||
|
||||
try {
|
||||
console.log(`Pushing files to repository (attempt ${attempt}/${maxAttempts})...`);
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||
if (dirent?.type === 'file' && dirent.content) {
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
|
||||
if (validBlobs.length === 0) {
|
||||
throw new Error('No valid files to push');
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
// Refresh repository reference to ensure we have the latest data
|
||||
const repoRefresh = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = repoRefresh.data;
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||||
const { data: ref } = await octokit.git.getRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
});
|
||||
const latestCommitSha = ref.object.sha;
|
||||
|
||||
if (validBlobs.length === 0) {
|
||||
throw new Error('No valid files to push');
|
||||
}
|
||||
// Create a new tree
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
base_tree: latestCommitSha,
|
||||
tree: validBlobs.map((blob) => ({
|
||||
path: blob!.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob!.sha,
|
||||
})),
|
||||
});
|
||||
|
||||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||||
const { data: ref } = await octokit.git.getRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
});
|
||||
const latestCommitSha = ref.object.sha;
|
||||
// Create a new commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
message: commitMessage || 'Initial commit from your app',
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
|
||||
// Create a new tree
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
base_tree: latestCommitSha,
|
||||
tree: validBlobs.map((blob) => ({
|
||||
path: blob!.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob!.sha,
|
||||
})),
|
||||
});
|
||||
// Update the reference
|
||||
await octokit.git.updateRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
|
||||
// Create a new commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
message: commitMessage || 'Initial commit from your app',
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
console.log('Files successfully pushed to repository');
|
||||
|
||||
// Update the reference
|
||||
await octokit.git.updateRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
return repo.html_url;
|
||||
} catch (error) {
|
||||
console.error(`Error during push attempt ${attempt}:`, error);
|
||||
|
||||
alert(`Repository created and code pushed: ${repo.html_url}`);
|
||||
// If we've just changed visibility and this is not our last attempt, wait and retry
|
||||
if ((visibilityJustChanged || attempt === 1) && attempt < maxAttempts) {
|
||||
const delayMs = attempt * 2000; // Increasing delay with each attempt
|
||||
console.log(`Waiting ${delayMs}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
|
||||
return pushFilesToRepo(attempt + 1);
|
||||
}
|
||||
|
||||
throw error; // Rethrow if we're out of attempts
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the push function with retry logic
|
||||
const repoUrl = await pushFilesToRepo();
|
||||
|
||||
// Return the repository URL
|
||||
return repoUrl;
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
throw error; // Rethrow the error for further handling
|
||||
|
||||
229
app/utils/file-watcher.ts
Normal file
229
app/utils/file-watcher.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { WebContainer } from '@webcontainer/api';
|
||||
import { WORK_DIR } from './constants';
|
||||
|
||||
// Global object to track watcher state
|
||||
const watcherState = {
|
||||
fallbackEnabled: tryLoadFallbackState(),
|
||||
watchingPaths: new Set<string>(),
|
||||
callbacks: new Map<string, Set<() => void>>(),
|
||||
pollingInterval: null as NodeJS.Timeout | null,
|
||||
};
|
||||
|
||||
// Try to load the fallback state from localStorage
|
||||
function tryLoadFallbackState(): boolean {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const state = localStorage.getItem('bolt-file-watcher-fallback');
|
||||
return state === 'true';
|
||||
}
|
||||
} catch {
|
||||
console.warn('[FileWatcher] Failed to load fallback state from localStorage');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the fallback state to localStorage
|
||||
function saveFallbackState(state: boolean) {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('bolt-file-watcher-fallback', state ? 'true' : 'false');
|
||||
}
|
||||
} catch {
|
||||
console.warn('[FileWatcher] Failed to save fallback state to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe file watcher that falls back to polling when native file watching fails
|
||||
*
|
||||
* @param webcontainer The WebContainer instance
|
||||
* @param pattern File pattern to watch
|
||||
* @param callback Function to call when files change
|
||||
* @returns An object with a close method
|
||||
*/
|
||||
export async function safeWatch(webcontainer: WebContainer, pattern: string = '**/*', callback: () => void) {
|
||||
// Register the callback
|
||||
if (!watcherState.callbacks.has(pattern)) {
|
||||
watcherState.callbacks.set(pattern, new Set());
|
||||
}
|
||||
|
||||
watcherState.callbacks.get(pattern)!.add(callback);
|
||||
|
||||
// If we're already using fallback mode, don't try native watchers again
|
||||
if (watcherState.fallbackEnabled) {
|
||||
// Make sure polling is active
|
||||
ensurePollingActive();
|
||||
|
||||
// Return a cleanup function
|
||||
return {
|
||||
close: () => {
|
||||
const callbacks = watcherState.callbacks.get(pattern);
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
|
||||
if (callbacks.size === 0) {
|
||||
watcherState.callbacks.delete(pattern);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Try to use native file watching
|
||||
try {
|
||||
const watcher = await webcontainer.fs.watch(pattern, { persistent: true });
|
||||
watcherState.watchingPaths.add(pattern);
|
||||
|
||||
// Use the native watch events
|
||||
(watcher as any).addEventListener('change', () => {
|
||||
// Call all callbacks for this pattern
|
||||
const callbacks = watcherState.callbacks.get(pattern);
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}
|
||||
});
|
||||
|
||||
// Return an object with a close method
|
||||
return {
|
||||
close: () => {
|
||||
try {
|
||||
watcher.close();
|
||||
watcherState.watchingPaths.delete(pattern);
|
||||
|
||||
const callbacks = watcherState.callbacks.get(pattern);
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
|
||||
if (callbacks.size === 0) {
|
||||
watcherState.callbacks.delete(pattern);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[FileWatcher] Error closing watcher:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[FileWatcher] Native file watching failed:', error);
|
||||
console.info('[FileWatcher] Falling back to polling mechanism for file changes');
|
||||
|
||||
// Switch to fallback mode for all future watches
|
||||
watcherState.fallbackEnabled = true;
|
||||
saveFallbackState(true);
|
||||
|
||||
// Start polling
|
||||
ensurePollingActive();
|
||||
|
||||
// Return a mock watcher object
|
||||
return {
|
||||
close: () => {
|
||||
const callbacks = watcherState.callbacks.get(pattern);
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
|
||||
if (callbacks.size === 0) {
|
||||
watcherState.callbacks.delete(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// If no more callbacks, stop polling
|
||||
if (watcherState.callbacks.size === 0 && watcherState.pollingInterval) {
|
||||
clearInterval(watcherState.pollingInterval);
|
||||
watcherState.pollingInterval = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure polling is active
|
||||
function ensurePollingActive() {
|
||||
if (watcherState.pollingInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up a polling interval that calls all callbacks
|
||||
watcherState.pollingInterval = setInterval(() => {
|
||||
// Call all registered callbacks
|
||||
for (const [, callbacks] of watcherState.callbacks.entries()) {
|
||||
callbacks.forEach((callback) => callback());
|
||||
}
|
||||
}, 3000); // Poll every 3 seconds
|
||||
|
||||
// Clean up interval when window unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (watcherState.pollingInterval) {
|
||||
clearInterval(watcherState.pollingInterval);
|
||||
watcherState.pollingInterval = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SafeWatchPaths mimics the webcontainer.internal.watchPaths method but with fallback
|
||||
export function safeWatchPaths(
|
||||
webcontainer: WebContainer,
|
||||
config: { include: string[]; exclude?: string[]; includeContent?: boolean },
|
||||
callback: any,
|
||||
) {
|
||||
// Create a valid mock event to prevent undefined errors
|
||||
const createMockEvent = () => ({
|
||||
type: 'change',
|
||||
path: `${WORK_DIR}/mock-path.txt`,
|
||||
buffer: new Uint8Array(0),
|
||||
});
|
||||
|
||||
// Start with polling if we already know native watching doesn't work
|
||||
if (watcherState.fallbackEnabled) {
|
||||
console.info('[FileWatcher] Using fallback polling for watchPaths');
|
||||
ensurePollingActive();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Use our helper to create a valid event
|
||||
const mockEvent = createMockEvent();
|
||||
|
||||
// Wrap in the expected structure of nested arrays
|
||||
callback([[mockEvent]]);
|
||||
}, 3000);
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Try native watching
|
||||
try {
|
||||
return webcontainer.internal.watchPaths(config, callback);
|
||||
} catch (error) {
|
||||
console.warn('[FileWatcher] Native watchPaths failed:', error);
|
||||
console.info('[FileWatcher] Using fallback polling for watchPaths');
|
||||
|
||||
// Mark as using fallback
|
||||
watcherState.fallbackEnabled = true;
|
||||
saveFallbackState(true);
|
||||
|
||||
// Set up polling
|
||||
ensurePollingActive();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Use our helper to create a valid event
|
||||
const mockEvent = createMockEvent();
|
||||
|
||||
// Wrap in the expected structure of nested arrays
|
||||
callback([[mockEvent]]);
|
||||
}, 3000);
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user