Skip to content
Closed
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
16 changes: 16 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,22 @@ window, clears its deadline on completion, and rejects a host that stays silent.
The packaged adapter additionally tests canonical requests, caller-supplied policy
spoofing, subscription acknowledgements and unsupported operations.

App frame `store.getMany(subjects)` (#1737): `hostStore.test.ts` checks it
answers in order through the same store read as `get` (same state after a
local change), reports an unreadable subject in its place without failing the
rest, and refuses more than 100 subjects or a non-array before reading any.
`viewProtocol.test.ts` checks the generated client makes one request for the
batch, hands back resources like `getResource`'s, asks nothing for an empty
list and refuses 101 without asking.

App frame theme (#1738): `views/PluginView/useCreateThemeVars.test.ts` checks
the frame stylesheet carries `--t-color-success` and `color-scheme` from the
built theme's `darkMode`. `FrameBridge.test.ts` checks the theme message
carries `colorScheme` and keeps it across a ready message and a reload.
`viewProtocol.test.ts` checks `store.getTheme()` reads the scheme from the
applied stylesheet before any message, then from messages (only the parent's,
only `light`/`dark`), and `onThemeChange` fires once per change until stopped.

`apps.spec.ts` runs the first write scenario with both the served SDK and this
checkout's v1 JS asset. The latter explicitly intercepts only `format=client`;
resource creation and signing still use the real local backend. This verifies the
Expand Down
10 changes: 7 additions & 3 deletions browser/data-browser/src/chunks/AppPage/AppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { newContextItem, useAISidebar } from '@components/AI/AISidebarContext';
import type { AIAtomicResourceMessageContext } from '@chunks/AI/types';

import resetCss from '../../reset.css?raw';
import { useCreateThemeVars } from '@views/PluginView/useCreateThemeVars';
import {
useCreateThemeVars,
useFrameColorScheme,
} from '@views/PluginView/useCreateThemeVars';
import { getIntegrationProxy } from '@helpers/integrationProxy';
import {
isPlatformId,
Expand Down Expand Up @@ -113,6 +116,7 @@ function AppFrameSession({
table,
});
const { handle: handleUI, forwardKey } = hostUI;
const colorScheme = useFrameColorScheme();

// Which plugin renders it. Resolved here rather than by each caller: a
// table tab and an app page both need it, and two copies would drift.
Expand Down Expand Up @@ -283,8 +287,8 @@ function AppFrameSession({
}, [store, app, drive, table, src, handleUI, forwardKey]);

useEffect(() => {
bridgeRef.current?.setStyle(`${resetCss}\n${stylesheet}`);
}, [stylesheet, src]);
bridgeRef.current?.setStyle(`${resetCss}\n${stylesheet}`, colorScheme);
}, [stylesheet, colorScheme, src]);

// The app never got as far as running: no token, no entry point, no source.
// Reported as a failure like any other, so a caller waiting on an outcome
Expand Down
131 changes: 129 additions & 2 deletions browser/data-browser/src/chunks/AppPage/hostStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { core } from '@tomic/react';
import type { Store } from '@tomic/react';
import { handleRequest, isHostRequest, isWithinApp } from './hostStore';
import {
handleRequest,
isHostRequest,
isWithinApp,
MAX_GET_MANY,
} from './hostStore';

vi.mock('@tomic/react', async () => {
const actual =
await vi.importActual<typeof import('@tomic/react')>('@tomic/react');

// Signing needs a real key and a real agent; what these tests are about is
// which requests leave and which are refused before they do.
return { ...actual, signRequest: async () => ({}) };
return {
...actual,
signRequest: async () => ({}),
// Reading an importer's stored config is tested in @tomic/lib
// (plugin-destination.test.ts); here only what `data` passes on.
destinationTablesFor: async (
_store: unknown,
_drive: string,
table: string,
) => (table === 'did:ad:transactions' ? DESTINATION_TABLES : undefined),
};
});

const DESTINATION_TABLES = {
statements: { table: 'did:ad:statements', rowClass: 'did:ad:statement' },
closingBalances: { table: 'did:ad:balances', rowClass: 'did:ad:balance' },
};

const APP = 'did:ad:app';
const DRIVE = 'did:ad:drive';

Expand Down Expand Up @@ -254,6 +274,22 @@ describe('writing as the app', () => {
expect(viewing.table).toBe('did:ad:someone-elses-table');
});

it('names the other tables of a multi-class destination by their keys', async () => {
const store = fakeStore();

await expect(
handleRequest(store, APP, DRIVE, req('data'), 'did:ad:transactions'),
).resolves.toEqual({
table: 'did:ad:transactions',
rowClass: undefined,
tables: DESTINATION_TABLES,
});
// A single-table destination, or any other table, answers as before.
await expect(
handleRequest(store, APP, DRIVE, req('data'), 'did:ad:other'),
).resolves.toEqual({ table: 'did:ad:other', rowClass: undefined });
});

it('refuses an operation it does not implement', async () => {
const store = fakeStore();

Expand Down Expand Up @@ -376,3 +412,94 @@ describe('integration-proxy capabilities', () => {
).rejects.toThrow();
});
});

