Skip to content

Commit a463c0f

Browse files
Copilothotlong
andcommitted
feat(cloud): add preview/demo mode protocol for marketplace listings
Add PreviewRequest/PreviewResponse schemas to app-store protocol and preview configuration to MarketplaceListingSchema, allowing customers to browse package content without login/registration. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ac56c39 commit a463c0f

4 files changed

Lines changed: 402 additions & 0 deletions

File tree

packages/spec/src/cloud/app-store.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
InstalledAppSummarySchema,
1515
ListInstalledAppsRequestSchema,
1616
ListInstalledAppsResponseSchema,
17+
PreviewRequestSchema,
18+
PreviewObjectSummarySchema,
19+
PreviewViewSummarySchema,
20+
PreviewResponseSchema,
1721
} from './app-store.zod';
1822

1923
describe('ReviewModerationStatusSchema', () => {
@@ -388,3 +392,180 @@ describe('ListInstalledAppsResponseSchema', () => {
388392
expect(parsed.total).toBe(1);
389393
});
390394
});
395+
396+
// ==========================================
397+
// Preview / Demo Mode Tests
398+
// ==========================================
399+
400+
describe('PreviewRequestSchema', () => {
401+
it('should accept minimal preview request', () => {
402+
const request = { listingId: 'listing-001' };
403+
const parsed = PreviewRequestSchema.parse(request);
404+
expect(parsed.listingId).toBe('listing-001');
405+
expect(parsed.version).toBeUndefined();
406+
expect(parsed.includeContent).toBeUndefined();
407+
});
408+
409+
it('should accept preview request with specific version and content types', () => {
410+
const request = {
411+
listingId: 'listing-001',
412+
version: '2.0.0',
413+
includeContent: ['objects', 'views', 'sample_data'] as const,
414+
};
415+
const parsed = PreviewRequestSchema.parse(request);
416+
expect(parsed.version).toBe('2.0.0');
417+
expect(parsed.includeContent).toHaveLength(3);
418+
expect(parsed.includeContent).toContain('objects');
419+
});
420+
421+
it('should accept all valid content types', () => {
422+
const request = {
423+
listingId: 'listing-001',
424+
includeContent: ['objects', 'views', 'dashboards', 'flows', 'sample_data', 'navigation'] as const,
425+
};
426+
const parsed = PreviewRequestSchema.parse(request);
427+
expect(parsed.includeContent).toHaveLength(6);
428+
});
429+
430+
it('should reject invalid content type', () => {
431+
const request = {
432+
listingId: 'listing-001',
433+
includeContent: ['invalid_type'],
434+
};
435+
expect(() => PreviewRequestSchema.parse(request)).toThrow();
436+
});
437+
});
438+
439+
describe('PreviewObjectSummarySchema', () => {
440+
it('should accept object summary with fields', () => {
441+
const summary = {
442+
name: 'lead',
443+
label: 'Lead',
444+
fieldCount: 3,
445+
fields: [
446+
{ name: 'first_name', label: 'First Name', type: 'text' },
447+
{ name: 'email', label: 'Email', type: 'text' },
448+
{ name: 'status', label: 'Status', type: 'select' },
449+
],
450+
};
451+
const parsed = PreviewObjectSummarySchema.parse(summary);
452+
expect(parsed.name).toBe('lead');
453+
expect(parsed.fieldCount).toBe(3);
454+
expect(parsed.fields).toHaveLength(3);
455+
});
456+
457+
it('should accept object summary without fields', () => {
458+
const summary = {
459+
name: 'account',
460+
label: 'Account',
461+
fieldCount: 15,
462+
};
463+
const parsed = PreviewObjectSummarySchema.parse(summary);
464+
expect(parsed.fields).toBeUndefined();
465+
});
466+
});
467+
468+
describe('PreviewViewSummarySchema', () => {
469+
it('should accept list view summary', () => {
470+
const summary = {
471+
name: 'all_leads',
472+
label: 'All Leads',
473+
type: 'list' as const,
474+
objectName: 'lead',
475+
};
476+
const parsed = PreviewViewSummarySchema.parse(summary);
477+
expect(parsed.type).toBe('list');
478+
});
479+
480+
it('should accept form view summary', () => {
481+
const summary = {
482+
name: 'lead_form',
483+
label: 'Lead Form',
484+
type: 'form' as const,
485+
objectName: 'lead',
486+
};
487+
const parsed = PreviewViewSummarySchema.parse(summary);
488+
expect(parsed.type).toBe('form');
489+
});
490+
491+
it('should reject invalid view type', () => {
492+
expect(() => PreviewViewSummarySchema.parse({
493+
name: 'test',
494+
label: 'Test',
495+
type: 'invalid',
496+
objectName: 'test',
497+
})).toThrow();
498+
});
499+
});
500+
501+
describe('PreviewResponseSchema', () => {
502+
it('should accept minimal preview response', () => {
503+
const response = {
504+
listingId: 'listing-001',
505+
name: 'Acme CRM',
506+
version: '2.0.0',
507+
};
508+
const parsed = PreviewResponseSchema.parse(response);
509+
expect(parsed.listingId).toBe('listing-001');
510+
expect(parsed.objects).toBeUndefined();
511+
expect(parsed.views).toBeUndefined();
512+
});
513+
514+
it('should accept full preview response with all content', () => {
515+
const response = {
516+
listingId: 'listing-001',
517+
name: 'Acme CRM',
518+
version: '2.0.0',
519+
demoUrl: 'https://demo.acme.com/crm',
520+
objects: [
521+
{ name: 'lead', label: 'Lead', fieldCount: 10, fields: [
522+
{ name: 'first_name', label: 'First Name', type: 'text' },
523+
]},
524+
{ name: 'account', label: 'Account', fieldCount: 8 },
525+
],
526+
views: [
527+
{ name: 'all_leads', label: 'All Leads', type: 'list' as const, objectName: 'lead' },
528+
{ name: 'lead_form', label: 'Lead Form', type: 'form' as const, objectName: 'lead' },
529+
],
530+
dashboards: [
531+
{ name: 'sales_overview', label: 'Sales Overview' },
532+
],
533+
flows: [
534+
{ name: 'lead_assignment', label: 'Lead Assignment', type: 'autolaunched' },
535+
],
536+
navigation: [
537+
{ id: 'nav_leads', label: 'Leads', type: 'object' },
538+
{ id: 'nav_dashboard', label: 'Dashboard', type: 'dashboard' },
539+
],
540+
sampleData: {
541+
lead: 50,
542+
account: 20,
543+
},
544+
expiresAt: '2025-06-01T12:00:00Z',
545+
};
546+
const parsed = PreviewResponseSchema.parse(response);
547+
expect(parsed.objects).toHaveLength(2);
548+
expect(parsed.views).toHaveLength(2);
549+
expect(parsed.dashboards).toHaveLength(1);
550+
expect(parsed.flows).toHaveLength(1);
551+
expect(parsed.navigation).toHaveLength(2);
552+
expect(parsed.sampleData?.lead).toBe(50);
553+
expect(parsed.demoUrl).toBe('https://demo.acme.com/crm');
554+
expect(parsed.expiresAt).toBe('2025-06-01T12:00:00Z');
555+
});
556+
557+
it('should accept preview response with only objects', () => {
558+
const response = {
559+
listingId: 'listing-001',
560+
name: 'Acme Utils',
561+
version: '1.0.0',
562+
objects: [
563+
{ name: 'task', label: 'Task', fieldCount: 5 },
564+
],
565+
};
566+
const parsed = PreviewResponseSchema.parse(response);
567+
expect(parsed.objects).toHaveLength(1);
568+
expect(parsed.views).toBeUndefined();
569+
expect(parsed.dashboards).toBeUndefined();
570+
});
571+
});

