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
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] `EditAppPage` merges wizard output with original app config to preserve fields not in wizard (e.g. `active`)
- [x] `client.meta.saveItem('app', name, schema)` — persists app metadata to backend on create/edit
- [x] MSW PUT handler for `/meta/:type/:name` — dev/mock mode metadata persistence
- [x] MSW handler refactored to use `MSWPlugin` + protocol broker shim — filter/sort/top/pagination now work correctly in dev/mock mode (Issue #858)
- [x] Draft persistence to localStorage with auto-clear on success
- [x] `createApp` i18n key added to all 10 locales
- [x] 13 console integration tests (routes, wizard callbacks, draft persistence, saveItem, CommandPalette)
Expand Down
59 changes: 57 additions & 2 deletions apps/console/src/__tests__/MSWServer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* Simple MSW Integration Test
* MSW Integration Tests
*
* Minimal test to verify MSW server is working
* Verifies the MSW server (powered by MSWPlugin) correctly handles:
* - Basic CRUD operations via the ObjectStack protocol
* - Query parameters: filter, sort, top, skip
*
* MSWPlugin routes all requests through the ObjectStack protocol stack,
* ensuring behaviour is identical to server mode (HonoServerPlugin).
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -40,4 +45,54 @@ describe('MSW Server Integration', () => {
expect(newContact.name).toBe('Test User');
expect(newContact.email).toBe('test@example.com');
});

// ── Protocol-level query tests (filter / sort / top) ───────────────────
// These tests hit the MSW HTTP layer via fetch, which MSWPlugin routes
// through HttpDispatcher → ObjectStack protocol. If the protocol handles
// filter/sort/top correctly, these will pass.

it('should support top (limit) via HTTP', async () => {
const res = await fetch('http://localhost/api/v1/data/contact?top=3');
expect(res.ok).toBe(true);
const body = await res.json();

// HttpDispatcher wraps in { success, data: { value: [...] } }
const data = body.data ?? body;
const records = data.value ?? data.records ?? data;
expect(Array.isArray(records)).toBe(true);
expect(records.length).toBeLessThanOrEqual(3);
});

it('should support sort via HTTP', async () => {
const res = await fetch('http://localhost/api/v1/data/contact?sort=name');
expect(res.ok).toBe(true);
const body = await res.json();

const data = body.data ?? body;
const records = data.value ?? data.records ?? data;
expect(Array.isArray(records)).toBe(true);
expect(records.length).toBeGreaterThan(0);

// Verify records are sorted by name ascending
const names = records.map((r: any) => r.name);
const sorted = [...names].sort((a: string, b: string) => a.localeCompare(b));
expect(names).toEqual(sorted);
});

it('should support filter via HTTP', async () => {
// Filter contacts where priority = "high" using tuple format
const filter = JSON.stringify(['priority', '=', 'high']);
const res = await fetch(`http://localhost/api/v1/data/contact?filter=${encodeURIComponent(filter)}`);
expect(res.ok).toBe(true);
const body = await res.json();

const data = body.data ?? body;
const records = data.value ?? data.records ?? data;
expect(Array.isArray(records)).toBe(true);
expect(records.length).toBeGreaterThan(0);
// All returned records should have priority 'high'
for (const record of records) {
expect(record.priority).toBe('high');
}
});
Comment on lines +82 to +97
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates that records have priority='high' but doesn't verify that records with other priorities (e.g., 'medium', 'low') are excluded. Consider adding an assertion that verifies the total count matches expected filtered results, or seed known data with multiple priorities and verify only 'high' priority records are returned.

For example, you could check that the returned count is less than the total count of contacts, or verify specific known 'high' priority contacts are present while known 'medium' ones are absent.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for the skip query parameter, which is mentioned as one of the key features in the PR description and test file comments (line 6 mentions "skip" alongside filter/sort/top). Consider adding a test case that validates pagination using the skip parameter works correctly through the protocol stack.

Suggested change
});
});
it('should support skip (offset) via HTTP', async () => {
// Use sort=name to ensure a deterministic ordering, then apply skip=2
const baseRes = await fetch('http://localhost/api/v1/data/contact?sort=name');
expect(baseRes.ok).toBe(true);
const baseBody = await baseRes.json();
const baseData = baseBody.data ?? baseBody;
const baseRecords = baseData.value ?? baseData.records ?? baseData;
expect(Array.isArray(baseRecords)).toBe(true);
expect(baseRecords.length).toBeGreaterThan(2);
const skipRes = await fetch('http://localhost/api/v1/data/contact?sort=name&skip=2');
expect(skipRes.ok).toBe(true);
const skipBody = await skipRes.json();
const skipData = skipBody.data ?? skipBody;
const skipRecords = skipData.value ?? skipData.records ?? skipData;
expect(Array.isArray(skipRecords)).toBe(true);
expect(skipRecords.length).toBeGreaterThan(0);
// After skipping 2 records, the first record in the skip result
// should match the third record from the base (index 2)
expect(skipRecords[0].id).toBe(baseRecords[2].id);
});