describe('getMany', () => {
/** A store holding `rows` in memory; anything else cannot be read. */
function rowStore(rows: Record<string, Record<string, unknown>>) {
const getResource = vi.fn(async (subject: string) => ({
subject,
title: String(rows[subject]?.name ?? subject),
error: rows[subject] ? undefined : new Error(`Unauthorized: ${subject}`),
getPropVals: () => ({ ...rows[subject] }),
}));

return { store: { getResource } as unknown as Store, getResource, rows };
}

it('reads each subject as get does, in order, in one answer', async () => {
const { store, rows } = rowStore({
'did:ad:a': { name: 'A' },
'did:ad:b': { name: 'B' },
});

const many = (await handleRequest(
store,
APP,
DRIVE,
req('getMany', { subjects: ['did:ad:b', 'did:ad:a'] }),
)) as unknown[];
expect(many).toEqual([
{ subject: 'did:ad:b', title: 'B', props: { name: 'B' }, loading: false },
{ subject: 'did:ad:a', title: 'A', props: { name: 'A' }, loading: false },
]);

// Same store, same state as `get`: a write this page already applied is
// what both see.
rows['did:ad:a'] = { name: 'A, edited' };
const [one] = (await handleRequest(
store,
APP,
DRIVE,
req('getMany', { subjects: ['did:ad:a'] }),
)) as Array<{ props: unknown }>;
const single = (await handleRequest(
store,
APP,
DRIVE,
req('get', { subject: 'did:ad:a' }),
)) as { propVals: unknown };
expect(one.props).toEqual(single.propVals);
expect(one.props).toEqual({ name: 'A, edited' });
expect(sent).toEqual([]);
});

it('reports one it cannot read in its place, without failing the rest', async () => {
const { store } = rowStore({ 'did:ad:a': { name: 'A' } });

expect(
await handleRequest(
store,
APP,
DRIVE,
req('getMany', { subjects: ['did:ad:secret', 'did:ad:a'] }),
),
).toEqual([
{ subject: 'did:ad:secret', error: 'Unauthorized: did:ad:secret' },
expect.objectContaining({ subject: 'did:ad:a' }),
]);
});

it(`refuses more than ${MAX_GET_MANY} before reading any`, async () => {
const { store, getResource } = rowStore({});
const subjects = Array.from(
{ length: MAX_GET_MANY + 1 },
(_, i) => `did:ad:${i}`,
);

await expect(
handleRequest(store, APP, DRIVE, req('getMany', { subjects })),
).rejects.toThrow(`at most ${MAX_GET_MANY}`);
expect(getResource).not.toHaveBeenCalled();
});

it.each([undefined, 'did:ad:a', [42], [''], [{ subject: 'did:ad:a' }]])(
'refuses %j as subjects',
async subjects => {
const { store, getResource } = rowStore({});
await expect(
handleRequest(store, APP, DRIVE, req('getMany', { subjects })),
).rejects.toThrow('array of subjects');
expect(getResource).not.toHaveBeenCalled();
},
);
});
69 changes: 58 additions & 11 deletions browser/data-browser/src/chunks/AppPage/hostStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ViewChanges } from '@helpers/extensions/viewApply';
import { parseViewSearch, runViewSearch } from '@helpers/extensions/viewSearch';
import {
core,
destinationTablesFor,
errorMessageFromResponse,
findSchema,
pluginSchema,
Expand Down Expand Up @@ -42,8 +43,16 @@ export interface HostRequest {
connectionId?: string;
/** The frame's own Ed25519 public key, base64url. */
publicKey?: string;
/** `getMany`: the subjects to read, in order. Checked, since the frame sends it. */
subjects?: unknown;
}

/**
* How many subjects one `getMany` may ask for. Enough for a page of rows in
* one round trip, and a bound on what a frame can make this page load at once.
*/
export const MAX_GET_MANY = 100;

export interface HostReply {
id: number | string;
result?: unknown;
Expand Down Expand Up @@ -123,27 +132,52 @@ export async function handleRequest(
// names what its rows are, and duplicating that on the app would be two
// places to disagree.
const tableResource = await store.getResource(subject);
// A table an importer's Set up created together with others (a
// manifest `destination.tables`): its siblings, by the keys the
// manifest declared, so a view of transactions can find statements.
const tables = await destinationTablesFor(store, drive, subject);

return {
table: subject,
rowClass: tableResource.get(core.properties.classtype) as
| string
| undefined,
...(tables ? { tables } : {}),
};
}

case 'get': {
const resource = await store.getResource(
required(request.subject, 'subject'),
case 'get':
return readForApp(store, required(request.subject, 'subject'));

// Many `get`s in one round trip: each subject is read exactly as `get`
// reads it, through this person's store, so it sees what they see and
// their own writes. One that cannot be read is reported in its place
// rather than failing the rest.
case 'getMany': {
const subjects = request.subjects;

if (
!Array.isArray(subjects) ||
!subjects.every(s => typeof s === 'string' && s !== '')
)
throw new Error('getMany takes an array of subjects');

if (subjects.length > MAX_GET_MANY)
throw new Error(
`getMany reads at most ${MAX_GET_MANY} subjects at a time; ask in batches`,
);

return Promise.all(
(subjects as string[]).map(async subject => {
try {
const { propVals, ...rest } = await readForApp(store, subject);

return { ...rest, props: propVals, loading: false };
} catch (e) {
return { subject, error: (e as Error).message };
}
}),
);

if (resource.error) throw resource.error;

return {
subject: resource.subject,
title: resource.title,
propVals: resource.getPropVals(),
};
}

case 'query':
Expand Down Expand Up @@ -262,6 +296,19 @@ export async function handleRequest(
}
}

/** One resource, as this person's store has it. What `get` answers. */
async function readForApp(store: Store, subject: string) {
const resource = await store.getResource(subject);

if (resource.error) throw resource.error;

return {
subject: resource.subject,
title: resource.title,
propVals: resource.getPropVals(),
};
}

/**
* `store.apply` and `store.undo` for one app frame: written as the app, and
* only under the app, like every other write it makes.
Expand Down
Loading
Loading