mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(plugins): apply exact local plugin source
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// and accidentally bypasses the snapshot writer (spec §8.2.1).
|
||||
export * from './atoms.js';
|
||||
export * from './apply.js';
|
||||
export * from './local-source.js';
|
||||
export {
|
||||
validatePluginFolder,
|
||||
flattenValidationDiagnostics,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import {
|
||||
readTeamResourceMaterialization,
|
||||
teamResourceMaterializationDir,
|
||||
} from '../collab/team-resource-materialization.js';
|
||||
import { isSafePluginId } from './installer.js';
|
||||
import { getInstalledPlugin, resolvePluginFolder } from './registry.js';
|
||||
|
||||
const TEAM_PLUGIN_SOURCE_PREFIX = 'team:plugin:';
|
||||
|
||||
function workspaceIdFromTeamPluginSource(
|
||||
source: string,
|
||||
pluginId: string,
|
||||
): string | null {
|
||||
const suffix = `:${pluginId}`;
|
||||
if (!source.startsWith(TEAM_PLUGIN_SOURCE_PREFIX) || !source.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
const workspaceId = source.slice(
|
||||
TEAM_PLUGIN_SOURCE_PREFIX.length,
|
||||
-suffix.length,
|
||||
).trim();
|
||||
return workspaceId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the exact already-local record selected by the UI. This deliberately
|
||||
* does not perform Workspace authorization: remote discovery and sync own that
|
||||
* boundary, while apply only reads bytes already managed by this daemon.
|
||||
*/
|
||||
export async function resolveLocalPluginBySource(input: {
|
||||
db: Database.Database;
|
||||
id: string;
|
||||
source: string;
|
||||
userPluginsRoot: string;
|
||||
}): Promise<InstalledPluginRecord | null> {
|
||||
const { db, id, source, userPluginsRoot } = input;
|
||||
const installed = getInstalledPlugin(db, id);
|
||||
if (installed?.source === source) return installed;
|
||||
|
||||
if (!isSafePluginId(id)) return null;
|
||||
const workspaceId = workspaceIdFromTeamPluginSource(source, id);
|
||||
if (!workspaceId) return null;
|
||||
const marker = await readTeamResourceMaterialization(
|
||||
userPluginsRoot,
|
||||
workspaceId,
|
||||
id,
|
||||
id,
|
||||
);
|
||||
if (
|
||||
!marker
|
||||
|| marker.kind !== 'plugin'
|
||||
|| marker.resourceId !== id
|
||||
|| marker.workspaceId !== workspaceId
|
||||
|| marker.sourceKey !== source
|
||||
) return null;
|
||||
|
||||
const resolved = await resolvePluginFolder({
|
||||
folder: teamResourceMaterializationDir(userPluginsRoot, workspaceId, id, id),
|
||||
folderId: id,
|
||||
sourceKind: 'user',
|
||||
source,
|
||||
});
|
||||
return resolved.ok ? resolved.record : null;
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export interface ResolveSnapshotInput {
|
||||
// Pluggable for tests; in production these are the daemon's live
|
||||
// skill / design-system catalogs (server.ts wires them).
|
||||
registry: RegistryView;
|
||||
/** Exact already-local record selected by a record-aware caller. */
|
||||
plugin?: InstalledPluginRecord | undefined;
|
||||
connectorProbe?: ConnectorProbe | undefined;
|
||||
// Optional active-project DS binding. Forwarded to `applyPlugin` so
|
||||
// plugins that declared `od.context.designSystem.primary: true` get
|
||||
@@ -220,7 +222,9 @@ export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnaps
|
||||
}
|
||||
|
||||
// Path 2: pluginId — run apply, persist a new snapshot.
|
||||
const plugin = getInstalledPlugin(input.db, fields.pluginId!);
|
||||
const plugin = input.plugin?.id === fields.pluginId
|
||||
? input.plugin
|
||||
: getInstalledPlugin(input.db, fields.pluginId!);
|
||||
if (!plugin) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -255,6 +255,11 @@ export interface RegisterPluginRoutesDeps {
|
||||
workspaceId: string | null,
|
||||
workspaceMemberId?: string | null,
|
||||
) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>;
|
||||
getLocalPluginBySource?: (
|
||||
db: SqliteDbLike,
|
||||
id: string,
|
||||
source: string,
|
||||
) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>;
|
||||
installPlugin: (db: SqliteDbLike, args: unknown) => AsyncIterable<unknown>;
|
||||
isSafePluginId: (id: string) => boolean;
|
||||
uninstallPlugin: (db: SqliteDbLike, id: string, roots: string[]) => Promise<{ ok: boolean; removedFolder?: boolean; warning?: string }>;
|
||||
@@ -404,6 +409,34 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
|
||||
? plugins.getWorkspacePlugin(db, id, workspaceId, authority?.workspaceMemberId ?? null)
|
||||
: plugins.getInstalledPlugin(db, id);
|
||||
};
|
||||
const applyResolvedPlugin = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
plugin: InstalledPluginLike,
|
||||
registry: unknown,
|
||||
) => {
|
||||
const body = req.body && typeof req.body === 'object'
|
||||
? req.body as Record<string, unknown>
|
||||
: {};
|
||||
const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {};
|
||||
const grantCaps = Array.isArray(body.grantCaps)
|
||||
? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string')
|
||||
: [];
|
||||
const locale = typeof body.locale === 'string' ? body.locale : undefined;
|
||||
const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService);
|
||||
const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
|
||||
if (grantCaps.length > 0) {
|
||||
const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]);
|
||||
computed.result.capabilitiesGranted = Array.from(merged);
|
||||
computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged);
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
...computed.result,
|
||||
warnings: computed.warnings,
|
||||
manifestSourceDigest: computed.manifestSourceDigest,
|
||||
});
|
||||
};
|
||||
const isTeamPlugin = (plugin: InstalledPluginLike | null | undefined): boolean =>
|
||||
typeof plugin?.source === 'string' && plugin.source.startsWith('team:plugin:');
|
||||
const hasActiveTeamPluginBinding = (
|
||||
@@ -579,20 +612,35 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
|
||||
)) return;
|
||||
return helpers.installOrUpgradePlugin(req, res, 'upgrade', authority);
|
||||
});
|
||||
app.post('/api/plugins/:id/apply-local', async (req, res) => {
|
||||
res.setHeader('x-od-plugin-apply-local', '1');
|
||||
try {
|
||||
const body = req.body && typeof req.body === 'object'
|
||||
? req.body as Record<string, unknown>
|
||||
: {};
|
||||
const source = typeof body.source === 'string' ? body.source.trim() : '';
|
||||
if (!source || !plugins.getLocalPluginBySource) {
|
||||
return res.status(404).json({ error: 'plugin not found' });
|
||||
}
|
||||
const plugin = await plugins.getLocalPluginBySource(db, req.params.id, source);
|
||||
if (!plugin) {
|
||||
return res.status(404).json({ error: 'plugin not found' });
|
||||
}
|
||||
const registry = await helpers.loadPluginRegistryView();
|
||||
return applyResolvedPlugin(req, res, plugin, registry);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof plugins.MissingInputError) {
|
||||
return res.status(422).json({ error: 'missing_inputs', fields: err.fields });
|
||||
}
|
||||
return res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
app.post('/api/plugins/:id/apply', async (req, res) => {
|
||||
try {
|
||||
const authority = await resolveWorkspaceAuthority(req, res);
|
||||
if (authority === undefined) return;
|
||||
const plugin = await resolveRequestPlugin(req.params.id, authority);
|
||||
if (!plugin) return res.status(404).json({ error: 'plugin not found' });
|
||||
const body = req.body && typeof req.body === 'object'
|
||||
? req.body as Record<string, unknown>
|
||||
: {};
|
||||
const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {};
|
||||
const grantCaps = Array.isArray(body.grantCaps)
|
||||
? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string')
|
||||
: [];
|
||||
const locale = typeof body.locale === 'string' ? body.locale : undefined;
|
||||
const registry = await helpers.loadPluginRegistryView({
|
||||
workspaceId: authority?.workspaceId ?? null,
|
||||
workspaceMemberId: authority?.workspaceMemberId ?? null,
|
||||
@@ -613,19 +661,7 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
|
||||
) {
|
||||
return res.status(404).json({ error: 'plugin not found' });
|
||||
}
|
||||
const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService);
|
||||
const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
|
||||
if (grantCaps.length > 0) {
|
||||
const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]);
|
||||
computed.result.capabilitiesGranted = Array.from(merged);
|
||||
computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged);
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
...computed.result,
|
||||
warnings: computed.warnings,
|
||||
manifestSourceDigest: computed.manifestSourceDigest,
|
||||
});
|
||||
return applyResolvedPlugin(req, res, plugin, registry);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof plugins.MissingInputError) {
|
||||
return res.status(422).json({ error: 'missing_inputs', fields: err.fields });
|
||||
|
||||
@@ -128,6 +128,10 @@ export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | '
|
||||
id: string,
|
||||
options: { workspaceId: string | null; workspaceMemberId: string | null },
|
||||
) => Promise<unknown | null>;
|
||||
getLocalPluginBySource?: (
|
||||
id: string,
|
||||
source: string,
|
||||
) => Promise<Parameters<typeof resolvePluginSnapshot>[0]['plugin'] | null>;
|
||||
};
|
||||
teamProjectCatalog?: VelaTeamProjectCatalogClient;
|
||||
/** Bounded authoritative verifier for idempotent Workspace project reads. */
|
||||
@@ -3490,10 +3494,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
||||
? req.body.pluginId.trim()
|
||||
: null;
|
||||
const requestedPluginSource =
|
||||
typeof req.body?.pluginSource === 'string' && req.body.pluginSource.trim().length > 0
|
||||
? req.body.pluginSource.trim()
|
||||
: null;
|
||||
const exactLocalPlugin = requestedPluginId && requestedPluginSource
|
||||
? await ctx.pluginScope?.getLocalPluginBySource?.(
|
||||
requestedPluginId,
|
||||
requestedPluginSource,
|
||||
) ?? null
|
||||
: null;
|
||||
if (
|
||||
requestedPluginId
|
||||
&& requestedPluginSource
|
||||
&& ctx.pluginScope?.getLocalPluginBySource
|
||||
&& !exactLocalPlugin
|
||||
) {
|
||||
return sendApiError(res, 404, 'PLUGIN_NOT_FOUND', 'plugin not found');
|
||||
}
|
||||
if (requestedPluginId) {
|
||||
const visiblePlugin = ctx.pluginScope
|
||||
? await ctx.pluginScope.getPlugin(requestedPluginId, creationWorkspaceScope)
|
||||
: getInstalledPlugin(db, requestedPluginId);
|
||||
const visiblePlugin = exactLocalPlugin ?? (
|
||||
ctx.pluginScope
|
||||
? await ctx.pluginScope.getPlugin(requestedPluginId, creationWorkspaceScope)
|
||||
: getInstalledPlugin(db, requestedPluginId)
|
||||
);
|
||||
if (!visiblePlugin) {
|
||||
return sendApiError(res, 404, 'PLUGIN_NOT_FOUND', 'plugin not found');
|
||||
}
|
||||
@@ -3649,6 +3673,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||
? { id: normalizedDesignSystemId }
|
||||
: undefined,
|
||||
connectorProbe: buildConnectorProbe(connectorService),
|
||||
...(exactLocalPlugin ? { plugin: exactLocalPlugin } : {}),
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
if (!explicitPlugin) {
|
||||
|
||||
@@ -372,6 +372,7 @@ import {
|
||||
registerBuiltInAtomWorkers,
|
||||
registerBundledPlugins,
|
||||
registryRootsForDataDir,
|
||||
resolveLocalPluginBySource,
|
||||
restoreProjectSnapshotLink,
|
||||
resolvePluginSnapshot,
|
||||
runPipelineForRun,
|
||||
@@ -7274,6 +7275,11 @@ export async function startServer({
|
||||
options.workspaceId,
|
||||
options.workspaceMemberId,
|
||||
),
|
||||
getLocalPluginBySource: (id, source) => getLocalPluginBySource(
|
||||
db,
|
||||
id,
|
||||
source,
|
||||
),
|
||||
},
|
||||
events: projectEventDeps,
|
||||
ids: idDeps,
|
||||
@@ -8182,6 +8188,16 @@ export async function startServer({
|
||||
(plugin) => plugin.id === id,
|
||||
) ?? null;
|
||||
};
|
||||
const getLocalPluginBySource = async (
|
||||
dbHandle,
|
||||
id: string,
|
||||
source: string,
|
||||
) => resolveLocalPluginBySource({
|
||||
db: dbHandle,
|
||||
id,
|
||||
source,
|
||||
userPluginsRoot: PLUGIN_REGISTRY_ROOTS.userPluginsRoot,
|
||||
});
|
||||
|
||||
registerPluginRoutes(app, {
|
||||
db,
|
||||
@@ -8204,6 +8220,7 @@ export async function startServer({
|
||||
listInstalledPlugins: listWorkspacePlugins,
|
||||
getInstalledPlugin,
|
||||
getWorkspacePlugin: getWorkspacePluginForRequest,
|
||||
getLocalPluginBySource,
|
||||
installPlugin,
|
||||
isSafePluginId,
|
||||
uninstallPlugin,
|
||||
|
||||
@@ -105,4 +105,83 @@ describe('Team plugin apply retraction gate', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(applyPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the exact local Team materialization without Workspace headers', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const applyPlugin = vi.fn((input: { plugin: { source?: string } }) => ({
|
||||
result: {
|
||||
query: input.plugin.source,
|
||||
capabilitiesGranted: [],
|
||||
appliedPlugin: { capabilitiesGranted: [] },
|
||||
},
|
||||
warnings: [],
|
||||
manifestSourceDigest: 'team-digest',
|
||||
}));
|
||||
const middleware: express.RequestHandler = (_req, _res, next) => next();
|
||||
|
||||
registerPluginRoutes(app, {
|
||||
db: {
|
||||
prepare: () => ({ all: () => [], get: () => null, run: () => undefined }),
|
||||
transaction: (run: () => unknown) => () => run(),
|
||||
},
|
||||
paths: { PROJECTS_DIR: '', PLUGIN_REGISTRY_ROOTS: [], PLUGIN_LOCKFILE_PATH: '' },
|
||||
ids: { randomId: () => 'unused' },
|
||||
projectStore: {},
|
||||
conversations: {},
|
||||
plugins: {
|
||||
getInstalledPlugin: () => ({
|
||||
id: 'shared-id',
|
||||
source: 'local:personal:shared-id',
|
||||
}),
|
||||
getWorkspacePlugin: async () => null,
|
||||
getLocalPluginBySource: async (_db: unknown, id: string, source: string) =>
|
||||
id === 'shared-id' && source === 'team:plugin:ws-a:shared-id'
|
||||
? { id, source }
|
||||
: null,
|
||||
listInstalledPlugins: () => [],
|
||||
applyPlugin,
|
||||
MissingInputError: class MissingInputError extends Error {
|
||||
fields: string[] = [];
|
||||
},
|
||||
},
|
||||
helpers: {
|
||||
requireLocalDaemonRequest: middleware,
|
||||
pluginUpload: {
|
||||
single: () => middleware,
|
||||
array: () => middleware,
|
||||
},
|
||||
loadPluginRegistryView: async () => ({}),
|
||||
buildConnectorProbe: () => ({}),
|
||||
connectorService: {},
|
||||
sendApiError: (res: express.Response, status: number, code: string, message: string) =>
|
||||
res.status(status).json({ error: { code, message } }),
|
||||
},
|
||||
} as unknown as Parameters<typeof registerPluginRoutes>[1]);
|
||||
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/plugins/shared-id/apply-local`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: 'team:plugin:ws-a:shared-id',
|
||||
inputs: {},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
query: 'team:plugin:ws-a:shared-id',
|
||||
});
|
||||
expect(applyPlugin).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin: expect.objectContaining({ source: 'team:plugin:ws-a:shared-id' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { openDatabase } from '../src/db.js';
|
||||
import {
|
||||
materializeWorkspaceScopedTeamResource,
|
||||
teamResourceMaterializationDir,
|
||||
} from '../src/collab/team-resource-materialization.js';
|
||||
import {
|
||||
resolveLocalPluginBySource,
|
||||
resolvePluginFolder,
|
||||
resolvePluginSnapshot,
|
||||
upsertInstalledPlugin,
|
||||
} from '../src/plugins/index.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) =>
|
||||
rm(root, { recursive: true, force: true }),
|
||||
));
|
||||
});
|
||||
|
||||
async function pluginManifest(folder: string, title: string): Promise<void> {
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(folder, 'open-design.json'),
|
||||
JSON.stringify({ name: 'shared-id', title, version: '1.0.0' }),
|
||||
);
|
||||
}
|
||||
|
||||
describe('resolveLocalPluginBySource', () => {
|
||||
it('selects an exact Team materialization when a Personal plugin has the same id', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'od-local-plugin-source-'));
|
||||
roots.push(root);
|
||||
const dataDir = path.join(root, 'data');
|
||||
const pluginsRoot = path.join(dataDir, 'plugins');
|
||||
const db = openDatabase(root, { dataDir });
|
||||
|
||||
const personalFolder = path.join(root, 'personal-plugin');
|
||||
await pluginManifest(personalFolder, 'Personal copy');
|
||||
const personal = await resolvePluginFolder({
|
||||
folder: personalFolder,
|
||||
folderId: 'shared-id',
|
||||
sourceKind: 'local',
|
||||
source: 'local:personal:shared-id',
|
||||
});
|
||||
if (!personal.ok) throw new Error(personal.errors.join('; '));
|
||||
upsertInstalledPlugin(db, personal.record);
|
||||
|
||||
const team = await materializeWorkspaceScopedTeamResource({
|
||||
kindRoot: pluginsRoot,
|
||||
identity: {
|
||||
kind: 'plugin',
|
||||
workspaceId: 'workspace-a',
|
||||
resourceId: 'shared-id',
|
||||
hubResourceId: 'hub-team-shared-id',
|
||||
},
|
||||
storageName: 'shared-id',
|
||||
pullInto: (dir) => pluginManifest(dir, 'Team copy'),
|
||||
verifyWorkspaceScope: async () => true,
|
||||
verifyStillShared: async () => true,
|
||||
});
|
||||
if (team.status !== 'committed') throw new Error('fixture did not materialize');
|
||||
|
||||
const exactTeamPlugin = await resolveLocalPluginBySource({
|
||||
db,
|
||||
id: 'shared-id',
|
||||
source: team.sourceKey,
|
||||
userPluginsRoot: pluginsRoot,
|
||||
});
|
||||
expect(exactTeamPlugin).toMatchObject({
|
||||
id: 'shared-id',
|
||||
title: 'Team copy',
|
||||
source: 'team:plugin:workspace-a:shared-id',
|
||||
fsPath: teamResourceMaterializationDir(
|
||||
pluginsRoot,
|
||||
'workspace-a',
|
||||
'shared-id',
|
||||
'shared-id',
|
||||
),
|
||||
});
|
||||
await expect(resolveLocalPluginBySource({
|
||||
db,
|
||||
id: 'shared-id',
|
||||
source: 'local:personal:shared-id',
|
||||
userPluginsRoot: pluginsRoot,
|
||||
})).resolves.toMatchObject({ title: 'Personal copy' });
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('team-project', 'Team project', 1, 1);
|
||||
const snapshot = resolvePluginSnapshot({
|
||||
db,
|
||||
body: { pluginId: 'shared-id' },
|
||||
projectId: 'team-project',
|
||||
registry: { skills: [], designSystems: [], craft: [], atoms: [] },
|
||||
plugin: exactTeamPlugin ?? undefined,
|
||||
});
|
||||
expect(snapshot?.ok).toBe(true);
|
||||
if (snapshot?.ok) {
|
||||
expect(snapshot.snapshot.pluginTitle).toBe('Team copy');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a forged Team source without an exact materialization marker', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'od-local-plugin-source-'));
|
||||
roots.push(root);
|
||||
const dataDir = path.join(root, 'data');
|
||||
const db = openDatabase(root, { dataDir });
|
||||
|
||||
await expect(resolveLocalPluginBySource({
|
||||
db,
|
||||
id: 'shared-id',
|
||||
source: 'team:plugin:workspace-forged:shared-id',
|
||||
userPluginsRoot: path.join(dataDir, 'plugins'),
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,7 @@ type AppCreateProjectInput = Omit<CreateInput, 'metadata'> & {
|
||||
metadata?: CreateInput['metadata'];
|
||||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
pluginSource?: string;
|
||||
pluginType?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
@@ -2892,6 +2893,7 @@ function AppInner() {
|
||||
metadata,
|
||||
...(input.conversationMode ? { conversationMode: input.conversationMode } : {}),
|
||||
...(input.pluginId ? { pluginId: input.pluginId } : {}),
|
||||
...(input.pluginSource ? { pluginSource: input.pluginSource } : {}),
|
||||
...(input.appliedPluginSnapshotId
|
||||
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
|
||||
: {}),
|
||||
|
||||
@@ -283,6 +283,7 @@ type EntryCreateProjectInput = Omit<CreateInput, 'metadata'> & {
|
||||
metadata?: CreateInput['metadata'];
|
||||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
pluginSource?: string;
|
||||
pluginType?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
@@ -1415,6 +1416,7 @@ export function EntryShell({
|
||||
metadata,
|
||||
pendingPrompt: payload.prompt,
|
||||
...(payload.pluginId ? { pluginId: payload.pluginId } : {}),
|
||||
...(payload.pluginSource ? { pluginSource: payload.pluginSource } : {}),
|
||||
...(payload.pluginType ? { pluginType: payload.pluginType } : {}),
|
||||
...(payload.appliedPluginSnapshotId
|
||||
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
|
||||
|
||||
@@ -49,6 +49,7 @@ type EntryCreateProjectInput = Omit<CreateInput, 'metadata'> & {
|
||||
metadata?: CreateInput['metadata'];
|
||||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
pluginSource?: string;
|
||||
pluginType?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
|
||||
@@ -95,11 +95,8 @@ import { setPendingDesignSystemCreateEntry } from '../analytics/ds-create-entry'
|
||||
import { workspaceContextLinkedDirs } from './workspace-context';
|
||||
import {
|
||||
currentWorkspaceAccountGeneration,
|
||||
type CurrentWorkspaceContextReadWitness,
|
||||
resolveCurrentWorkspaceContextReadWitness,
|
||||
useTeamProjects,
|
||||
useWorkspaceContext,
|
||||
workspaceContextReadWitnessFromState,
|
||||
} from '../collab/useWorkspaceContext';
|
||||
import { useWorkspaceInvalidation } from '../collab/workspace-events';
|
||||
import { useWorkspaceSnapshotActivation } from '../collab/workspace-snapshot-activation';
|
||||
@@ -1374,41 +1371,17 @@ export function HomeView({
|
||||
setPendingChipId(null);
|
||||
}
|
||||
}
|
||||
let writeWorkspaceContext;
|
||||
let workspaceWitness: CurrentWorkspaceContextReadWitness | null = null;
|
||||
const requiresWorkspaceIdentity = record.source.startsWith('team:plugin:');
|
||||
if (!requiresWorkspaceIdentity) {
|
||||
try {
|
||||
if (workspaceContextState.identityChangePending) {
|
||||
throw new Error('workspace identity change pending');
|
||||
}
|
||||
writeWorkspaceContext = resolvedWorkspaceContextForWrite(workspaceContextState);
|
||||
} catch {
|
||||
writeWorkspaceContext = null;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
workspaceWitness = workspaceContextReadWitnessFromState(workspaceContextState)
|
||||
?? await resolveCurrentWorkspaceContextReadWitness();
|
||||
} catch {
|
||||
workspaceWitness = null;
|
||||
}
|
||||
if (!workspaceWitness?.isStillCurrent() || !workspaceWitness.context) {
|
||||
clearPendingApply();
|
||||
setError(
|
||||
'Workspace context is unavailable. Try again when workspace sync finishes.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
writeWorkspaceContext = workspaceWitness.context;
|
||||
}
|
||||
const writeWorkspaceContext = resolvedWorkspaceContextForWrite(
|
||||
workspaceContextState,
|
||||
{ unavailablePolicy: 'unscoped' },
|
||||
);
|
||||
const result = await applyPlugin(record.id, {
|
||||
locale,
|
||||
inputs,
|
||||
pluginSource: record.source,
|
||||
workspaceContext: writeWorkspaceContext,
|
||||
});
|
||||
clearPendingApply();
|
||||
if (workspaceWitness && !workspaceWitness.isStillCurrent()) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2483,6 +2456,9 @@ export function HomeView({
|
||||
const accepted = await onSubmit({
|
||||
prompt: trimmed,
|
||||
pluginId: routedPluginId,
|
||||
...(submittedActive?.record.source
|
||||
? { pluginSource: submittedActive.record.source }
|
||||
: {}),
|
||||
pluginType: submittedActive?.record.marketplaceTrust ?? (routedPluginId ? 'official' : null),
|
||||
skillId: resolvedSkillId,
|
||||
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
|
||||
|
||||
@@ -172,6 +172,7 @@ export function InlinePluginsRail(props: Props) {
|
||||
const result = await applyPlugin(record.id, {
|
||||
...(props.projectId ? { projectId: props.projectId } : {}),
|
||||
locale,
|
||||
pluginSource: record.source,
|
||||
workspaceContext: writeWorkspaceContext,
|
||||
});
|
||||
if (workspaceIdentityRef.current !== issuedIdentity) return;
|
||||
|
||||
@@ -298,9 +298,14 @@ export function PluginDetailView(props: Props) {
|
||||
});
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
const workspaceContext = resolvedWorkspaceContextForWrite(
|
||||
workspaceContextState,
|
||||
{ unavailablePolicy: 'unscoped' },
|
||||
);
|
||||
const result = await applyPlugin(plugin.id, {
|
||||
locale,
|
||||
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
|
||||
pluginSource: plugin.source,
|
||||
workspaceContext,
|
||||
});
|
||||
setApplying(false);
|
||||
if (!result) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useWorkspaceContext } from '../collab/useWorkspaceContext';
|
||||
export interface PluginLoopSubmit {
|
||||
prompt: string;
|
||||
pluginId: string | null;
|
||||
pluginSource?: string | null;
|
||||
// Marketplace trust of the routed plugin (official / community / …), used
|
||||
// to attribute project_create_result to a plugin type. Null when no plugin.
|
||||
pluginType?: string | null;
|
||||
@@ -132,9 +133,14 @@ export function PluginLoopHome({ onSubmit }: Props) {
|
||||
) {
|
||||
setPendingApplyId(record.id);
|
||||
setError(null);
|
||||
const workspaceContext = resolvedWorkspaceContextForWrite(
|
||||
workspaceContextState,
|
||||
{ unavailablePolicy: 'unscoped' },
|
||||
);
|
||||
const result = await applyPlugin(record.id, {
|
||||
locale,
|
||||
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
|
||||
pluginSource: record.source,
|
||||
workspaceContext,
|
||||
});
|
||||
setPendingApplyId(null);
|
||||
if (!result) {
|
||||
|
||||
@@ -167,11 +167,13 @@ export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
|
||||
try {
|
||||
workspaceContext = workspaceContextForAction();
|
||||
} catch {
|
||||
return null;
|
||||
if (!record?.source) return null;
|
||||
workspaceContext = null;
|
||||
}
|
||||
const result = await applyPlugin(pluginId, {
|
||||
...(props.projectId ? { projectId: props.projectId } : {}),
|
||||
locale,
|
||||
...(record?.source ? { pluginSource: record.source } : {}),
|
||||
workspaceContext,
|
||||
});
|
||||
if (!result) return null;
|
||||
|
||||
@@ -404,6 +404,7 @@ export function PluginsView({
|
||||
setNotice(null);
|
||||
const result = await applyPlugin(record.id, {
|
||||
locale,
|
||||
pluginSource: record.source,
|
||||
workspaceContext: pluginsContextRef.current,
|
||||
});
|
||||
setPendingApplyId(null);
|
||||
|
||||
@@ -602,6 +602,7 @@ export async function createProject(
|
||||
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
|
||||
// project. Used by the PluginLoopHome flow on Home.
|
||||
pluginId?: string;
|
||||
pluginSource?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
workspaceContext?: WorkspaceCollabContext | null;
|
||||
@@ -1571,9 +1572,10 @@ export async function persistTabsToDaemonNow(
|
||||
// Plan §3.C1 — plugin discovery + apply.
|
||||
//
|
||||
// applyPlugin() is the canonical entry point for both the inline rail
|
||||
// (NewProjectPanel + ChatComposer) and the marketplace detail page. It
|
||||
// hits POST /api/plugins/:id/apply, which is the same pure resolver
|
||||
// the daemon uses; the response carries everything the composer needs:
|
||||
// (NewProjectPanel + ChatComposer) and the marketplace detail page. Record-aware
|
||||
// callers use the exact local source resolver; id-only compatibility callers
|
||||
// retain POST /api/plugins/:id/apply. Both responses carry everything the
|
||||
// composer needs:
|
||||
// - query (pre-filled brief)
|
||||
// - contextItems (chip strip)
|
||||
// - inputs (form fields)
|
||||
@@ -2428,13 +2430,46 @@ export async function applyPlugin(
|
||||
projectId?: string;
|
||||
grantCaps?: string[];
|
||||
locale?: string;
|
||||
pluginSource?: string;
|
||||
workspaceContext?: WorkspaceCollabContext | null;
|
||||
} = {},
|
||||
): Promise<ApplyResult | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/plugins/${encodeURIComponent(pluginId)}/apply`,
|
||||
const requestBody = JSON.stringify({
|
||||
...(options.pluginSource ? { source: options.pluginSource } : {}),
|
||||
inputs: options.inputs ?? {},
|
||||
projectId: options.projectId,
|
||||
grantCaps: options.grantCaps ?? [],
|
||||
locale: options.locale,
|
||||
});
|
||||
const pluginUrl = `/api/plugins/${encodeURIComponent(pluginId)}`;
|
||||
let resp = await fetch(
|
||||
`${pluginUrl}/${options.pluginSource ? 'apply-local' : 'apply'}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(!options.pluginSource && options.workspaceContext
|
||||
? workspaceProjectHeaders(options.workspaceContext)
|
||||
: {}),
|
||||
},
|
||||
body: requestBody,
|
||||
},
|
||||
);
|
||||
// Compatibility with a daemon that predates exact local-source apply. The
|
||||
// legacy route keeps its original Workspace-scoped behavior and response;
|
||||
// new daemons never take this branch for a valid catalog record.
|
||||
const localApplyUnsupported = Boolean(
|
||||
options.pluginSource
|
||||
&& resp.status === 404
|
||||
&& resp.headers.get('x-od-plugin-apply-local') !== '1',
|
||||
);
|
||||
const legacyFallbackCanResolveSelectedSource = Boolean(
|
||||
!options.pluginSource?.startsWith('team:plugin:')
|
||||
|| options.workspaceContext,
|
||||
);
|
||||
if (localApplyUnsupported && legacyFallbackCanResolveSelectedSource) {
|
||||
resp = await fetch(`${pluginUrl}/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -2442,14 +2477,9 @@ export async function applyPlugin(
|
||||
? workspaceProjectHeaders(options.workspaceContext)
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: options.inputs ?? {},
|
||||
projectId: options.projectId,
|
||||
grantCaps: options.grantCaps ?? [],
|
||||
locale: options.locale,
|
||||
}),
|
||||
},
|
||||
);
|
||||
body: requestBody,
|
||||
});
|
||||
}
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as ApplyResult & { ok?: boolean };
|
||||
return json;
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('HomeView media composer options', () => {
|
||||
})));
|
||||
});
|
||||
|
||||
it('re-applies the selected plugin from directory identity while rich context is loading', async () => {
|
||||
it('re-applies the exact local source without waiting for rich Workspace context', async () => {
|
||||
const fetchMock = stubFetch({ teamMediaPlugin: true });
|
||||
const onSubmit = vi.fn();
|
||||
const props = homeProps({ onSubmit });
|
||||
@@ -465,12 +465,15 @@ describe('HomeView media composer options', () => {
|
||||
await submitHome();
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const scopedApply = fetchMock.mock.calls.filter(([url, init]) => (
|
||||
const localApply = fetchMock.mock.calls.filter(([url]) => (
|
||||
typeof url === 'string'
|
||||
&& url.includes('/api/plugins/od-media-generation/apply')
|
||||
&& new Headers(init?.headers).get('x-od-workspace-id') === 'workspace-cold'
|
||||
&& url.includes('/api/plugins/od-media-generation/apply-local')
|
||||
)).at(-1);
|
||||
expect(scopedApply).toBeTruthy();
|
||||
expect(localApply).toBeTruthy();
|
||||
expect(new Headers(localApply?.[1]?.headers).has('x-od-workspace-id')).toBe(false);
|
||||
expect(JSON.parse(String(localApply?.[1]?.body))).toMatchObject({
|
||||
source: 'team:plugin:workspace-a:od-media-generation',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps bundled plugins usable when identity is pending and the directory is empty', async () => {
|
||||
@@ -558,7 +561,7 @@ describe('HomeView media composer options', () => {
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply a team plugin unscoped when the settled workspace directory is empty', async () => {
|
||||
it('applies an already-local Team plugin when the settled workspace directory is empty', async () => {
|
||||
const fetchMock = stubFetch({
|
||||
emptyWorkspaceDirectory: true,
|
||||
teamMediaPlugin: true,
|
||||
@@ -577,9 +580,11 @@ describe('HomeView media composer options', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('home-hero-submit').getAttribute('aria-busy')).toBe('false');
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-media-generation/apply')
|
||||
))).toBe(false);
|
||||
const localApply = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-media-generation/apply-local')
|
||||
));
|
||||
expect(localApply).toBeTruthy();
|
||||
expect(new Headers(localApply?.[1]?.headers).has('x-od-workspace-id')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves od-media-generation required inputs when applying media chips', async () => {
|
||||
|
||||
@@ -618,7 +618,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
|
||||
return applyResponse;
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
@@ -646,7 +646,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(inputCard?.style.getPropertyValue('--home-hero-prompt-max-height')).toBe('132px');
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-plugin-authoring/apply',
|
||||
'/api/plugins/od-plugin-authoring/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
||||
@@ -680,7 +680,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
|
||||
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -718,7 +718,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
|
||||
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -745,12 +745,12 @@ describe('HomeView prompt handoff', () => {
|
||||
const dialog = await screen.findByRole('dialog', { name: /replace current prompt/i });
|
||||
expect(homeHeroPromptText()).toBe('Keep my custom plugin brief');
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')
|
||||
))).toBe(false);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Replace' }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-plugin-authoring/apply',
|
||||
'/api/plugins/od-plugin-authoring/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT));
|
||||
@@ -765,7 +765,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -795,7 +795,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
await screen.findByTestId('home-hero-input');
|
||||
@@ -813,6 +813,7 @@ describe('HomeView prompt handoff', () => {
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: 'Use the selected starter as the driver',
|
||||
pluginId: 'example-web-prototype',
|
||||
pluginSource: WEB_PROTOTYPE_PLUGIN.source,
|
||||
appliedPluginSnapshotId: 'snap-web-prototype',
|
||||
})));
|
||||
});
|
||||
@@ -861,7 +862,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -884,11 +885,11 @@ describe('HomeView prompt handoff', () => {
|
||||
|
||||
await clickHomeShortcut('create-plugin');
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-new-generation/apply',
|
||||
'/api/plugins/od-new-generation/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
|
||||
));
|
||||
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
|
||||
inputs: {
|
||||
@@ -925,7 +926,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -955,7 +956,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
))).toBe(false);
|
||||
// The design-system picker is now a persistent control in the row below the
|
||||
// composer (next to the working-directory picker), available for every
|
||||
@@ -985,11 +986,11 @@ describe('HomeView prompt handoff', () => {
|
||||
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
));
|
||||
const protoApplyInputs = JSON.parse(String((applyCall?.[1] as RequestInit).body)).inputs;
|
||||
expect(protoApplyInputs).toMatchObject({
|
||||
@@ -1025,7 +1026,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')) {
|
||||
return new Response(JSON.stringify(DOCUMENT_NEW_GENERATION_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1059,11 +1060,11 @@ describe('HomeView prompt handoff', () => {
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-new-generation/apply',
|
||||
'/api/plugins/od-new-generation/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
|
||||
));
|
||||
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
|
||||
inputs: {
|
||||
@@ -1089,7 +1090,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1130,11 +1131,11 @@ describe('HomeView prompt handoff', () => {
|
||||
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
));
|
||||
const protoApplyInputs = JSON.parse(String((applyCall?.[1] as RequestInit).body)).inputs;
|
||||
expect(protoApplyInputs).toMatchObject({ designSystem: 'No design system' });
|
||||
@@ -1154,7 +1155,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1218,7 +1219,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1258,7 +1259,7 @@ describe('HomeView prompt handoff', () => {
|
||||
);
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
))).toBe(false);
|
||||
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
|
||||
// The design-system picker is now the persistent control below the composer.
|
||||
@@ -1281,11 +1282,11 @@ describe('HomeView prompt handoff', () => {
|
||||
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
));
|
||||
// The preset card seeds the prompt as plain text while preserving the
|
||||
// chip's structured inputs (artifactKind / fidelity / audience /
|
||||
@@ -1322,7 +1323,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1366,7 +1367,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(homeHeroPromptText()).toBe('Create a live Notion dashboard artifact.');
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/apply')
|
||||
typeof url === 'string' && url.includes('/apply-local')
|
||||
))).toBe(false);
|
||||
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
|
||||
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
|
||||
@@ -1378,7 +1379,7 @@ describe('HomeView prompt handoff', () => {
|
||||
// reference), while the live-artifact chip's project kind + metadata are
|
||||
// carried forward. Submit resolves the snapshot for the preset plugin.
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/image-template-notion-team-dashboard-live-artifact/apply',
|
||||
'/api/plugins/image-template-notion-team-dashboard-live-artifact/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -1402,7 +1403,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')) {
|
||||
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1430,17 +1431,17 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
|
||||
))).toBe(false);
|
||||
await setPromptAndSettle('Build a refreshable Stripe revenue dashboard.');
|
||||
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-live-artifact/apply',
|
||||
'/api/plugins/example-live-artifact/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
|
||||
));
|
||||
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
|
||||
inputs: {},
|
||||
@@ -1470,7 +1471,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')) {
|
||||
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1525,7 +1526,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1554,7 +1555,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
))).toBe(false);
|
||||
expect(homeHeroPromptText()).toBe('Keep my current brief');
|
||||
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
|
||||
@@ -1568,13 +1569,13 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')) {
|
||||
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1605,7 +1606,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-plugin-presets').textContent).toContain('Simple Deck');
|
||||
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')
|
||||
))).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(homeHeroPromptText()).toBe(
|
||||
@@ -1620,7 +1621,7 @@ describe('HomeView prompt handoff', () => {
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
|
||||
expect(fetchMock.mock.calls.some(([url]) => (
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
||||
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
|
||||
))).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(homeHeroPromptText()).toBe(
|
||||
@@ -1637,7 +1638,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1689,7 +1690,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
});
|
||||
@@ -1707,7 +1708,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1746,7 +1747,7 @@ describe('HomeView prompt handoff', () => {
|
||||
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
|
||||
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
|
||||
@@ -1778,7 +1779,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1819,7 +1820,7 @@ describe('HomeView prompt handoff', () => {
|
||||
const appended = `Keep my current brief\n\n${query}`;
|
||||
await waitFor(() => expect(homeHeroPromptText()).toBe(appended));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
|
||||
@@ -1851,7 +1852,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
|
||||
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1890,7 +1891,7 @@ describe('HomeView prompt handoff', () => {
|
||||
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
|
||||
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-web-prototype/apply',
|
||||
'/api/plugins/example-web-prototype/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
|
||||
@@ -1921,7 +1922,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-meta-landing/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/api/plugins/example-meta-landing/apply-local')) {
|
||||
return new Response(JSON.stringify(META_INSTRUCTION_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1959,7 +1960,7 @@ describe('HomeView prompt handoff', () => {
|
||||
expect(homeHeroPromptText()).not.toContain('verbatim');
|
||||
expect(homeHeroPromptText()).not.toContain('example.html');
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/example-meta-landing/apply',
|
||||
'/api/plugins/example-meta-landing/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
});
|
||||
@@ -1972,7 +1973,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1996,7 +1997,7 @@ describe('HomeView prompt handoff', () => {
|
||||
await clearActiveTypeChip();
|
||||
await clickHomeShortcut('create-plugin');
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-plugin-authoring/apply',
|
||||
'/api/plugins/od-plugin-authoring/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => {
|
||||
@@ -2028,7 +2029,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -2052,7 +2053,7 @@ describe('HomeView prompt handoff', () => {
|
||||
await clearActiveTypeChip();
|
||||
await clickHomeShortcut('create-plugin');
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/od-plugin-authoring/apply',
|
||||
'/api/plugins/od-plugin-authoring/apply-local',
|
||||
expect.anything(),
|
||||
));
|
||||
|
||||
@@ -2087,7 +2088,7 @@ describe('HomeView prompt handoff', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
if (typeof url === 'string' && url.includes('/apply-local')) {
|
||||
return applyResponse;
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Asserts that:
|
||||
// - The rail fetches GET /api/plugins on mount and renders one card per row.
|
||||
// - Clicking a card POSTs to /api/plugins/:id/apply and forwards the
|
||||
// - Clicking a card POSTs to /api/plugins/:id/apply-local and forwards the
|
||||
// ApplyResult to onApplied.
|
||||
// - The rail filters by taskKind / mode when supplied.
|
||||
|
||||
@@ -130,6 +130,13 @@ describe('InlinePluginsRail', () => {
|
||||
const card = await waitFor(() => screen.getByTitle('A fixture'));
|
||||
fireEvent.click(card);
|
||||
await waitFor(() => expect(onApplied).toHaveBeenCalled());
|
||||
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
||||
url === '/api/plugins/sample-plugin/apply-local'
|
||||
));
|
||||
expect(applyCall).toBeTruthy();
|
||||
expect(JSON.parse(String(applyCall?.[1]?.body))).toMatchObject({
|
||||
source: '/tmp/sample',
|
||||
});
|
||||
const [record, result] = onApplied.mock.calls[0]!;
|
||||
expect(record.id).toBe('sample-plugin');
|
||||
expect(result.appliedPlugin.snapshotId).toBe('snap-1');
|
||||
|
||||
@@ -104,7 +104,10 @@ async function renderDetailAndUse() {
|
||||
const useButton = await screen.findByTestId('plugin-detail-use');
|
||||
fireEvent.click(useButton);
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(applyPlugin)).toHaveBeenCalledWith(PLUGIN.id, expect.anything());
|
||||
expect(vi.mocked(applyPlugin)).toHaveBeenCalledWith(
|
||||
PLUGIN.id,
|
||||
expect.objectContaining({ pluginSource: PLUGIN.source }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,13 @@ describe('PluginsSection', () => {
|
||||
// rendered: inputs fall back to schema defaults instead of being edited inline.
|
||||
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
|
||||
expect(onApplied).toHaveBeenCalled();
|
||||
const localApply = fetchMock.mock.calls.find(([url]) => (
|
||||
url === '/api/plugins/sample-plugin/apply-local'
|
||||
));
|
||||
expect(localApply).toBeTruthy();
|
||||
expect(JSON.parse(String(localApply?.[1]?.body))).toMatchObject({
|
||||
source: '/tmp/sample',
|
||||
});
|
||||
const [brief, applied] = onApplied.mock.calls[0]!;
|
||||
// `topic` has no schema default, so it stays un-substituted in the brief.
|
||||
expect(brief).toContain('{{topic}}');
|
||||
|
||||
@@ -82,6 +82,40 @@ function teamWorkspaceContext(
|
||||
};
|
||||
}
|
||||
|
||||
describe('createProject', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('preserves the exact local plugin source in the create payload', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
|
||||
project: {
|
||||
id: 'project-local-plugin',
|
||||
name: 'Local plugin project',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
conversationId: 'conversation-1',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await createProject({
|
||||
name: 'Local plugin project',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
pluginId: 'shared-plugin-id',
|
||||
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
});
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
||||
pluginId: 'shared-plugin-id',
|
||||
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConversation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -294,6 +328,78 @@ describe('applyPlugin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the selected installed source for local apply without Workspace headers', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
|
||||
JSON.stringify({ ok: true }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await applyPlugin('shared-plugin-id', {
|
||||
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
} as Parameters<typeof applyPlugin>[1]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/api/plugins/shared-plugin-id/apply-local');
|
||||
expect(new Headers(init?.headers).has('x-od-workspace-id')).toBe(false);
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
source: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
inputs: {},
|
||||
grantCaps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the legacy apply route only when an old daemon lacks local apply', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
||||
if (String(url).endsWith('/apply-local')) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await applyPlugin('bundled-plugin', { pluginSource: 'bundled:bundled-plugin' });
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/plugins/bundled-plugin/apply-local',
|
||||
'/api/plugins/bundled-plugin/apply',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fall back when a new daemon rejects the exact local source', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
|
||||
JSON.stringify({ error: 'plugin not found' }),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-od-plugin-apply-local': '1',
|
||||
},
|
||||
},
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(applyPlugin('shared-plugin-id', {
|
||||
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
})).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let an old daemon resolve a Team source headerless', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response('not found', { status: 404 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(applyPlugin('shared-plugin-id', {
|
||||
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
|
||||
})).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/plugins/shared-plugin-id/apply-local');
|
||||
});
|
||||
|
||||
it('scopes same-id plugin apply requests to the exact A/B workspace', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
|
||||
@@ -317,6 +317,8 @@ export interface CreateProjectRequest {
|
||||
pendingPrompt?: string;
|
||||
metadata?: ProjectMetadata;
|
||||
pluginId?: string;
|
||||
/** Exact source of the already-local plugin record selected by the UI. */
|
||||
pluginSource?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
/** Session mode for the default conversation seeded with the project. */
|
||||
|
||||
Reference in New Issue
Block a user