Copilot uses AI. Check for mistakes.
});
48 changes: 20 additions & 28 deletions apps/console/src/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
/**
* MSW Browser Worker Setup via ObjectStack Runtime
*
* Uses the shared createKernel() factory to bootstrap the ObjectStack kernel,
* then creates MSW handlers via the shared handler factory.
* Uses the shared createKernel() factory to bootstrap the ObjectStack kernel
* with MSWPlugin, which automatically exposes all ObjectStack API endpoints
* via MSW. This ensures filter/sort/top/pagination work identically to
* server mode.
*
* This pattern follows @objectstack/studio — see https://github.com/objectstack-ai/spec
*/

import { ObjectKernel } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { setupWorker } from 'msw/browser';
import appConfig, { sharedConfig } from '../../objectstack.shared';
import type { MSWPlugin } from '@objectstack/plugin-msw';
import appConfig from '../../objectstack.shared';
import { createKernel } from './createKernel';
import { createHandlers } from './handlers';

let kernel: ObjectKernel | null = null;
let driver: InMemoryDriver | null = null;
let worker: ReturnType<typeof setupWorker> | null = null;
let mswPlugin: MSWPlugin | null = null;

export async function startMockServer() {
// Polyfill process.on for ObjectKernel in browser environment
Expand All @@ -35,36 +36,27 @@ export async function startMockServer() {

if (import.meta.env.DEV) console.log('[MSW] Starting ObjectStack Runtime (Browser Mode)...');

const result = await createKernel({ appConfig });
kernel = result.kernel;
driver = result.driver;

// Create MSW handlers that match the response format of HonoServerPlugin
// Include both /api/v1 and legacy /api paths so the ObjectStackClient can
// reach the mock server regardless of which base URL it probes.
// Pass sharedConfig (pre-defineStack) so handlers can enrich object metadata
// with listViews that defineStack's Zod parse strips.
const v1Handlers = createHandlers('/api/v1', kernel, driver, sharedConfig);
const legacyHandlers = createHandlers('/api', kernel, driver, sharedConfig);
const handlers = [...v1Handlers, ...legacyHandlers];

// Start MSW service worker
worker = setupWorker(...handlers);
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: `${import.meta.env.BASE_URL}mockServiceWorker.js`,
const result = await createKernel({
appConfig,
mswOptions: {
enableBrowser: true,
baseUrl: '/api/v1',
logRequests: import.meta.env.DEV,
},
});
kernel = result.kernel;
driver = result.driver;
mswPlugin = result.mswPlugin ?? null;

if (import.meta.env.DEV) console.log('[MSW] ObjectStack Runtime ready');
return kernel;
}

export function stopMockServer() {
if (worker) {
worker.stop();
worker = null;
if (mswPlugin) {
const worker = mswPlugin.getWorker();
if (worker) worker.stop();
mswPlugin = null;
}
kernel = null;
driver = null;
Expand Down
91 changes: 89 additions & 2 deletions apps/console/src/mocks/createKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,94 @@
* Creates a fully bootstrapped ObjectStack kernel for use in both
* browser (MSW setupWorker) and test (MSW setupServer) environments.
*
* Uses MSWPlugin from @objectstack/plugin-msw to expose the full
* ObjectStack protocol via MSW. This ensures filter/sort/top/pagination
* work identically to server mode.
*
* Follows the same pattern as @objectstack/studio's createKernel —
* see https://github.com/objectstack-ai/spec
*/

import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { MSWPlugin } from '@objectstack/plugin-msw';
import type { MSWPluginOptions } from '@objectstack/plugin-msw';

export interface KernelOptions {
/** Application configuration (defineStack output) */
appConfig: any;
/** Whether to skip system validation (useful in browser) */
skipSystemValidation?: boolean;
/** MSWPlugin options; when provided, MSWPlugin is added to the kernel. */
mswOptions?: MSWPluginOptions;
}

export interface KernelResult {
kernel: ObjectKernel;
driver: InMemoryDriver;
/** The MSWPlugin instance (if mswOptions was provided). */
mswPlugin?: MSWPlugin;
}

/**
* Install a lightweight broker shim on the kernel so that
* HttpDispatcher (used by MSWPlugin) can route data/metadata
* calls through the ObjectStack protocol service.
*
* A full Moleculer-based broker is only available in server mode
* (HonoServerPlugin). In MSW/browser mode we bridge the gap with
* this thin adapter that delegates to the protocol service.
*/
async function installBrokerShim(kernel: ObjectKernel): Promise<void> {
let protocol: any;
try {
protocol = await kernel.getService('protocol');
} catch {
return;
}
if (!protocol) return;

(kernel as any).broker = {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type safety issue: Casting kernel to any to attach the broker property bypasses TypeScript's type checking. This could hide errors if the ObjectKernel interface changes or if MSWPlugin expects specific broker interface methods beyond just call().

Consider creating a proper interface for the broker shim and using type assertion to a union type (e.g., kernel as ObjectKernel & { broker: BrokerShim }) rather than casting to any.

Copilot uses AI. Check for mistakes.
async call(action: string, params: any = {}) {
const [service, method] = action.split('.');

if (service === 'data') {
switch (method) {
case 'query':
return protocol.findData({ object: params.object, query: params.query ?? params });
case 'get':
return protocol.getData({ object: params.object, id: params.id });
case 'create':
return protocol.createData({ object: params.object, data: params.data });
case 'update':
return protocol.updateData({ object: params.object, id: params.id, data: params.data });
case 'delete':
return protocol.deleteData({ object: params.object, id: params.id });
case 'batch':
return protocol.batchData?.({ object: params.object, ...params }) ?? { results: [] };
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing default case in data service switch: The switch statement for data methods (query, get, create, update, delete, batch) doesn't have a default case to handle unrecognized methods. If an unrecognized data method is called, execution falls through to the metadata service checks, which could return incorrect results. Add a default case in the switch statement that throws an error for unhandled data methods.

Suggested change
return protocol.batchData?.({ object: params.object, ...params }) ?? { results: [] };
return protocol.batchData?.({ object: params.object, ...params }) ?? { results: [] };
default:
throw new Error(`[BrokerShim] Unhandled data method: ${method}`);

Copilot uses AI. Check for mistakes.
}
}

if (service === 'metadata') {
if (method === 'types') return protocol.getMetaTypes({});
if (method === 'getObject') {
return protocol.getMetaItem({ type: 'object', name: params.objectName });
}
if (method === 'saveItem') {
return protocol.saveMetaItem?.({ type: params.type, name: params.name, item: params.item });
}
if (method.startsWith('get')) {
const type = method.replace('get', '').toLowerCase();
return protocol.getMetaItem({ type, name: params.name });
}
// list-style calls: metadata.objects, metadata.apps, etc.
return protocol.getMetaItems({ type: method, packageId: params.packageId });
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential bug in metadata routing: The fallback at line 89 assumes any unhandled method in the 'metadata' service should be treated as a list-style call (metadata.objects, metadata.apps, etc.). However, this could incorrectly handle typos or invalid method names, returning misleading results instead of throwing an error.

Consider adding explicit handling for known list methods (objects, apps, dashboards, pages, reports) and throwing an error for unrecognized methods, similar to the throw statement at line 92 for the data service.

Suggested change
return protocol.getMetaItems({ type: method, packageId: params.packageId });
switch (method) {
case 'objects':
case 'apps':
case 'dashboards':
case 'pages':
case 'reports':
return protocol.getMetaItems({ type: method, packageId: params.packageId });
default:
throw new Error(`[BrokerShim] Unhandled metadata method: ${method} in action: ${action}`);
}

Copilot uses AI. Check for mistakes.
}

throw new Error(`[BrokerShim] Unhandled action: ${action}`);
},
Comment on lines +56 to +93
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling: If protocol methods throw errors (e.g., findData, getData, createData), they will propagate uncaught. While this might be intentional to let MSWPlugin handle errors, the broker shim should at least ensure errors are properly wrapped or logged for debugging. Consider adding try-catch blocks around protocol method calls and wrapping errors with additional context (e.g., which action and params caused the error).

Copilot uses AI. Check for mistakes.
};
}

/**
Expand All @@ -31,7 +101,7 @@ export interface KernelResult {
* so that kernel setup logic is not duplicated.
*/
export async function createKernel(options: KernelOptions): Promise<KernelResult> {
const { appConfig, skipSystemValidation = true } = options;
const { appConfig, skipSystemValidation = true, mswOptions } = options;

const driver = new InMemoryDriver();

Expand All @@ -43,7 +113,24 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
await kernel.use(new DriverPlugin(driver, 'memory'));
await kernel.use(new AppPlugin(appConfig));

let mswPlugin: MSWPlugin | undefined;
if (mswOptions) {
// Install a protocol-based broker shim BEFORE MSWPlugin's start phase
// so that HttpDispatcher (inside MSWPlugin) can resolve data/metadata
// calls without requiring a full Moleculer broker.
await installBrokerShim(kernel);

mswPlugin = new MSWPlugin(mswOptions);
await kernel.use(mswPlugin);
}

await kernel.bootstrap();

return { kernel, driver };
// Re-install broker shim after bootstrap to ensure the protocol service
// is fully initialised (some plugins register services during start phase).
if (mswOptions) {
await installBrokerShim(kernel);
}
Comment on lines +129 to +133
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broker shim is installed twice: before MSWPlugin is added (line 121) and after bootstrap (lines 131-132). While the comment explains this is to ensure the protocol service is fully initialized, this pattern is fragile and could mask initialization order issues. Consider one of these alternatives:

  1. Only install the shim after bootstrap (remove line 121), which should be sufficient since MSWPlugin's handlers are only registered during bootstrap.
  2. Add a check in installBrokerShim to avoid reinstalling if the broker already exists and is functional.
  3. Document why both installations are necessary with a more detailed explanation of what changes between the two calls.

Copilot uses AI. Check for mistakes.

return { kernel, driver, mswPlugin };
}
Loading