forked from GitHubMirrors/silverbullet-icalendar
feat(test): implement Playwright E2E and Dockerized testing infrastructure
Some checks failed
Build SilverBullet Plug / build (push) Has been cancelled
Some checks failed
Build SilverBullet Plug / build (push) Has been cancelled
This commit is contained in:
@@ -1,77 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
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[] = [];
|
||||
|
||||
// Listen for console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error' || msg.text().includes('TypeError')) {
|
||||
errors.push(msg.text());
|
||||
console.error('Browser Console Error:', msg.text());
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error') errors.push(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.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for the editor to load
|
||||
await expect(page.locator('#sb-main')).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log('Page reached, waiting for boot sequence...');
|
||||
|
||||
// 2. Install Plug (Mocking the installation by writing to PLUGS.md or using command)
|
||||
// For this test, we assume the built plug is served or we use the local raw link.
|
||||
// We'll use the 'Plugs: Add' command if possible, or just write to PLUGS.md.
|
||||
// 2. Persistent Monitoring for Sync Activity
|
||||
let syncDetected = false;
|
||||
let eventsSynced = 0;
|
||||
const timeoutMs = 120000; // 2 minutes
|
||||
const startTime = Date.now();
|
||||
|
||||
// Let's use the keyboard to trigger the command palette
|
||||
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
|
||||
console.log(`Starting monitoring loop for ${timeoutMs/1000}s...`);
|
||||
|
||||
// Navigate to PLUGS
|
||||
await page.goto('/PLUGS');
|
||||
await page.waitForSelector('.cm-content');
|
||||
|
||||
// Clear and write the local plug URI
|
||||
// In our docker-compose, the host files are at /work
|
||||
// But SB needs a URI. We'll use the gitea link or a local mock server link.
|
||||
// For now, let's assume we want to test the built file in the test space.
|
||||
|
||||
const plugUri = 'gh:sstent/silverbullet-icalendar/icalendar.plug.js'; // Fallback or use local
|
||||
await page.locator('.cm-content').fill(`- ${plugUri}`);
|
||||
await page.keyboard.press('Control+s'); // Save
|
||||
|
||||
// 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);
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
// Check for notifications
|
||||
const notification = page.locator('.sb-notification:has-text("Synced")');
|
||||
if (await notification.count() > 0) {
|
||||
const text = await notification.innerText();
|
||||
console.log('Detected Sync Notification:', text);
|
||||
const match = text.match(/Synced (\d+) events/);
|
||||
if (match) {
|
||||
eventsSynced = parseInt(match[1], 10);
|
||||
if (eventsSynced > 0) {
|
||||
syncDetected = true;
|
||||
console.log(`SUCCESS: ${eventsSynced} events synced!`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Configure source in SETTINGS
|
||||
await page.goto('/SETTINGS');
|
||||
await page.waitForSelector('.cm-content');
|
||||
await page.locator('.cm-content').fill(`
|
||||
icalendar:
|
||||
sources:
|
||||
- url: http://mock-ics-server/calendar.ics
|
||||
name: TestCalendar
|
||||
`);
|
||||
await page.keyboard.press('Control+s');
|
||||
await page.waitForTimeout(2000);
|
||||
// Every 30 seconds, try to "poke" it with a keyboard shortcut if not started
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 30000 && elapsed < 35000 && !syncDetected) {
|
||||
console.log('Auto-sync not detected yet, trying manual trigger shortcut...');
|
||||
await page.keyboard.press('.');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.type('iCalendar: Sync');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
// 4. Trigger Sync
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await page.fill('input[placeholder="Command"]', 'iCalendar: Sync');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Wait for sync to complete (flash notification)
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 5. Final check
|
||||
expect(errors).toHaveLength(0);
|
||||
// 3. Final verification
|
||||
console.log('Final accumulated [iCalendar] logs:', logs);
|
||||
|
||||
// Check if the query rendered meetings in the UI
|
||||
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.');
|
||||
});
|
||||
});
|
||||
|
||||
155
tests/reach_variations_test.ts
Normal file
155
tests/reach_variations_test.ts
Normal 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("");
|
||||
}
|
||||
Reference in New Issue
Block a user