feat(test): implement Playwright E2E and Dockerized testing infrastructure
Some checks failed
Build SilverBullet Plug / build (push) Has been cancelled

This commit is contained in:
2026-02-21 13:13:06 -08:00
parent 29ce643ac1
commit 90ab92421a
17 changed files with 5202 additions and 99 deletions

View File

@@ -29,5 +29,5 @@ This file tracks all major tracks for the project. Each track has its own detail
--- ---
- [ ] **Track: Implement Playwright and Dockerized testing infrastructure with real ICS data samples.** - [~] **Track: Implement Playwright and Dockerized testing infrastructure with real ICS data samples.**
*Link: [./tracks/testing_infrastructure_20260219/](./tracks/testing_infrastructure_20260219/)* *Link: [./tracks/testing_infrastructure_20260219/](./tracks/testing_infrastructure_20260219/)*

View File

@@ -7,6 +7,7 @@
- [x] Task: Integration Test Scaffolding 2bfd9d5 - [x] Task: Integration Test Scaffolding 2bfd9d5
- [x] Create tests/integration_test.ts that uses the actual ts-ics parser on files in test_data/. - [x] Create tests/integration_test.ts that uses the actual ts-ics parser on files in test_data/.
- [x] Task: Conductor - User Manual Verification 'Environment & Mock Server' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Environment & Mock Server' (Protocol in workflow.md)
- [x] Ensure use of `docker-compose -f docker-compose.test.yml down -v` for clean starts.
## Phase 2: Playwright E2E Setup [checkpoint: e80e4fb] ## Phase 2: Playwright E2E Setup [checkpoint: e80e4fb]
- [x] Task: Initialize Playwright 319a955 - [x] Task: Initialize Playwright 319a955
@@ -18,10 +19,11 @@
- [x] Task: Conductor - User Manual Verification 'Playwright E2E Setup' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Playwright E2E Setup' (Protocol in workflow.md)
## Phase 3: Validation & Bug Fix ## Phase 3: Validation & Bug Fix
- [ ] Task: Verify Infrastructure against current bug - [x] Task: Verify Infrastructure against current bug 2bfd9d5
- [ ] Add the problematic .ics to test_data/. - [x] Add the problematic .ics to test_data/.
- [ ] Confirm that E2E and Integration tests fail with Unknown RRULE property 'WORKWEEKSTART'. - [x] Confirm that E2E and Integration tests fail with Unknown RRULE property 'WORKWEEKSTART'.
- [ ] Task: Implement Fix for WORKWEEKSTART - [x] Task: Implement Fix for WORKWEEKSTART a8755eb
- [ ] Update RRULE_KEY_MAP in icalendar.ts. - [x] Update RRULE_KEY_MAP in icalendar.ts.
- [ ] Run tests again to confirm they pass. - [x] Run tests again to confirm they pass.
- [ ] Task: Conductor - User Manual Verification 'Validation & Bug Fix' (Protocol in workflow.md) - [~] Task: Conductor - User Manual Verification 'Validation & Bug Fix' (Protocol in workflow.md)
- [ ] Update Playwright to run in headed mode via xvfb-run per user request.

View File

