499 lines
21 KiB
TypeScript
499 lines
21 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
|
import {
|
|
CodingCapabilityRegistryImpl,
|
|
} from '../../electron/coding-plugins/registry';
|
|
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
|
import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service';
|
|
import {
|
|
createDataServiceOperations,
|
|
DataServiceCloudClient,
|
|
type DataServiceOperations,
|
|
} from '../../electron/services/data-service-client';
|
|
import {
|
|
DATA_SERVICE_PLUGIN_DEFINITION,
|
|
type CodingPluginDefinition,
|
|
} from '../../shared/coding-plugins';
|
|
import { productToolDetailsOfResult } from '../../shared/coding-conversation-product-tool-protocol';
|
|
import type { DataServiceErrorContext, DataServiceHostResult } from '../../shared/data-service';
|
|
import type {
|
|
EffectivePluginResolver,
|
|
EffectivePluginSnapshot,
|
|
} from '../../electron/coding-plugins/effective-resolver';
|
|
|
|
const policy: PluginPolicyClientState = {
|
|
status: 'current',
|
|
revision: 7,
|
|
lastVerifiedAt: 1,
|
|
catalog: {
|
|
schema_version: 1,
|
|
catalog_version: 'catalog-1',
|
|
pricing_version: null,
|
|
plugins: [{
|
|
plugin_id: 'makelore.data-service',
|
|
supported_contract_versions: [1],
|
|
status: 'active',
|
|
capabilities: [
|
|
{
|
|
capability_id: 'data-service.control',
|
|
operations: ['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']
|
|
.map((operation) => ({
|
|
operation,
|
|
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
|
})),
|
|
},
|
|
{
|
|
capability_id: 'data-service.documents',
|
|
operations: ['get_document', 'list_documents', 'put_document', 'delete_document']
|
|
.map((operation) => ({
|
|
operation,
|
|
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
|
})),
|
|
},
|
|
{
|
|
capability_id: 'data-service.preview',
|
|
operations: ['get', 'list', 'put', 'delete'].map((operation) => ({
|
|
operation,
|
|
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
|
})),
|
|
},
|
|
],
|
|
}],
|
|
},
|
|
};
|
|
|
|
const context = {
|
|
conversationId: 'conversation-a',
|
|
runId: 'run-a',
|
|
resourceId: 'resource-a',
|
|
projectId: 'local-project-a',
|
|
projectPath: 'C:\\projects\\demo',
|
|
skillIds: ['data-service'],
|
|
};
|
|
|
|
function adapter(): CodingPluginAdapter {
|
|
return {
|
|
pluginId: 'makelore.data-service',
|
|
async inspect() { return { status: 'ready' }; },
|
|
async invoke() {
|
|
return {
|
|
success: true,
|
|
status: 200,
|
|
code: null,
|
|
error: null,
|
|
retryable: false,
|
|
payload_schema: 'data-service.v1',
|
|
data: { id: 'one', data: {}, revision: 1 },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function registry(overrides: Partial<ConstructorParameters<typeof CodingCapabilityRegistryImpl>[0]> = {}) {
|
|
return new CodingCapabilityRegistryImpl({
|
|
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
|
getEnabledPluginIds: async () => ['makelore.data-service'],
|
|
adapters: [adapter()],
|
|
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe('CodingCapabilityRegistry', () => {
|
|
it('joins package, selection, grant, role, and server policy for parent resources', async () => {
|
|
const resources = await registry().resolveWorkerResources({
|
|
projectPath: context.projectPath,
|
|
assignedSkillIds: context.skillIds,
|
|
role: 'parent',
|
|
});
|
|
expect(resources.catalogRevision).toBe(7);
|
|
expect(resources.pluginIds).toEqual(['makelore.data-service']);
|
|
expect(resources.effectiveSkillIds).toEqual(['data-service']);
|
|
expect(resources.tools.map(({ name }) => name)).toHaveLength(10);
|
|
});
|
|
|
|
it('never exposes plugin resources to child workers or before the first catalog', async () => {
|
|
const child = await registry().resolveWorkerResources({
|
|
projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'child',
|
|
});
|
|
expect(child.pluginIds).toEqual([]);
|
|
expect(child.tools).toEqual([]);
|
|
const unavailable = await registry({
|
|
policyClient: {
|
|
getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }),
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}).resolveWorkerResources({
|
|
projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'parent',
|
|
});
|
|
expect(unavailable.tools).toEqual([]);
|
|
});
|
|
|
|
it('does not wait for policy refresh when child or parent core-only resources cannot consume plugins', async () => {
|
|
const refresh = vi.fn(() => new Promise<void>(() => {}));
|
|
const policyClient = { getState: () => policy, refresh };
|
|
const child = await registry({ policyClient }).resolveWorkerResources({
|
|
projectPath: context.projectPath,
|
|
assignedSkillIds: context.skillIds,
|
|
role: 'child',
|
|
});
|
|
const coreOnly = await registry({ policyClient }).resolveWorkerResources({
|
|
projectPath: context.projectPath,
|
|
assignedSkillIds: ['grilling'],
|
|
role: 'parent',
|
|
});
|
|
|
|
expect(child.tools).toEqual([]);
|
|
expect(coreOnly.effectiveSkillIds).toEqual(['grilling']);
|
|
expect(refresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes policy before resolving an assigned server-backed parent plugin', async () => {
|
|
const refresh = vi.fn().mockResolvedValue(undefined);
|
|
const getEnabledPluginIds = vi.fn().mockResolvedValue(['makelore.data-service']);
|
|
await registry({
|
|
policyClient: { getState: () => policy, refresh },
|
|
getEnabledPluginIds,
|
|
}).resolveWorkerResources({
|
|
projectPath: context.projectPath,
|
|
assignedSkillIds: context.skillIds,
|
|
role: 'parent',
|
|
});
|
|
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
expect(refresh.mock.invocationCallOrder[0]).toBeLessThan(getEnabledPluginIds.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('revalidates invocation, derives the stable Pi request id, and preserves domain faults', async () => {
|
|
const result = await registry().invoke({
|
|
toolName: 'data_service_get_document',
|
|
context,
|
|
workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds,
|
|
value: { collection: 'todos', document_id: 'one' },
|
|
});
|
|
expect(result.details).toMatchObject({
|
|
schema: 'makelore-capability.v1',
|
|
plugin_id: 'makelore.data-service',
|
|
capability_id: 'data-service.documents',
|
|
operation: 'get_document',
|
|
request_id: 'pi:run-a:resource-a',
|
|
billing: { mode: 'included', status: 'included' },
|
|
payload_schema: 'data-service.v1',
|
|
});
|
|
const invalid = await registry().invoke({
|
|
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds, value: { collection: 'todos', projectPath: 'forbidden' },
|
|
});
|
|
expect(invalid.details).toMatchObject({ success: false, code: 'plugin_input_invalid' });
|
|
const disabled = await registry({ getEnabledPluginIds: async () => [] }).invoke({
|
|
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds, value: { collection: 'todos', document_id: 'one' },
|
|
});
|
|
expect(disabled.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
|
|
const invalidIdentity = await registry().invoke({
|
|
toolName: 'data_service_get_document',
|
|
context: { ...context, runId: '' },
|
|
workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds,
|
|
value: { collection: 'todos', document_id: 'one' },
|
|
});
|
|
expect(invalidIdentity.details).toMatchObject({
|
|
success: false, code: 'plugin_input_invalid', request_id: 'invalid-request-id',
|
|
});
|
|
expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i);
|
|
});
|
|
|
|
it('dispatches a Marketplace-hosted tool from the frozen effective snapshot without a static tool list', async () => {
|
|
const hostedDefinition: CodingPluginDefinition = {
|
|
id: 'makelore.game-resource', version: '1.0.0', contractVersion: 1,
|
|
displayName: 'Game Resource', description: 'Hosted game resources',
|
|
runtimeKind: 'platform_hosted', acquisitionMode: 'user_acquired',
|
|
releaseId: 'release-game-1',
|
|
provenance: { source: 'marketplace', packageRoot: 'C:/packages/game-resource' },
|
|
scope: 'project', adapterId: '', requiresBackend: true,
|
|
skills: [{
|
|
id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md',
|
|
grants: ['game-resource.generate'],
|
|
}],
|
|
tools: [{
|
|
name: 'game_resource_generate', label: 'Generate', description: 'Generate an asset',
|
|
capabilityId: 'game-resource.generate', operation: 'generate', roles: ['parent'],
|
|
mutation: 'write', projectWriteLease: false,
|
|
permissions: ['hosted.game-resource.generate'], executionMode: 'job',
|
|
inputSchema: {
|
|
type: 'object', additionalProperties: false, required: ['kind'],
|
|
properties: { kind: { type: 'string' } },
|
|
},
|
|
}],
|
|
operations: [{
|
|
capabilityId: 'game-resource.generate', operation: 'generate', toolName: 'game_resource_generate',
|
|
}],
|
|
surfaces: {},
|
|
};
|
|
const hostedPolicy: PluginPolicyClientState = {
|
|
status: 'current', revision: 9, lastVerifiedAt: 1,
|
|
catalog: {
|
|
schema_version: 1, catalog_version: 'hosted-1', pricing_version: 'pricing-1',
|
|
plugins: [{
|
|
plugin_id: hostedDefinition.id, supported_contract_versions: [1], status: 'active',
|
|
capabilities: [{
|
|
capability_id: 'game-resource.generate',
|
|
operations: [{
|
|
operation: 'generate',
|
|
billing: {
|
|
mode: 'platform_metered', entitlement_scope: 'plugin_usage', notice: 'Metered',
|
|
unit_name: 'generation', unit_size: 1, rate_points: '1.00',
|
|
minimum_charge_points: '1.00', rounding_mode: 'ceil',
|
|
},
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
};
|
|
const frozenSnapshot: EffectivePluginSnapshot = {
|
|
accountSessionId: 'account-a\u00001', projectId: context.projectId,
|
|
pluginReleaseIds: ['release-game-1'], effectiveSkillIds: ['game-resource'],
|
|
skillEntries: [{
|
|
id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md',
|
|
packageRoot: 'C:/packages/game-resource',
|
|
}],
|
|
toolDefinitions: hostedDefinition.tools,
|
|
runtimePolicies: [{
|
|
pluginId: hostedDefinition.id, pluginVersion: hostedDefinition.version,
|
|
releaseId: hostedDefinition.releaseId, contractVersion: 1,
|
|
capabilityId: 'game-resource.generate', operation: 'generate',
|
|
billing: hostedPolicy.catalog!.plugins[0]!.capabilities[0]!.operations[0]!.billing,
|
|
}],
|
|
unavailableReasons: [],
|
|
};
|
|
const resolve = vi.fn(async () => frozenSnapshot);
|
|
const effectiveResolver = {
|
|
resolve,
|
|
resolveForInvocation: resolve,
|
|
getSkillSources: vi.fn(async () => []),
|
|
getPolicyState: vi.fn(() => hostedPolicy),
|
|
getInstalledDefinition: vi.fn(async () => hostedDefinition),
|
|
} as unknown as EffectivePluginResolver;
|
|
const invoke = vi.fn(async () => ({
|
|
success: true as const, status: 202, code: null, error: null, retryable: false as const,
|
|
payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' },
|
|
billing: {
|
|
mode: 'platform_metered' as const, status: 'dispatched' as const,
|
|
reserved_points: '2.00', usage_amount: 1, unit: 'generation',
|
|
},
|
|
}));
|
|
const capabilityRegistry = registry({
|
|
definitions: [], effectiveResolver,
|
|
adapters: [{ pluginId: hostedDefinition.id, inspect: async () => ({ status: 'ready' }), invoke }],
|
|
policyClient: { getState: () => hostedPolicy, refresh: vi.fn() },
|
|
getEnabledPluginIds: async () => [hostedDefinition.id],
|
|
});
|
|
|
|
const result = await capabilityRegistry.invoke({
|
|
toolName: 'game_resource_generate',
|
|
context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot },
|
|
workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' },
|
|
});
|
|
|
|
expect(effectiveResolver.getInstalledDefinition).toHaveBeenCalledWith(
|
|
hostedDefinition.id,
|
|
hostedDefinition.releaseId,
|
|
);
|
|
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
|
|
requestId: 'pi:run-a:resource-a', workerRole: 'parent', effectiveSkillIds: ['game-resource'],
|
|
pluginReleaseId: hostedDefinition.releaseId,
|
|
}), hostedDefinition.tools[0], { kind: 'pixel' });
|
|
expect(result.details).toMatchObject({
|
|
schema: 'makelore-capability.v1', plugin_id: hostedDefinition.id,
|
|
capability_id: 'game-resource.generate', operation: 'generate',
|
|
request_id: 'pi:run-a:resource-a', success: true, status: 202,
|
|
billing: { mode: 'platform_metered', status: 'dispatched', reserved_points: '2.00' },
|
|
payload_schema: 'game-resource.v1',
|
|
});
|
|
|
|
resolve.mockResolvedValue({
|
|
...frozenSnapshot,
|
|
pluginReleaseIds: ['release-game-2'],
|
|
runtimePolicies: [{
|
|
...frozenSnapshot.runtimePolicies[0]!,
|
|
pluginVersion: '2.0.0',
|
|
releaseId: 'release-game-2',
|
|
}],
|
|
});
|
|
const stale = await capabilityRegistry.invoke({
|
|
toolName: 'game_resource_generate',
|
|
context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot },
|
|
workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' },
|
|
});
|
|
expect(stale.details).toMatchObject({ success: false, code: 'plugin_runtime_stale' });
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refuses a new plugin action from an old worker after lifecycle invalidation', async () => {
|
|
const frozenSnapshot: EffectivePluginSnapshot = {
|
|
accountSessionId: 'account-a\u00001',
|
|
projectId: context.projectId,
|
|
pluginReleaseIds: [],
|
|
effectiveSkillIds: ['data-service'],
|
|
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
|
toolDefinitions: DATA_SERVICE_PLUGIN_DEFINITION.tools,
|
|
runtimePolicies: [],
|
|
unavailableReasons: [],
|
|
};
|
|
const currentSnapshot: EffectivePluginSnapshot = {
|
|
...frozenSnapshot,
|
|
effectiveSkillIds: [],
|
|
skillEntries: [],
|
|
toolDefinitions: [],
|
|
unavailableReasons: [{
|
|
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
code: 'project_disabled',
|
|
message: 'Plugin is not enabled for this project',
|
|
}],
|
|
};
|
|
const effectiveResolver = {
|
|
resolve: vi.fn(async () => currentSnapshot),
|
|
resolveForInvocation: vi.fn(async () => currentSnapshot),
|
|
getSkillSources: vi.fn(async () => []),
|
|
getPolicyState: vi.fn(() => policy),
|
|
} as unknown as EffectivePluginResolver;
|
|
const result = await registry({ effectiveResolver }).invoke({
|
|
toolName: 'data_service_get_document',
|
|
context: { ...context, effectiveSnapshot: frozenSnapshot },
|
|
workerRole: 'parent',
|
|
effectiveSkillIds: frozenSnapshot.effectiveSkillIds,
|
|
value: { collection: 'todos', document_id: 'one' },
|
|
});
|
|
|
|
expect(result.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
|
|
expect(effectiveResolver.resolveForInvocation).toHaveBeenCalledWith(expect.objectContaining({
|
|
projectId: context.projectId,
|
|
projectPath: context.projectPath,
|
|
role: 'parent',
|
|
}));
|
|
});
|
|
|
|
it('keeps the replay identity and parses bounded Data Service faults for all ten tools', async () => {
|
|
const fault = (
|
|
code: string,
|
|
context?: DataServiceErrorContext,
|
|
retryAfter?: number,
|
|
): DataServiceHostResult<never> => ({
|
|
success: false,
|
|
status: code === 'rate_limited' ? 429 : code === 'document_too_large' ? 413 : 409,
|
|
code,
|
|
error: `Representative ${code} fault`,
|
|
retryable: code === 'rate_limited',
|
|
...(context ? { context } : {}),
|
|
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }),
|
|
data: null,
|
|
});
|
|
const operations: DataServiceOperations = {
|
|
configure: vi.fn(async () => fault('quota_exceeded', {
|
|
resource: 'collections', limit: 20, current: 20, attempted: 21,
|
|
})),
|
|
inspect: vi.fn(async () => fault('instance_not_found')),
|
|
listProjects: vi.fn(async () => fault('quota_exceeded', {
|
|
resource: 'instances', limit: 20, current: 20, attempted: 21,
|
|
})),
|
|
getDocument: vi.fn(async () => fault('document_not_found')),
|
|
listDocuments: vi.fn(async () => fault('rate_limited', undefined, 17)),
|
|
putDocument: vi.fn(async () => fault('document_too_large', {
|
|
resource: 'bytes', actual: 98_305, limit: 98_304,
|
|
})),
|
|
deleteDocument: vi.fn(async () => fault('revision_conflict', { current_revision: 7 })),
|
|
removeCollection: vi.fn(async () => fault('collection_not_found')),
|
|
reset: vi.fn(async () => fault('quota_exceeded', {
|
|
resource: 'documents', limit: 5_000, current: 5_000, attempted: 5_001,
|
|
})),
|
|
removeProject: vi.fn(async () => fault('instance_not_found')),
|
|
};
|
|
const capabilityRegistry = registry({
|
|
adapters: [createDataServicePluginAdapter(operations)],
|
|
});
|
|
const inputs: Readonly<Record<string, unknown>> = {
|
|
data_service_configure: { collections: ['todos'] },
|
|
data_service_inspect: {},
|
|
data_service_list_projects: {},
|
|
data_service_get_document: { collection: 'todos', document_id: 'one' },
|
|
data_service_list_documents: { collection: 'todos' },
|
|
data_service_put_document: { collection: 'todos', document_id: 'one', data: {} },
|
|
data_service_delete_document: { collection: 'todos', document_id: 'one', confirmed: true },
|
|
data_service_remove_collection: { collection: 'todos', confirmed: true },
|
|
data_service_reset: { confirmed: true },
|
|
data_service_remove_project: { confirmed: true },
|
|
};
|
|
|
|
for (const tool of DATA_SERVICE_PLUGIN_DEFINITION.tools) {
|
|
const invoke = () => capabilityRegistry.invoke({
|
|
toolName: tool.name,
|
|
context,
|
|
workerRole: 'parent' as const,
|
|
effectiveSkillIds: context.skillIds,
|
|
value: inputs[tool.name],
|
|
});
|
|
const first = await invoke();
|
|
const replay = await invoke();
|
|
expect(first.details.request_id).toBe('pi:run-a:resource-a');
|
|
expect(replay.details.request_id).toBe(first.details.request_id);
|
|
expect(productToolDetailsOfResult(first)).toEqual(first.details);
|
|
}
|
|
|
|
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
|
toolName: 'data_service_put_document', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds, value: inputs.data_service_put_document,
|
|
}))).toMatchObject({ context: { resource: 'bytes', actual: 98_305, limit: 98_304 } });
|
|
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
|
toolName: 'data_service_delete_document', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds, value: inputs.data_service_delete_document,
|
|
}))).toMatchObject({ context: { current_revision: 7 } });
|
|
expect(productToolDetailsOfResult(await capabilityRegistry.invoke({
|
|
toolName: 'data_service_list_documents', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds, value: inputs.data_service_list_documents,
|
|
}))).toMatchObject({ retry_after_seconds: 17 });
|
|
});
|
|
|
|
it('keeps the Pi identity through the one authoritative 401 refresh', async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
id: 'one',
|
|
data: { done: false },
|
|
revision: 7,
|
|
created_at: '2026-08-27T00:00:00Z',
|
|
updated_at: '2026-08-27T00:00:00Z',
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
|
|
options?.forceRefresh ? 'refreshed-token' : 'stale-token'
|
|
));
|
|
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
|
|
const operations = createDataServiceOperations({
|
|
client,
|
|
projects: {
|
|
requireActiveRealProjectWithIdentity: vi.fn(async () => ({
|
|
projectId: '11111111-1111-4111-8111-111111111111',
|
|
})) as never,
|
|
},
|
|
});
|
|
const result = await registry({
|
|
adapters: [createDataServicePluginAdapter(operations)],
|
|
}).invoke({
|
|
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
|
effectiveSkillIds: context.skillIds,
|
|
value: { collection: 'todos', document_id: 'one' },
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
success: true,
|
|
request_id: 'pi:run-a:resource-a',
|
|
data: { id: 'one', revision: 7 },
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl });
|
|
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
|
});
|
|
});
|