packages/spec/src/cloud/app-store.zod.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,139 @@ export const ListInstalledAppsResponseSchema = z.object({
376376
pageSize: z.number().int().min(1),
377377
});
378378

379+
// ==========================================
380+
// Preview / Demo Mode (No Login Required)
381+
// ==========================================
382+
383+
/**
384+
* Preview Request Schema
385+
*
386+
* Allows customers to request a preview of a marketplace listing's content
387+
* without registration or login. Analogous to Salesforce AppExchange
388+
* "Test Drive" or Shopify App Store live demo.
389+
*
390+
* ## Use Case
391+
* A marketplace visitor wants to evaluate a package before committing
392+
* to install. They can browse object definitions, view layouts, sample
393+
* data, and navigation structure — all without authentication.
394+
*/
395+
export const PreviewRequestSchema = z.object({
396+
/** Listing ID to preview */
397+
listingId: z.string().describe('Marketplace listing ID to preview'),
398+
399+
/** Specific version to preview (defaults to latest) */
400+
version: z.string().optional().describe('Version to preview (defaults to latest)'),
401+
402+
/**
403+
* Content types to include in the preview response.
404+
* If omitted, returns all content types enabled by the publisher.
405+
*/
406+
includeContent: z.array(z.enum([
407+
'objects', // Object definitions (fields, relationships)
408+
'views', // List and form view definitions
409+
'dashboards', // Dashboard layouts
410+
'flows', // Automation flow definitions
411+
'sample_data', // Seed/demo data
412+
'navigation', // App navigation structure
413+
])).optional()
414+
.describe('Content types to include (defaults to all enabled by publisher)'),
415+
});
416+
417+
/**
418+
* Preview Object Summary — a read-only snapshot of an object definition
419+
*/
420+
export const PreviewObjectSummarySchema = z.object({
421+
/** Object name (snake_case) */
422+
name: z.string().describe('Object machine name'),
423+
424+
/** Display label */
425+
label: z.string().describe('Object display label'),
426+
427+
/** Number of fields */
428+
fieldCount: z.number().int().min(0).describe('Total field count'),
429+
430+
/** Field summaries (name + type + label) */
431+
fields: z.array(z.object({
432+
name: z.string().describe('Field machine name'),
433+
label: z.string().describe('Field display label'),
434+
type: z.string().describe('Field type (text, number, lookup, etc.)'),
435+
})).optional().describe('Field summaries'),
436+
});
437+
438+
/**
439+
* Preview View Summary — a read-only snapshot of a view definition
440+
*/
441+
export const PreviewViewSummarySchema = z.object({
442+
/** View name */
443+
name: z.string().describe('View name'),
444+
445+
/** Display label */
446+
label: z.string().describe('View display label'),
447+
448+
/** View type (list or form) */
449+
type: z.enum(['list', 'form']).describe('View type'),
450+
451+
/** Target object name */
452+
objectName: z.string().describe('Target object name'),
453+
});
454+
455+
/**
456+
* Preview Response Schema
457+
*
458+
* A read-only snapshot of the package contents for evaluation.
459+
* No authentication required to receive this response.
460+
*/
461+
export const PreviewResponseSchema = z.object({
462+
/** Listing ID */
463+
listingId: z.string().describe('Marketplace listing ID'),
464+
465+
/** Package display name */
466+
name: z.string().describe('Package display name'),
467+
468+
/** Version being previewed */
469+
version: z.string().describe('Version being previewed'),
470+
471+
/** External demo URL (if available) */
472+
demoUrl: z.string().url().optional()
473+
.describe('External demo URL for live interactive preview'),
474+
475+
/** Object definitions included in the package */
476+
objects: z.array(PreviewObjectSummarySchema).optional()
477+
.describe('Object definitions'),
478+
479+
/** View definitions included in the package */
480+
views: z.array(PreviewViewSummarySchema).optional()
481+
.describe('View definitions'),
482+
483+
/** Dashboard names included in the package */
484+
dashboards: z.array(z.object({
485+
name: z.string(),
486+
label: z.string(),
487+
})).optional().describe('Dashboard summaries'),
488+
489+
/** Flow/automation names included in the package */
490+
flows: z.array(z.object({
491+
name: z.string(),
492+
label: z.string(),
493+
type: z.string().describe('Flow type (autolaunched, screen, schedule, etc.)'),
494+
})).optional().describe('Automation flow summaries'),
495+
496+
/** Navigation structure */
497+
navigation: z.array(z.object({
498+
id: z.string(),
499+
label: z.string(),
500+
type: z.string().describe('Navigation item type'),
501+
})).optional().describe('Navigation structure'),
502+
503+
/** Sample data record counts by object */
504+
sampleData: z.record(z.string(), z.number().int().min(0)).optional()
505+
.describe('Sample data record counts keyed by object name'),
506+
507+
/** Preview session expiration (if applicable) */
508+
expiresAt: z.string().datetime().optional()
509+
.describe('Preview session expiration timestamp'),
510+
});
511+
379512
// ==========================================
380513
// Export Types
381514
// ==========================================
@@ -394,3 +527,7 @@ export type AppSubscription = z.infer<typeof AppSubscriptionSchema>;
394527
export type InstalledAppSummary = z.infer<typeof InstalledAppSummarySchema>;
395528
export type ListInstalledAppsRequest = z.infer<typeof ListInstalledAppsRequestSchema>;
396529
export type ListInstalledAppsResponse = z.infer<typeof ListInstalledAppsResponseSchema>;
530+
export type PreviewRequest = z.infer<typeof PreviewRequestSchema>;
531+
export type PreviewObjectSummary = z.infer<typeof PreviewObjectSummarySchema>;
532+
export type PreviewViewSummary = z.infer<typeof PreviewViewSummarySchema>;
533+
export type PreviewResponse = z.infer<typeof PreviewResponseSchema>;

