Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1084,3 +1084,15 @@ cancelled on teardown. Old plugin-name grants are deliberately not migrated.
commit. It failed with the server signer before `Resource::destroy_as` was used;
installation deletion must use the same selected identity as create/update.
LocalThought: Rust handler tests cover connection binding, request signing, duplicate-page rejection, typed paginated previews, and Calendar UTC date-range validation. Live Calendar OAuth, bounded fetch, review/apply and event table display were verified against proxy v39 (54 records).

Google Calendar one-way projection: `integrations/localthought/calendar.test.ts`
covers all-day/timed start dates, offset boundaries, exclusive end preservation,
feature notes (including WASM-normalized field names), cancellations without
start data, invalid active events, namespace isolation and repeat import/local
field preservation. `browser/e2e/tests/google-calendar-import.spec.mts` uses the
shared HTTP mock integration-proxy with a paginated Google Calendar, tenant
secret entry and OAuth consent. It covers browser WASM fetching, local
schema/proposal/apply, Calendar display, provider updates, OPFS reload and
stable identities while AtomicServer HTTP/WebSockets are unavailable. Missing
rows in a bounded snapshot are retained, not interpreted as deletions.
Live-provider browser OAuth verification remains separate from this fixture test.
47 changes: 43 additions & 4 deletions browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
import type { Config } from '../../../../../integrations/localthought/plugin';
import { localImportVerdict } from './localImportVerdict';
import source from '../../../../../integrations/localthought/plugin.js?raw';
import {
calendarProjection,
calendarFields,
} from '../../../../../integrations/localthought/calendar';