@@ -1,12 +1,11 @@
services: services:
silverbullet-test: silverbullet-test:
image: zefhemel/silverbullet:latest image: zefhemel/silverbullet:2.4.0
ports: ports:
- "3001:3000" - "3001:3000"
volumes: volumes:
- sb-test-space:/space - ./test_space_e2e:/space
environment: environment:
- SB_USER=admin:admin
- SB_LOG_PUSH=true - SB_LOG_PUSH=true
- SB_DEBUG=true - SB_DEBUG=true
- SB_SPACE_LUA_TRUSTED=true - SB_SPACE_LUA_TRUSTED=true
@@ -17,18 +16,21 @@ services:
- "8081:80" - "8081:80"
volumes: volumes:
- ./test_data:/usr/share/nginx/html:ro - ./test_data:/usr/share/nginx/html:ro
- ./test_data/nginx.conf:/etc/nginx/nginx.conf:ro
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.49.0-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
network_mode: "service:silverbullet-test"
volumes: volumes:
- .:/work - .:/work
- /work/node_modules
- /tmp/.X11-unix:/tmp/.X11-unix
working_dir: /work working_dir: /work
environment: environment:
- CI=true - CI=true
depends_on: - DISPLAY=${DISPLAY:-:0}
- silverbullet-test - SB_URL=http://localhost:3000
- mock-ics-server command: sh -c "npm install && npx playwright test"
command: npx playwright test
volumes: volumes:
sb-test-space: sb-test-space:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -196,7 +196,7 @@ export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
/** /**
* Expands recurring events into individual occurrences. * Expands recurring events into individual occurrences.
*/ */
export function expandRecurrences(icsEvent: any, windowDays = 365): any[] { export function expandRecurrences(icsEvent: any, windowDays = 365, now = new Date()): any[] {
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule; const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
if (!rruleStr) return [icsEvent]; if (!rruleStr) return [icsEvent];
@@ -238,17 +238,13 @@ export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
set.exdate(new Date(exdate.includes("Z") ? exdate : exdate + "Z")); set.exdate(new Date(exdate.includes("Z") ? exdate : exdate + "Z"));
} }
const now = new Date();
// Start our visible window 7 days ago to catch recent past events
const filterStart = new Date(now.getTime() - 7 * 86400000);
const windowEnd = new Date(now.getTime() + windowDays * 86400000); const windowEnd = new Date(now.getTime() + windowDays * 86400000);
// Expand from the event's actual start date to ensure all recurrences are calculated correctly // Expand from the event's actual start date up to the window end.
// but only take occurrences between (now - 7 days) and (now + windowDays) // This provides "no limit" lookback (bound only by the event's own start date).
const occurrences = set.between(dtstart, windowEnd, true); const occurrences = set.between(dtstart, windowEnd, true);
return occurrences const mapped = occurrences
.filter(occurrenceDate => occurrenceDate >= filterStart)
.map(occurrenceDate => { .map(occurrenceDate => {
const localIso = localDateString(occurrenceDate); const localIso = localDateString(occurrenceDate);
return { return {
@@ -258,6 +254,7 @@ export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
rrule: undefined, rrule: undefined,
}; };
}); });
return mapped;
} catch (err) { } catch (err) {
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err); console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
return [icsEvent]; return [icsEvent];
@@ -265,7 +262,6 @@ export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
} }
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> { async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
console.log(`[iCalendar] Fetching from: ${source.url}`);
try { try {
const response = await fetch(source.url); const response = await fetch(source.url);
if (!response.ok) { if (!response.ok) {
@@ -286,29 +282,31 @@ async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any
if (!finalDate) continue; if (!finalDate) continue;
const localIso = localDateString(finalDate); const localIso = localDateString(finalDate);
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
const baseEvent = { const baseEvent = {
...icsEvent, ...icsEvent,
name: icsEvent.summary || "Untitled Event", name: icsEvent.summary || "Untitled Event",
start: localIso, start: localIso,
tag: "ical-event", tag: "ical-event",
sourceName: source.name sourceName: source.name,
timezone: rawTz
}; };
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
} }
const expanded = expandRecurrences(baseEvent, windowDays); const expanded = expandRecurrences(baseEvent, windowDays);
for (const occurrence of expanded) { for (const occurrence of expanded) {
const uniqueKey = `${occurrence.start}${occurrence.uid || occurrence.summary || ''}`; // Use summary in key to avoid collisions for meetings sharing UID/Start
const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`;
occurrence.ref = await sha256Hash(uniqueKey); occurrence.ref = await sha256Hash(uniqueKey);
events.push(convertDatesToStrings(occurrence)); events.push(convertDatesToStrings(occurrence));
} }
} }
return events; return events;
} catch (err) { } catch (err: any) {
console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err); console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err.message || err, err.stack || "");
return []; return [];
} }
} }

18
package-lock.json generated
View File

@@ -1,13 +1,29 @@
{ {
"name": "silverbullet-icalendar", "name": "work",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@playwright/test": "^1.58.2",
"playwright": "^1.58.2" "playwright": "^1.58.2"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",

View File

@@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"playwright": "^1.58.2" "playwright": "^1.58.2",
"@playwright/test": "^1.58.2"
} }
} }

View File

@@ -2,20 +2,29 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: './tests/e2e',
timeout: 180000,
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: 'html', reporter: 'html',
use: { use: {
baseURL: 'http://silverbullet-test:3000', baseURL: process.env.SB_URL || 'http://localhost:3000',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
headless: false,
}, },
projects: [ projects: [
{ {
name: 'chromium', name: 'firefox',
use: { ...devices['Desktop Chrome'] }, use: {
...devices['Desktop Firefox'],
launchOptions: {
firefoxUserPrefs: {
'dom.securecontext.whitelist': 'http://localhost:3000,http://silverbullet-test:3000,http://mock-ics-server',
},
},
},
}, },
], ],
}); });

80
test_data/calendar.ics Normal file
View File

@@ -0,0 +1,80 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Repro//EN
BEGIN:VEVENT
UID:repro-workweekstart
SUMMARY:Repro WorkWeekStart
DTSTART:20260219T100000Z
DTEND:20260219T110000Z
RRULE:FREQ=WEEKLY;BYDAY=MO;WKST=MO
END:VEVENT
BEGIN:VTIMEZONE
TZID:GMT Standard Time
BEGIN:STANDARD
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T010000
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VTIMEZONE
TZID:Pacific Standard Time
BEGIN:STANDARD
DTSTART:16010101T020000
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
SUMMARY:Discuss Alletra MP terraform provider requirements
DTSTART;TZID=GMT Standard Time:20260116T153000
DTEND;TZID=GMT Standard Time:20260116T160000
TRANSP:OPAQUE
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
END:VEVENT
BEGIN:VEVENT
RRULE:FREQ=WEEKLY;UNTIL=20260814T170000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;WKST=SU
SUMMARY:BUSY Weekly
DTSTART;TZID=Pacific Standard Time:20260116T130000
DTEND;TZID=Pacific Standard Time:20260116T133000
TRANSP:OPAQUE
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
END:VEVENT
BEGIN:VEVENT
RRULE:FREQ=WEEKLY;UNTIL=20260324T143000Z;INTERVAL=1;BYDAY=TU;WKST=SU
EXDATE;TZID=Pacific Standard Time:20260203T083000
SUMMARY:HPE-Veeam check-in (weekly)
DTSTART;TZID=Pacific Standard Time:20260120T083000
DTEND;TZID=Pacific Standard Time:20260120T093000
TRANSP:OPAQUE
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
END:VEVENT
BEGIN:VEVENT
SUMMARY:Following: Neutron Star Program Meeting
DTSTART;TZID=Pacific Standard Time:20260120T083000
DTEND;TZID=Pacific Standard Time:20260120T093000
TRANSP:TRANSPARENT
X-MICROSOFT-CDO-BUSYSTATUS:FREE
END:VEVENT
BEGIN:VEVENT
RRULE:FREQ=MONTHLY;UNTIL=20260731T170000Z;INTERVAL=1;BYDAY=-1FR
SUMMARY:PC&FS prioritization & roadmap planning session - monthly
DTSTART;TZID=Pacific Standard Time:20260130T100000
DTEND;TZID=Pacific Standard Time:20260130T130000
TRANSP:OPAQUE
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

18
test_data/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
events {}
http {
server {
listen 80;
location / {
root /usr/share/nginx/html;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
}
}

4772
test_data/reachcalendar.ics Executable file

File diff suppressed because it is too large Load Diff

14
test_space_e2e/CONFIG.md Normal file
View File

@@ -0,0 +1,14 @@
# Configuration
```space-lua
config.set("icalendar", {
sources = {
{
url = "http://172.22.0.3/reachcalendar.ics",
name = "TestCalendar"
}
}
})
```

File diff suppressed because one or more lines are too long

23
test_space_e2e/index.md Normal file
View File

@@ -0,0 +1,23 @@
.iCalendar: Sync
# Meetings for Jan 20th, 2026.iCalendar: Sync
.
.iCalendar: Sync
.iCalendar: Sync
${template.each(query[[
from e = index.tag "ical-event"
where e.start:startsWith("2026-01-20")
order by e.start
]], function(e)
return string.format("* %s to %s: %s (TZ: %s)\n",
e.start:sub(12, 16),
e["end"]:sub(12, 16),
e.summary,
e.timezone or "UTC")
end)}
# Welcome 👋
Welcome to the wondrous world of SilverBullet.

View File

@@ -1,77 +1,80 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('iCalendar Sync E2E', () => { test.describe('iCalendar Sync E2E', () => {
test('should install plug and sync without console errors', async ({ page }) => { test('should verify iCalendar sync activity', async ({ page }) => {
const logs: string[] = [];
const errors: string[] = []; const errors: string[] = [];
// Listen for console errors
page.on('console', msg => { page.on('console', msg => {
if (msg.type() === 'error' || msg.text().includes('TypeError')) { const text = msg.text();
errors.push(msg.text()); if (msg.type() === 'error') errors.push(text);
console.error('Browser Console Error:', msg.text()); if (text.includes('[iCalendar]')) {
logs.push(text);
console.log('Detected SB Log:', text);
} }
}); });
// 1. Login // 1. Load Editor
console.log('Navigating to /');
await page.goto('/'); await page.goto('/');
await page.fill('input[name="username"]', 'admin'); await page.waitForLoadState('networkidle');
await page.fill('input[name="password"]', 'admin'); console.log('Page reached, waiting for boot sequence...');
await page.click('button[type="submit"]');
// Wait for the editor to load
await expect(page.locator('#sb-main')).toBeVisible({ timeout: 10000 });
// 2. Install Plug (Mocking the installation by writing to PLUGS.md or using command) // 2. Persistent Monitoring for Sync Activity
// For this test, we assume the built plug is served or we use the local raw link. let syncDetected = false;
// We'll use the 'Plugs: Add' command if possible, or just write to PLUGS.md. let eventsSynced = 0;
const timeoutMs = 120000; // 2 minutes
const startTime = Date.now();
// Let's use the keyboard to trigger the command palette console.log(`Starting monitoring loop for ${timeoutMs/1000}s...`);
await page.keyboard.press('Control+Enter'); // Or whatever the shortcut is. Default is Cmd/Ctrl-Enter or /
// Wait for palette
// Actually, writing to PLUGS.md is more reliable for automation
// Navigate to PLUGS while (Date.now() - startTime < timeoutMs) {
await page.goto('/PLUGS'); // Check for notifications
await page.waitForSelector('.cm-content'); const notification = page.locator('.sb-notification:has-text("Synced")');
if (await notification.count() > 0) {
// Clear and write the local plug URI const text = await notification.innerText();
// In our docker-compose, the host files are at /work console.log('Detected Sync Notification:', text);
// But SB needs a URI. We'll use the gitea link or a local mock server link. const match = text.match(/Synced (\d+) events/);
// For now, let's assume we want to test the built file in the test space. if (match) {
eventsSynced = parseInt(match[1], 10);
const plugUri = 'gh:sstent/silverbullet-icalendar/icalendar.plug.js'; // Fallback or use local if (eventsSynced > 0) {
await page.locator('.cm-content').fill(`- ${plugUri}`); syncDetected = true;
await page.keyboard.press('Control+s'); // Save console.log(`SUCCESS: ${eventsSynced} events synced!`);
break;
// Trigger Plugs: Update }
await page.keyboard.press('Control+Enter'); }
await page.fill('input[placeholder="Command"]', 'Plugs: Update'); }
await page.keyboard.press('Enter');
// Wait for notification or some time
await page.waitForTimeout(5000);
// 3. Configure source in SETTINGS // Every 30 seconds, try to "poke" it with a keyboard shortcut if not started
await page.goto('/SETTINGS'); const elapsed = Date.now() - startTime;
await page.waitForSelector('.cm-content'); if (elapsed > 30000 && elapsed < 35000 && !syncDetected) {
await page.locator('.cm-content').fill(` console.log('Auto-sync not detected yet, trying manual trigger shortcut...');
icalendar: await page.keyboard.press('.');
sources: await page.waitForTimeout(1000);
- url: http://mock-ics-server/calendar.ics await page.keyboard.type('iCalendar: Sync');
name: TestCalendar await page.keyboard.press('Enter');
`); }
await page.keyboard.press('Control+s');
await page.waitForTimeout(2000);
// 4. Trigger Sync await page.waitForTimeout(2000);
await page.keyboard.press('Control+Enter'); }
await page.fill('input[placeholder="Command"]', 'iCalendar: Sync');
await page.keyboard.press('Enter');
// Wait for sync to complete (flash notification) // 3. Final verification
await page.waitForTimeout(5000); console.log('Final accumulated [iCalendar] logs:', logs);
// 5. Final check // Check if the query rendered meetings in the UI
expect(errors).toHaveLength(0); const meetingItems = page.locator('li:has-text("to"):has-text(":")');
const meetingCount = await meetingItems.count();
console.log(`Meetings found in UI: ${meetingCount}`);
// Filter out expected noise
const relevantErrors = errors.filter(e => !e.includes('401') && !e.includes('favicon'));
expect(relevantErrors, `Found unexpected errors: ${relevantErrors[0]}`).toHaveLength(0);
expect(syncDetected, 'iCalendar sync failed or synced 0 events').toBe(true);
expect(eventsSynced).toBeGreaterThan(0);
// Verify query rendering
expect(meetingCount).toBeGreaterThanOrEqual(12);
console.log('Test Passed.');
}); });
}); });

View File

@@ -0,0 +1,155 @@
import { assertEquals, assert } from "jsr:@std/assert";
import { resolveEventStart, expandRecurrences, localDateString } from "../icalendar.ts";
const TEST_NOW = new Date("2026-01-20T12:00:00Z");
Deno.test("Variation: Standard Opaque Meeting (Busy)", async () => {
const icsEvent = {
summary: "Discuss Alletra MP terraform provider requirements",
start: {
date: "2026-01-16T15:30:00.000",
local: {
date: "2026-01-16T15:30:00.000",
timezone: "GMT Standard Time"
}
},
transp: "OPAQUE",
"x-microsoft-cdo-busystatus": "BUSY"
};
const result = await resolveEventStart(icsEvent);
// GMT Standard Time in Jan is UTC+0
assertEquals(result?.toISOString(), "2026-01-16T15:30:00.000Z");
});
Deno.test("Variation: Transparent Meeting (Free)", async () => {
const icsEvent = {
summary: "Following: Neutron Star Program Meeting",
start: {
date: "2026-01-20T08:30:00.000",
local: {
date: "2026-01-20T08:30:00.000",
timezone: "Pacific Standard Time"
}
},
transp: "TRANSPARENT",
"x-microsoft-cdo-busystatus": "FREE"
};
const result = await resolveEventStart(icsEvent);
// PST in Jan is UTC-8
assertEquals(result?.toISOString(), "2026-01-20T16:30:00.000Z");
});
Deno.test("Variation: Recurring Weekly (Multi-day: MO,TU,WE,TH,FR)", () => {
const icsEvent = {
summary: "BUSY Weekly",
start: "2026-01-16T13:00:00",
rrule: "FREQ=WEEKLY;UNTIL=20260814T170000Z;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;WKST=SU"
};
// Use TEST_NOW to ensure the window matches
const results = expandRecurrences(icsEvent, 7, TEST_NOW);
// Should have multiple occurrences per week
assert(results.length > 1);
assert(results.some(r => r.start.includes("2026-01-19"))); // Monday
assert(results.some(r => r.start.includes("2026-01-20"))); // Tuesday
});
Deno.test("Variation: Recurring with EXDATE (Exclusion)", () => {
const icsEvent = {
summary: "HPE-Veeam check-in",
start: "2026-01-20T08:30:00",
rrule: "FREQ=WEEKLY;UNTIL=20260324T143000Z;INTERVAL=1;BYDAY=TU;WKST=SU",
exdate: ["2026-02-03T08:30:00"]
};
const results = expandRecurrences(icsEvent, 60, TEST_NOW);
const dates = results.map(r => r.start);
assert(dates.includes("2026-01-20T08:30:00"));
assert(dates.includes("2026-01-27T08:30:00"));
assert(!dates.includes("2026-02-03T08:30:00"), "EXDATE should be excluded");
assert(dates.includes("2026-02-10T08:30:00"));
});
Deno.test("Variation: Monthly Recurring (Last Friday)", () => {
const icsEvent = {
summary: "Monthly Planning",
start: "2026-01-30T10:00:00", // This is the last Friday of Jan 2026
rrule: "FREQ=MONTHLY;UNTIL=20260731T170000Z;INTERVAL=1;BYDAY=-1FR"
};
const results = expandRecurrences(icsEvent, 100, TEST_NOW);
const dates = results.map(r => r.start);
assert(dates.includes("2026-01-30T10:00:00"));
assert(dates.includes("2026-02-27T10:00:00")); // Last Friday of Feb 2026
assert(dates.includes("2026-03-27T10:00:00")); // Last Friday of Mar 2026
});
Deno.test("Variation: Tentative Meeting", async () => {
const icsEvent = {
summary: "CO SW&P: Morpheus SW Core Team",
start: {
date: "2026-01-19T11:30:00.000",
local: {
date: "2026-01-19T11:30:00.000",
timezone: "Central Standard Time"
}
},
"x-microsoft-cdo-busystatus": "TENTATIVE"
};
const result = await resolveEventStart(icsEvent);
// CST in Jan is UTC-6
assertEquals(result?.toISOString(), "2026-01-19T17:30:00.000Z");
});
Deno.test("Variation: Long Location/URL", () => {
const icsEvent = {
summary: "Omnissa Horizon:HPE VME Weekly Cadence",
location: "https://omnissa.zoom.us/j/84780526943?pwd=fow88EiiZyUKsW26JrJavqiirbb1hv.1&from=addon"
};
assertEquals(icsEvent.location.length > 50, true);
});
Deno.test("Feature: Unlimited lookback window", () => {
const start = new Date(TEST_NOW.getTime() - 500 * 86400000); // 500 days ago
const icsEvent = {
summary: "Event from 500 days ago",
start: localDateString(start),
rrule: "FREQ=DAILY;COUNT=1000"
};
const results = expandRecurrences(icsEvent, 30, TEST_NOW);
// Should include events from 500 days ago because there is now no limit
assert(results.some(r => r.start === localDateString(start)), "Should find occurrence from 500 days ago");
});
Deno.test("Feature: Hash Collision Prevention (Same UID/Start, Different Summary)", async () => {
// This happens in reachcalendar.ics where a "Following:" event shares UID/Time with main event
const event1 = {
start: "2026-01-20T08:30:00",
uid: "collision-uid",
summary: "Main Meeting"
};
const event2 = {
start: "2026-01-20T08:30:00",
uid: "collision-uid",
summary: "Following: Main Meeting"
};
const hash1 = await sha256Hash(`${event1.start}${event1.uid}${event1.summary}`);
const hash2 = await sha256Hash(`${event2.start}${event2.uid}${event2.summary}`);
assert(hash1 !== hash2, "Hashes must be unique even if UID and Start match");
});
// Helper needed for the test above since it's not exported from icalendar.ts
async function sha256Hash(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}