packages/spec/src/cloud/marketplace.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,55 @@ describe('MarketplaceListingSchema', () => {
167167
};
168168
expect(() => MarketplaceListingSchema.parse(listing)).toThrow();
169169
});
170+
171+
it('should accept listing with preview mode enabled', () => {
172+
const listing = {
173+
id: 'listing-002',
174+
packageId: 'com.acme.crm',
175+
publisherId: 'pub-001',
176+
name: 'Acme CRM',
177+
category: 'crm' as const,
178+
latestVersion: '1.0.0',
179+
preview: {
180+
enabled: true,
181+
demoUrl: 'https://demo.acme.com/crm',
182+
includedContent: ['objects', 'views', 'navigation'],
183+
expiresInSeconds: 3600,
184+
},
185+
};
186+
const parsed = MarketplaceListingSchema.parse(listing);
187+
expect(parsed.preview?.enabled).toBe(true);
188+
expect(parsed.preview?.demoUrl).toBe('https://demo.acme.com/crm');
189+
expect(parsed.preview?.includedContent).toHaveLength(3);
190+
expect(parsed.preview?.expiresInSeconds).toBe(3600);
191+
});
192+
193+
it('should default preview.enabled to false', () => {
194+
const listing = {
195+
id: 'listing-003',
196+
packageId: 'com.acme.utils',
197+
publisherId: 'pub-001',
198+
name: 'Acme Utils',
199+
category: 'other' as const,
200+
latestVersion: '1.0.0',
201+
preview: {},
202+
};
203+
const parsed = MarketplaceListingSchema.parse(listing);
204+
expect(parsed.preview?.enabled).toBe(false);
205+
});
206+
207+
it('should accept listing without preview (optional)', () => {
208+
const listing = {
209+
id: 'listing-004',
210+
packageId: 'com.acme.tools',
211+
publisherId: 'pub-001',
212+
name: 'Acme Tools',
213+
category: 'other' as const,
214+
latestVersion: '1.0.0',
215+
};
216+
const parsed = MarketplaceListingSchema.parse(listing);
217+
expect(parsed.preview).toBeUndefined();
218+
});
170219
});
171220

172221
describe('PackageSubmissionSchema', () => {

0 commit comments

Comments
 (0)