export function ConnectLocalThought({
drive,
Expand Down Expand Up @@ -130,12 +134,13 @@ export function ConnectLocalThought({
setError('');

try {
const fetched = await proxyRequest<FetchedPlatform>(store, 'fetch', {
const response = await proxyRequest<FetchedPlatform>(store, 'fetch', {
drive,
connection: connection.connection,
constants,
...(platform === 'google-calendar' ? { calendarRange } : {}),
});
const fetched = calendarProjection(response);
if (fetched.platform !== platform)
throw new Error('Imported platform did not match this connection');
const schemaStore = localSchemaStore(store);
Expand Down Expand Up @@ -195,13 +200,47 @@ export function ConnectLocalThought({
[dataBrowser.properties.viewColumns]: columns,
},
});
const calendar =
platform === 'google-calendar' && term.shortname === 'event'
? await ensureInstallationResource(store, drive, {
parent: destination.subject,
localId: `${identity}:calendar:${term.shortname}`,
isA: [dataBrowser.classes.view],
propVals: {
[core.properties.name]: tableName,
[dataBrowser.properties.viewKind]: 'calendar',
[dataBrowser.properties.viewGroupBy]:
properties[calendarFields.day],
[dataBrowser.properties.viewColumns]: columns,
},
})
: undefined;
const existingViews = destination.get(
dataBrowser.properties.tableViews,
) as string[] | undefined;
await destination.set(dataBrowser.properties.tableViews, [
view.subject,
...new Set([
...(existingViews ?? []),
view.subject,
...(calendar ? [calendar.subject] : []),
]),
]);
await destination.set(
const currentDefault = destination.get(
dataBrowser.properties.tableDefaultView,
view.subject,
);

if (
!currentDefault ||
(calendar &&
!existingViews?.includes(calendar.subject) &&
currentDefault === view.subject)
) {
await destination.set(
dataBrowser.properties.tableDefaultView,
calendar?.subject ?? view.subject,
);
}

await destination.save();
destinations[term.shortname] = { table: destination.subject, rowClass };
}
Expand Down
169 changes: 169 additions & 0 deletions browser/e2e/tests/google-calendar-import.spec.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { test, expect } from '@playwright/test';
import {
mockProxy,
tenantSecret,
} from '../../../integrations/localthought/mock-proxy.mjs';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:6747';
const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:9883';
test.use({ serviceWorkers: 'block' });

// Reuse the browser transport and HTTP mock from the Devonian integration.
// The tenant secret is a public fixture. No real provider credentials are used.
test('Calendar imports, refreshes and persists through the browser with AtomicServer unavailable', async ({
page,
}) => {
test.setTimeout(180_000);
const proxy = mockProxy({ frontendOrigin: new URL(FRONTEND_URL).origin });
await new Promise<void>(resolve => proxy.listen(0, '127.0.0.1', resolve));
const port = (proxy.address() as { port: number }).port;
const proxyOrigin = `http://127.0.0.1:${port}`;
const configuredProxy =
process.env.VITE_INTEGRATION_PROXY_URL || 'https://localthought.io';
await page.routeWebSocket('**/*', socket => socket.close());
const forbidden: string[] = [];
const providerMethods: string[] = [];
// Forward the configured proxy to this test's isolated HTTP fixture. All
// tenant proof, consent, code rotation, pagination and WASM code remain real.
await page.route('**/*', async route => {
const request = route.request();
const url = new URL(request.url());

if (/^\/(integration-proxy|plugin-run|commit)(\/|$)/.test(url.pathname)) {
forbidden.push(url.pathname);

return route.abort();
}

if (url.origin === configuredProxy) {
if (url.pathname.startsWith('/proxy/'))
providerMethods.push(request.method());
const response = await route.fetch({
url: `${proxyOrigin}${url.pathname}${url.search}`,
maxRedirects: 0,
});

return route.fulfill({ response });
}

if (
url.origin === new URL(SERVER_URL).origin &&
url.origin !== new URL(FRONTEND_URL).origin
)
return route.abort();

return route.continue();
});

try {
await page.goto(`${FRONTEND_URL}/app/dev-drive`);
await page.waitForURL(/app\/show\?subject=/, { timeout: 60000 });

const setup = async () => {
await page
.getByRole('link', { name: 'Integrations', exact: true })
.click();
await page
.locator('[data-integration="google-calendar"]')
.getByRole('button', { name: 'Set up connection' })
.click();
};

await setup();
await page.getByLabel('LocalThought tenant secret').fill(tenantSecret);
await page
.getByRole('button', { name: 'Install and connect', exact: true })
.click();
await page
.getByRole('button', { name: 'Connect test account', exact: true })
.click();
await expect(
page.getByRole('button', { name: 'Fetch and preview', exact: true }),
).toBeVisible();
expect(page.url()).not.toContain('connection_code');
expect(
await page.evaluate(() =>
JSON.stringify({ ...localStorage, ...sessionStorage }),
),
).not.toContain(tenantSecret);

const apply = async (count: number) => {
await page
.getByRole('button', { name: 'Fetch and preview', exact: true })
.click();
await page
.getByRole('button', { name: new RegExp(`^Apply ${count} changes?$`) })
.click();
await page
.getByRole('link', { name: 'Open imported records', exact: true })
.click();
await expect(page.getByTestId('calendar-view')).toBeVisible();
};

await apply(2);
await expect(page.getByTestId('calendar-event')).toHaveCount(2);
await page.getByText('Calendar timed fixture', { exact: true }).click();
await expect(page.getByRole('dialog').last()).toContainText(
'Recurring event',
);
await expect(page.getByRole('dialog').last()).toContainText(
'Attendees and RSVP',
);
await page.keyboard.press('Escape');
const original = await page.evaluate(async () => {
const store = window.store!;
const table = new URL(location.href).searchParams.get('subject')!;
const result = await store.queryLocalDb({
drive: store.getDrive()!,
property: 'https://atomicdata.dev/properties/parent',
value: table,
limit: 100,
});
const subjects = result!.subjects.sort();
const row = await store.getResource(subjects[0]);
await row.set(
'https://atomicdata.dev/properties/description',
'Atomic-only notes',
);
await row.save();
await store.getClientDb()!.flush();

return { subjects, table, annotated: row.subject };
});
await page.reload();
await expect(page.getByTestId('calendar-event')).toHaveCount(2);
proxy.calendar.events[1].summary = 'Calendar refreshed fixture';
await setup();
await apply(1);
await expect(
page.getByText('Calendar refreshed fixture', { exact: true }),
).toBeVisible();
await expect(page.getByTestId('calendar-event')).toHaveCount(2);
const restored = await page.evaluate(async saved => {
const store = window.store!;
const result = await store.queryLocalDb({
drive: store.getDrive()!,
property: 'https://atomicdata.dev/properties/parent',
value: saved.table,
limit: 100,
});

return {
subjects: result!.subjects.sort(),
note: (await store.getResource(saved.annotated)).get(
'https://atomicdata.dev/properties/description',
),
};
}, original);
expect(restored.subjects).toEqual(original.subjects);
expect(restored.note).toBe('Atomic-only notes');
expect(providerMethods).toEqual(['GET', 'GET', 'GET', 'GET']);
expect(
proxy.calendar.requests.filter(r => r.query.pageToken === 'second'),
).toHaveLength(2);
expect(forbidden).toEqual([]);
} finally {
await new Promise<void>((resolve, reject) =>
proxy.close(error => (error ? reject(error) : resolve())),
);
}
});
50 changes: 50 additions & 0 deletions integrations/localthought/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,53 @@ An unbounded fetch successfully traversed multiple pages but exceeded the
5,000-record preview limit; the UI now defaults to the next 30 days. Date
bounds and recurrence expansion are passed to Syncables as collection query
settings. The importer remains a manual snapshot, not a background sync.

## Calendar view

Google event imports now install a Calendar view alongside the source table.
The projected date uses the day in Google's supplied start offset (or the
unchanged all-day date), so mixed all-day/timed events share one DATE column.
The original Start and End objects retain timezones and exclusive end values.
The month view shows each event on its start day; it does not draw duration or
multi-day spans. Additional notes identify recurring events, attendees,
reminders and conferencing when those fields are returned by the catalog.
Recurring instances are expanded by the existing bounded provider fetch.

Use **Fetch and preview** again to refresh, then review and apply changes.
Existing source identities and import baselines prevent duplicates and preserve
Atomic-only fields and local edits. Cancellation records are retained with a
note; absence from a bounded fetch never deletes an Atomic resource. Google
may omit cancelled events from list results, so this is not a deletion feed.
Removed optional provider fields are not cleared by the shared snapshot importer.
This first version deliberately remains manual and one-way. No OAuth write
scope is requested and edits in Atomic do not update Google.

`calendar.test.ts` exercises mixed dates, offset boundaries, exclusive ends,
recurrence/attendee notes, cancellations, malformed starts, cross-calendar
identity and repeated imports with private local fields.

## Browser-only Calendar regression

`browser/e2e/tests/google-calendar-import.spec.mts` starts the shared mock
integration-proxy from #1399 with a synthetic Google Calendar. The test enters
the public fixture tenant secret, completes consent, and exercises real browser
credential rotation, WASM pagination, local schema installation, proposal review,
OPFS application, and Calendar rendering. It refreshes changed provider data
and checks that native identities and Atomic-only notes survive reload.
AtomicServer HTTP and all WebSockets are blocked throughout; only GET requests
are permitted for provider data. The configured LocalThought origin is forwarded
to the isolated HTTP fixture, so no live provider credentials or data are used.

Run with a dev frontend built from this branch and its matching WASM bundle:

```sh
FRONTEND_URL=http://127.0.0.1:6747 SERVER_URL=http://127.0.0.1:19999 \
browser/e2e/node_modules/.bin/playwright test \
--config browser/e2e/playwright.config.ts \
browser/e2e/tests/google-calendar-import.spec.mts --project chromium
```

If the frontend uses `VITE_INTEGRATION_PROXY_URL`, pass the same value to the
test process. The test forwards that origin to its own fixture. Live Google
OAuth on the browser path still depends on the proxy CORS deployment described
above; this fixture test does not claim live-provider verification.
Loading