Merge pull request #15 from jedmund/jedmund/autosave
complete autosave implementation
This commit is contained in:
commit
ed906b6c75
5 changed files with 591 additions and 0 deletions
61
docs/autosave-completion-guide.md
Normal file
61
docs/autosave-completion-guide.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Admin Autosave Completion Guide
|
||||
|
||||
## Objectives
|
||||
- Eliminate redundant save requests triggered on initial page load.
|
||||
- Restore reliable local draft recovery, including clear-up of stale backups.
|
||||
- Deliver autosave status feedback that visibly transitions back to `idle` after successful saves.
|
||||
- Ensure navigation/unload flows wait for pending autosaves instead of cancelling them mid-flight.
|
||||
|
||||
## Key Problem Areas
|
||||
|
||||
### Missing Draft Handlers
|
||||
- `src/routes/admin/posts/[id]/edit/+page.svelte:425` references `restoreDraft` and `dismissDraft`, but the functions are never defined. Draft recovery buttons therefore break compilation and runtime behavior.
|
||||
|
||||
### Immediate Autosaves on Load
|
||||
- Effects in `src/routes/admin/posts/[id]/edit/+page.svelte:307` and `src/lib/components/admin/ProjectForm.svelte:157` call `autoSave.schedule()` as soon as the component mounts. Because the payload hash includes `updatedAt`, each mount triggers redundant PUTs until the server response realigns the hash.
|
||||
|
||||
### Ineffective Navigation Guard
|
||||
- `beforeNavigate(() => autoSave.flush())` (posts + project form) does not cancel the outbound navigation, so the flush typically aborts when the route unloads. Result: unsaved work if the user navigates away during a pending autosave.
|
||||
|
||||
### Controller Lifecycle Gaps
|
||||
- `createAutoSaveController` timers/AbortController persist after leaving the page because callers never invoke `destroy()`.
|
||||
- Post editor imports `clearDraft` but never clears the draft after successful saves or when dismissing the prompt, so stale backups reappear.
|
||||
|
||||
## Controller Enhancements (`src/lib/admin/autoSave.ts`)
|
||||
- **Baseline priming**: Add a `prime(initialPayload)` (or allow `onSaved` to pass the response payload) to set `lastSentHash` immediately after fetching server data. This prevents an automatic save when the user has not made changes.
|
||||
- **Auto-idle transition**: When status becomes `'saved'`, set a timeout (e.g., 2s) that reverts status to `'idle'`. Cancel the timeout on any new state change.
|
||||
- **Robust destroy**: Ensure `destroy()` clears pending timers and aborts the current request; expose and require callers to invoke it on component teardown.
|
||||
- Consider optional helper flags (e.g., `autoResetStatus`) so forms do not reimplement timing logic.
|
||||
|
||||
## Shared Lifecycle Helper
|
||||
Create a utility (e.g., `initAutoSaveLifecycle`) that accepts the controller plus configuration:
|
||||
- Registers keyboard shortcut (`Cmd/Ctrl+S`) to `flush()` once the page has loaded.
|
||||
- Provides a real navigation guard that cancels the navigation event, awaits `flush()`, then resumes or surfaces an error.
|
||||
- Hooks into `onDestroy` to remove listeners and call `controller.destroy()`.
|
||||
- Optionally wires window unload handling if needed.
|
||||
|
||||
## Form Integration Checklist
|
||||
|
||||
### Posts Editor (`src/routes/admin/posts/[id]/edit/+page.svelte`)
|
||||
1. Implement `restoreDraft` / `dismissDraft` and handle `clearDraft` after autosave or manual save success.
|
||||
2. Introduce a `hasLoaded` flag set after `loadPost()` (and controller `prime`) before scheduling autosave.
|
||||
3. Adopt the shared lifecycle helper for navigation, keyboard shortcuts, and cleanup.
|
||||
|
||||
### Project Form (`src/lib/components/admin/ProjectForm.svelte`)
|
||||
1. Mirror baseline priming and `hasLoaded` gating before scheduling.
|
||||
2. Clear drafts on success or dismissal, and reuse the lifecycle helper.
|
||||
3. Ensure autosave only starts after the initial project data populates `formData`.
|
||||
|
||||
### Other Forms (Simple Post, Essay, Photo, etc.)
|
||||
- Audit each admin form to ensure they use the shared lifecycle helper, seed baselines, clear drafts, and transition status back to `idle`.
|
||||
|
||||
## Testing & Verification
|
||||
- **Unit Tests**: Cover controller state transitions, baseline priming, abort handling, and auto-idle timeout (`tests/autoSaveController.test.ts`). Run with `node --test --loader tsx tests/autoSaveController.test.ts`.
|
||||
- **Component Tests**: Verify autosave does not fire on initial mount, drafts restore/clear correctly, and navigation waits for flush.
|
||||
- **Manual QA**: Confirm keyboard shortcut behavior, offline fallback, and that UI returns to `idle` after showing “saved”.
|
||||
|
||||
## Structural Considerations
|
||||
- Factor shared autosave wiring into reusable modules to avoid copy/paste drift.
|
||||
- Ensure server response payloads used in `prime()` reflect the canonical representation (including normalized fields) so hashes stay in sync.
|
||||
- Document the lifecycle helper so new admin screens adopt the proven pattern without regression.
|
||||
|
||||
338
prd/PRD-auto-save-functionality.md
Normal file
338
prd/PRD-auto-save-functionality.md
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
# Product Requirements Document: Auto-Save Functionality
|
||||
|
||||
## Executive Summary
|
||||
Implement an intelligent auto-save system for all admin forms and editors to prevent data loss and improve the content creation experience.
|
||||
|
||||
## Problem Statement
|
||||
Currently, users must manually save their work in the admin interface, which can lead to:
|
||||
- Data loss if the browser crashes or connection is interrupted
|
||||
- Anxiety about losing work during long editing sessions
|
||||
- Inefficient workflow with frequent manual saves
|
||||
- No recovery mechanism for unsaved changes
|
||||
|
||||
## Goals & Success Metrics
|
||||
|
||||
### Primary Goals
|
||||
1. Prevent data loss during content creation
|
||||
2. Provide seamless, unobtrusive saving experience
|
||||
3. Enable recovery from unexpected interruptions
|
||||
4. Maintain data consistency and integrity
|
||||
|
||||
### Success Metrics
|
||||
- 0% data loss from browser crashes or network issues
|
||||
- <3 second save latency for typical content
|
||||
- 95% of saves complete without user intervention
|
||||
- User satisfaction with editing experience improvement
|
||||
|
||||
## User Stories
|
||||
|
||||
### As a content creator
|
||||
- I want my work to be automatically saved so I don't lose progress
|
||||
- I want to see clear feedback about save status
|
||||
- I want to recover my work if something goes wrong
|
||||
- I want control over when auto-save is active
|
||||
|
||||
### As a site administrator
|
||||
- I want to ensure data integrity across all saves
|
||||
- I want to minimize server load from frequent saves
|
||||
- I want to track save patterns for optimization
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### Core Auto-Save System
|
||||
|
||||
#### 1. Smart Debouncing
|
||||
- **Content changes**: 2-second delay after user stops typing
|
||||
- **Metadata changes**: Immediate save for critical fields
|
||||
- **Navigation events**: Immediate save before leaving page
|
||||
- **Keyboard shortcut**: Cmd/Ctrl+S for manual save
|
||||
|
||||
#### 2. Save States & Feedback
|
||||
- **Idle**: No pending changes
|
||||
- **Saving**: Active save in progress with spinner
|
||||
- **Saved**: Confirmation with timestamp
|
||||
- **Error**: Clear error message with retry option
|
||||
- **Conflict**: Detection and resolution UI
|
||||
|
||||
#### 3. Data Persistence
|
||||
- **Server-first**: Primary storage in database
|
||||
- **Local backup**: IndexedDB for offline/recovery
|
||||
- **Conflict detection**: Version tracking with timestamps
|
||||
- **Partial saves**: Only send changed fields
|
||||
|
||||
### Visual Design
|
||||
|
||||
#### Status Indicator
|
||||
```
|
||||
States:
|
||||
- Idle: No indicator (clean UI)
|
||||
- Saving: "Saving..." with subtle spinner
|
||||
- Saved: "All changes saved" (fades after 2s)
|
||||
- Error: Red indicator with retry button
|
||||
- Offline: "Working offline" badge
|
||||
```
|
||||
|
||||
#### Positioning
|
||||
- Fixed position in editor header
|
||||
- Non-intrusive, doesn't shift content
|
||||
- Responsive to different screen sizes
|
||||
- Accessible color contrast
|
||||
|
||||
### API Design
|
||||
|
||||
#### New Endpoints
|
||||
```typescript
|
||||
// Auto-save endpoint
|
||||
POST /api/posts/[id]/autosave
|
||||
Body: {
|
||||
content?: JSONContent,
|
||||
title?: string,
|
||||
metadata?: object,
|
||||
lastModified: timestamp
|
||||
}
|
||||
Response: {
|
||||
success: boolean,
|
||||
lastModified: timestamp,
|
||||
conflict?: {
|
||||
serverVersion: object,
|
||||
serverModified: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery endpoint
|
||||
GET /api/posts/[id]/recover
|
||||
Response: {
|
||||
localDraft?: object,
|
||||
serverVersion: object,
|
||||
timestamps: {
|
||||
local?: timestamp,
|
||||
server: timestamp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### Form Components to Update
|
||||
1. **EssayForm.svelte** - Blog posts and essays
|
||||
2. **ProjectForm.svelte** - Project case studies
|
||||
3. **AlbumForm.svelte** - Album descriptions
|
||||
4. **SimplePostForm.svelte** - Simple text posts
|
||||
5. **PhotoPostForm.svelte** - Photo posts with captions
|
||||
|
||||
#### Composer Integration
|
||||
- Hook into TipTap editor's `onUpdate` event
|
||||
- Track content changes separately from metadata
|
||||
- Handle rich media embeds appropriately
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
#### Auto-Save Hook (`useAutoSave.svelte.ts`)
|
||||
```typescript
|
||||
class AutoSave {
|
||||
private state = $state<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
private lastSaved = $state<Date | null>(null)
|
||||
private saveTimer: NodeJS.Timeout | null = null
|
||||
private saveQueue: Set<string> = new Set()
|
||||
|
||||
constructor(options: AutoSaveOptions) {
|
||||
// Initialize with endpoint, auth, debounce settings
|
||||
}
|
||||
|
||||
track(field: string, value: any): void
|
||||
save(immediate?: boolean): Promise<void>
|
||||
recover(): Promise<RecoveryData>
|
||||
reset(): void
|
||||
}
|
||||
```
|
||||
|
||||
#### Svelte 5 Integration
|
||||
- Use `$state` rune for reactive state
|
||||
- Use `$effect` for side effects and cleanup
|
||||
- Use `$derived` for computed values
|
||||
- Maintain compatibility with existing stores
|
||||
|
||||
### Backend Requirements
|
||||
|
||||
#### Database Schema Updates
|
||||
```sql
|
||||
-- Add version tracking
|
||||
ALTER TABLE posts ADD COLUMN version INTEGER DEFAULT 1;
|
||||
ALTER TABLE posts ADD COLUMN last_auto_save TIMESTAMP;
|
||||
|
||||
-- Auto-save drafts table
|
||||
CREATE TABLE auto_save_drafts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entity_type VARCHAR(50),
|
||||
entity_id INTEGER,
|
||||
user_id INTEGER,
|
||||
content JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### Performance Optimizations
|
||||
- Implement request coalescing for rapid changes
|
||||
- Use database transactions for consistency
|
||||
- Add Redis caching for conflict detection
|
||||
- Implement rate limiting per user
|
||||
|
||||
### Security Considerations
|
||||
- Validate user ownership before auto-save
|
||||
- Sanitize content to prevent XSS
|
||||
- Rate limit to prevent abuse
|
||||
- Encrypt local storage data
|
||||
- Audit trail for all saves
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- Save latency <500ms for text content
|
||||
- <2MB memory overhead per form
|
||||
- Debounce efficiency >90% reduction in requests
|
||||
- Support 100+ concurrent editors
|
||||
|
||||
### Reliability
|
||||
- 99.9% save success rate
|
||||
- Graceful degradation on network issues
|
||||
- Automatic retry with exponential backoff
|
||||
- Data recovery from last 24 hours
|
||||
|
||||
### Usability
|
||||
- Zero configuration for basic use
|
||||
- Clear, non-technical error messages
|
||||
- Intuitive conflict resolution
|
||||
- Keyboard accessible
|
||||
|
||||
### Compatibility
|
||||
- Chrome 90+, Firefox 88+, Safari 14+
|
||||
- Mobile responsive
|
||||
- Works with screen readers
|
||||
- Progressive enhancement
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Infrastructure (Week 1-2)
|
||||
- [ ] Create `useAutoSave` hook
|
||||
- [ ] Implement debouncing logic
|
||||
- [ ] Add basic status component
|
||||
- [ ] Create auto-save API endpoint
|
||||
|
||||
### Phase 2: Form Integration (Week 2-3)
|
||||
- [ ] Integrate with EssayForm
|
||||
- [ ] Integrate with ProjectForm
|
||||
- [ ] Add keyboard shortcuts
|
||||
- [ ] Implement local storage backup
|
||||
|
||||
### Phase 3: Advanced Features (Week 3-4)
|
||||
- [ ] Conflict detection and resolution
|
||||
- [ ] Offline support with service worker
|
||||
- [ ] Recovery interface
|
||||
- [ ] Performance monitoring
|
||||
|
||||
### Phase 4: Polish & Testing (Week 4-5)
|
||||
- [ ] UI/UX refinements
|
||||
- [ ] Comprehensive testing
|
||||
- [ ] Documentation
|
||||
- [ ] Performance optimization
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Debounce logic validation
|
||||
- State management correctness
|
||||
- API error handling
|
||||
- Local storage operations
|
||||
|
||||
### Integration Tests
|
||||
- Form component integration
|
||||
- API endpoint validation
|
||||
- Conflict resolution flow
|
||||
- Recovery scenarios
|
||||
|
||||
### E2E Tests
|
||||
- Complete save flow
|
||||
- Network interruption handling
|
||||
- Multi-tab scenarios
|
||||
- Mobile experience
|
||||
|
||||
### Performance Tests
|
||||
- Load testing with concurrent users
|
||||
- Memory leak detection
|
||||
- Network bandwidth usage
|
||||
- Database query optimization
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
1. **Beta Testing**: Deploy to staging with select users
|
||||
2. **Gradual Rollout**: Enable for 10% → 50% → 100% of forms
|
||||
3. **Monitoring**: Track save success rates and user feedback
|
||||
4. **Iteration**: Refine based on real-world usage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Version 2.0
|
||||
- Real-time collaboration indicators
|
||||
- Revision history with diff view
|
||||
- Auto-save templates and drafts
|
||||
- AI-powered content suggestions
|
||||
|
||||
### Version 3.0
|
||||
- Multi-device sync
|
||||
- Offline-first architecture
|
||||
- Advanced merge conflict resolution
|
||||
- Team collaboration features
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Data corruption | High | Implement checksums and validation |
|
||||
| Performance degradation | Medium | Rate limiting and request batching |
|
||||
| User confusion | Low | Clear UI feedback and documentation |
|
||||
| Storage limits | Low | Implement cleanup and quotas |
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Libraries
|
||||
- None required (uses native Svelte/SvelteKit features)
|
||||
|
||||
### Internal Systems
|
||||
- Existing authentication system
|
||||
- Toast notification system
|
||||
- TipTap editor integration
|
||||
- Prisma database client
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Auto-save activates within 2 seconds of changes
|
||||
- [ ] Visual feedback appears for all save states
|
||||
- [ ] Manual save button remains functional
|
||||
- [ ] Recovery works after browser crash
|
||||
- [ ] No data loss in normal operation
|
||||
- [ ] Performance metrics meet targets
|
||||
- [ ] Accessibility standards met
|
||||
- [ ] Documentation complete
|
||||
|
||||
## Appendix
|
||||
|
||||
### Competitive Analysis
|
||||
- **Notion**: Instant save with "Saving..." indicator
|
||||
- **Google Docs**: Real-time with conflict resolution
|
||||
- **WordPress**: Auto-save drafts every 60 seconds
|
||||
- **Medium**: Continuous save with version history
|
||||
|
||||
### User Research Insights
|
||||
- Users expect auto-save in modern editors
|
||||
- Visual feedback reduces anxiety
|
||||
- Recovery options increase trust
|
||||
- Performance is critical for user satisfaction
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2025-01-30
|
||||
**Author**: System Architecture Team
|
||||
**Status**: Ready for Implementation
|
||||
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeNavigate } from '$app/navigation'
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { AutoSaveController } from './autoSave'
|
||||
|
||||
interface AutoSaveLifecycleOptions {
|
||||
isReady?: () => boolean
|
||||
onFlushError?: (error: unknown) => void
|
||||
enableShortcut?: boolean
|
||||
}
|
||||
|
||||
export function initAutoSaveLifecycle(
|
||||
controller: AutoSaveController,
|
||||
options: AutoSaveLifecycleOptions = {}
|
||||
) {
|
||||
const { isReady = () => true, onFlushError, enableShortcut = true } = options
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
onDestroy(() => controller.destroy())
|
||||
return
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!enableShortcut) return
|
||||
if (!isReady()) return
|
||||
const key = event.key.toLowerCase()
|
||||
const isModifier = event.metaKey || event.ctrlKey
|
||||
if (!isModifier || key !== 's') return
|
||||
event.preventDefault()
|
||||
controller.flush().catch((error) => {
|
||||
onFlushError?.(error)
|
||||
})
|
||||
}
|
||||
|
||||
if (enableShortcut) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
const stopNavigating = beforeNavigate(async (navigation) => {
|
||||
if (!isReady()) return
|
||||
navigation.cancel()
|
||||
try {
|
||||
await controller.flush()
|
||||
navigation.retry()
|
||||
} catch (error) {
|
||||
onFlushError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (enableShortcut) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
stopNavigating?.()
|
||||
controller.destroy()
|
||||
}
|
||||
|
||||
onDestroy(stop)
|
||||
|
||||
return { stop }
|
||||
}
|
||||
33
src/routes/+error.svelte
Normal file
33
src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="error-container">
|
||||
<h1 class="error-code">404</h1>
|
||||
<p class="error-message">Not Found</p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 24px;
|
||||
margin-top: $unit-2x;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
99
tests/autoSaveController.test.ts
Normal file
99
tests/autoSaveController.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAutoSaveController } from '../src/lib/admin/autoSave'
|
||||
|
||||
describe('createAutoSaveController', () => {
|
||||
beforeEach(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
// @ts-expect-error add minimal navigator shim for tests
|
||||
global.navigator = { onLine: true }
|
||||
}
|
||||
})
|
||||
|
||||
it('skips save when payload matches primed baseline', async () => {
|
||||
let value = 0
|
||||
let saveCalls = 0
|
||||
|
||||
const controller = createAutoSaveController<{ value: number }>({
|
||||
debounceMs: 5,
|
||||
getPayload: () => ({ value }),
|
||||
save: async () => {
|
||||
saveCalls += 1
|
||||
return { value }
|
||||
}
|
||||
})
|
||||
|
||||
controller.prime({ value })
|
||||
controller.schedule()
|
||||
|
||||
await wait(15)
|
||||
assert.equal(saveCalls, 0)
|
||||
|
||||
await controller.flush()
|
||||
assert.equal(saveCalls, 0)
|
||||
|
||||
controller.destroy()
|
||||
})
|
||||
|
||||
it('saves when payload changes and returns to idle after success', async () => {
|
||||
let value = 0
|
||||
let updatedAt = 0
|
||||
let saveCalls = 0
|
||||
const statuses: string[] = []
|
||||
|
||||
const controller = createAutoSaveController<{ value: number; updatedAt: number }, { updatedAt: number }>({
|
||||
debounceMs: 5,
|
||||
idleResetMs: 10,
|
||||
getPayload: () => ({ value, updatedAt }),
|
||||
save: async (payload) => {
|
||||
saveCalls += 1
|
||||
return { updatedAt: payload.updatedAt + 1 }
|
||||
},
|
||||
onSaved: (response, { prime }) => {
|
||||
updatedAt = response.updatedAt
|
||||
prime({ value, updatedAt })
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribe = controller.status.subscribe((status) => {
|
||||
statuses.push(status)
|
||||
})
|
||||
|
||||
controller.prime({ value, updatedAt })
|
||||
|
||||
value = 1
|
||||
controller.schedule()
|
||||
|
||||
await wait(15)
|
||||
assert.equal(saveCalls, 1)
|
||||
assert.ok(statuses.includes('saving'))
|
||||
assert.ok(statuses.includes('saved'))
|
||||
|
||||
await wait(20)
|
||||
assert.equal(statuses.at(-1), 'idle')
|
||||
|
||||
unsubscribe()
|
||||
controller.destroy()
|
||||
})
|
||||
|
||||
it('cancels pending work on destroy', async () => {
|
||||
let saveCalls = 0
|
||||
const controller = createAutoSaveController<{ foo: string }>({
|
||||
debounceMs: 20,
|
||||
getPayload: () => ({ foo: 'bar' }),
|
||||
save: async () => {
|
||||
saveCalls += 1
|
||||
}
|
||||
})
|
||||
|
||||
controller.schedule()
|
||||
controller.destroy()
|
||||
|
||||
await wait(30)
|
||||
assert.equal(saveCalls, 0)
|
||||
})
|
||||
})
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
Loading…
Reference in a new issue