Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions src/abilities/__tests__/registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,167 @@ describe( 'registry — sd-ai-86a regression', () => {
expect( registerAbility ).toHaveBeenCalledTimes( 1 );
} );

test( 'registers the category once between webpack module instances', async () => {
const registerAbilityCategory = jest
.fn()
.mockResolvedValue( undefined );
global.wp = {
abilities: {
registerAbility: jest.fn().mockResolvedValue( undefined ),
registerAbilityCategory,
},
};
const firstBundle = loadRegistry();
const secondBundle = loadRegistry();

await Promise.all( [
firstBundle.registerCategory(),
secondBundle.registerCategory(),
] );

expect( registerAbilityCategory ).toHaveBeenCalledTimes( 1 );
} );

test( 'keeps local abilities available after a malformed provider breaks core registration', async () => {
const providerError = new Error(
'Ability "wpforms/list-forms" references non-existent category "wpforms-forms".'
);
const registerAbilityCategory = jest
.fn()
.mockRejectedValue( providerError );
const registerAbility = jest.fn().mockResolvedValue( undefined );
const warning = jest
.spyOn( console, 'warn' )
.mockImplementation( () => {} );
global.wp = {
abilities: {
registerAbility,
registerAbilityCategory,
},
};
const firstBundle = loadRegistry();
const secondBundle = loadRegistry();
const callback = jest.fn().mockResolvedValue( { available: true } );

await firstBundle.registerCategory();
await firstBundle.registerClientAbility( {
name: 'sd-ai-agent-js/first-local-fallback',
label: 'First local fallback',
description: 'Works when core registration fails',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
annotations: { readonly: true },
callback,
} );
await secondBundle.registerCategory();
await secondBundle.registerClientAbility( {
name: 'sd-ai-agent-js/second-local-fallback',
label: 'Second local fallback',
description: 'Does not retry the broken core store',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
annotations: { readonly: true },
callback: jest.fn(),
} );

expect( registerAbilityCategory ).toHaveBeenCalledTimes( 1 );
expect( registerAbility ).not.toHaveBeenCalled();
expect( warning ).toHaveBeenCalledTimes( 1 );
await expect(
secondBundle.executeClientAbility(
'sd-ai-agent-js/first-local-fallback',
{}
)
).resolves.toEqual( { available: true } );
await expect( secondBundle.snapshotDescriptors() ).resolves.toEqual(
expect.arrayContaining( [
expect.objectContaining( {
name: 'sd-ai-agent-js/first-local-fallback',
} ),
expect.objectContaining( {
name: 'sd-ai-agent-js/second-local-fallback',
} ),
] )
);
warning.mockRestore();
} );

test( 'continues after an already registered category', async () => {
const duplicateCategoryError = new Error(
'Ability category "sd-ai-agent-js" is already registered.'
);
const registerAbility = jest.fn().mockResolvedValue( undefined );
const warning = jest
.spyOn( console, 'warn' )
.mockImplementation( () => {} );
global.wp = {
abilities: {
registerAbility,
registerAbilityCategory: jest
.fn()
.mockRejectedValue( duplicateCategoryError ),
},
};
const { registerCategory, registerClientAbility } = loadRegistry();

await registerCategory();
await registerClientAbility( {
name: 'sd-ai-agent-js/after-duplicate-category',
label: 'After duplicate category',
description: 'Registers after an idempotent category response',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
annotations: {},
callback: jest.fn(),
} );

expect( registerAbility ).toHaveBeenCalledTimes( 1 );
expect( warning ).not.toHaveBeenCalled();
warning.mockRestore();
} );

test( 'continues registering after an individual ability fails', async () => {
const registerAbility = jest
.fn()
.mockRejectedValueOnce( new Error( 'Ability validation failed.' ) )
.mockResolvedValueOnce( undefined );
const warning = jest
.spyOn( console, 'warn' )
.mockImplementation( () => {} );
global.wp = {
abilities: {
registerAbility,
registerAbilityCategory: jest
.fn()
.mockResolvedValue( undefined ),
},
};
const { registerClientAbility } = loadRegistry();

await registerClientAbility( {
name: 'sd-ai-agent-js/invalid-ability',
label: 'Invalid ability',
description: 'Fails independently',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
annotations: {},
callback: jest.fn(),
} );
await registerClientAbility( {
name: 'sd-ai-agent-js/valid-ability',
label: 'Valid ability',
description: 'Still reaches the store',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
annotations: {},
callback: jest.fn(),
} );

expect( registerAbility ).toHaveBeenCalledTimes( 2 );
expect( warning ).not.toHaveBeenCalled();
warning.mockRestore();
} );

test( 'registerClientAbility stores callback locally even when wp.abilities is undefined', async () => {
// Simulate a page where @wordpress/abilities never loaded.
delete global.wp;
Expand Down
156 changes: 121 additions & 35 deletions src/abilities/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ const CATEGORY_DESCRIPTION = __(
const WIN_REGISTRY_KEY = '__sdAiAgentClientAbilityRegistry';

/**
* Single category-registration Promise for this module instance. Cross-bundle
* category registration is already coordinated by index.js.
* Single category-registration Promise for this module instance.
*
* The page registry also stores this promise, which makes category
* registration safe when separate webpack bundles evaluate their own copy of
* this module before (or independently from) the index.js coordinator.
*
* @type {Promise<void>|null}
*/
Expand All @@ -77,18 +80,80 @@ function getPageRegistry() {
const page = window;

if ( page[ WIN_REGISTRY_KEY ] ) {
return page[ WIN_REGISTRY_KEY ];
const registry = page[ WIN_REGISTRY_KEY ];
// Preserve state published by an earlier compatible bundle revision while
// adding bootstrap state introduced by a newer one.
registry.categoryRegistrationPromise ??= null;
registry.coreRegistrationFailed ??= false;
registry.coreRegistrationDiagnosticEmitted ??= false;

return registry;
}

page[ WIN_REGISTRY_KEY ] = {
n: new Set(),
c: new Map(),
d: new Map(),
categoryRegistrationPromise: null,
coreRegistrationFailed: false,
coreRegistrationDiagnosticEmitted: false,
};

return page[ WIN_REGISTRY_KEY ];
}

/**
* Record one core-store bootstrap failure without disabling the local
* client-ability fallback. A malformed third-party ability can cause core's
* initial ability hydration to reject; retrying every local registration
* against that same broken store only repeats the provider error.
*
* @param {Object} registry Shared page registry.
* @param {*} error Core registration failure.
* @return {void}
*/
function recordCoreRegistrationFailure( registry, error ) {
registry.coreRegistrationFailed = true;

if ( registry.coreRegistrationDiagnosticEmitted ) {
return;
}

registry.coreRegistrationDiagnosticEmitted = true;
// eslint-disable-next-line no-console
console.warn(
'[sd-ai-agent] WordPress abilities registration failed; local client abilities remain available.',
error
);
}

/**
* Detect the category-hydration failure emitted when another provider leaves
* the shared core abilities store in an unusable state.
*
* @param {*} error Registration error.
* @return {boolean} True when core category hydration failed.
*/
function isCoreCategoryHydrationFailure( error ) {
const message =
error instanceof Error ? error.message : String( error || '' );

return /references non-existent category/i.test( message );
}

/**
* Detect an idempotent category-registration result from the core store.
*
* @param {*} error Registration error.
* @return {boolean} True when the category was already registered.
*/
function isDuplicateCategoryError( error ) {
const message =
error instanceof Error ? error.message : String( error || '' );

return /category.+already registered/i.test( message );
}

/**
* Detect whether the WP 7.0 abilities API is available on this page.
*
Expand Down Expand Up @@ -148,41 +213,56 @@ async function waitForAbilitiesApi( maxWaitMs = 30_000 ) {
* @return {Promise<void>}
*/
export async function registerCategory() {
if ( categoryRegistrationPromise ) {
const registry = getPageRegistry();
if ( registry.categoryRegistrationPromise ) {
categoryRegistrationPromise = registry.categoryRegistrationPromise;
return categoryRegistrationPromise;
}

if ( registry.coreRegistrationFailed ) {
return;
}

// Set the promise immediately — before any awaits — to prevent concurrent
// callers from racing into this function and launching duplicate registrations.
// The async body inside will wait for wp.abilities to become available.
categoryRegistrationPromise = ( async () => {
// Wait for @wordpress/core-abilities to populate wp.abilities. This
// handles the race condition where floating-widget.js (regular deferred
// script) runs before the @wordpress/core-abilities script module has
// executed. Previously we returned early with `undefined`, which left
// categoryRegistrationPromise null and silently skipped all ability
// registration with no retry path.
await waitForAbilitiesApi();

if ( ! abilitiesApiAvailable() ) {
// API never became available (e.g. not a WP 7.0+ site). Skip silently.
// Clear the module value so a later call can retry after the core
// script module becomes available.
categoryRegistrationPromise = null;
return;
}
categoryRegistrationPromise = registry.categoryRegistrationPromise =
( async () => {
// Wait for @wordpress/core-abilities to populate wp.abilities. This
// handles the race condition where floating-widget.js (regular deferred
// script) runs before the @wordpress/core-abilities script module has
// executed. Previously we returned early with `undefined`, which left
// categoryRegistrationPromise null and silently skipped all ability
// registration with no retry path.
await waitForAbilitiesApi();

try {
await wp.abilities.registerAbilityCategory( CATEGORY_SLUG, {
label: CATEGORY_LABEL,
description: CATEGORY_DESCRIPTION,
} );
} catch ( _err ) {
// Already registered by another bundle on the same page —
// safe to ignore. Both bundles will continue to register
// their abilities into the same shared category.
}
} )();
if ( ! abilitiesApiAvailable() ) {
// API never became available (e.g. not a WP 7.0+ site). Skip silently.
// Clear the module value so a later call can retry after the core
// script module becomes available.
categoryRegistrationPromise = null;
registry.categoryRegistrationPromise = null;
return;
}

try {
await wp.abilities.registerAbilityCategory( CATEGORY_SLUG, {
label: CATEGORY_LABEL,
description: CATEGORY_DESCRIPTION,
} );
} catch ( error ) {
// A second bundle or core may have registered this category first.
// Treat that idempotent result as success. Other individual category
// validation failures must not disable later ability registrations.
if ( isDuplicateCategoryError( error ) ) {
return;
}

if ( isCoreCategoryHydrationFailure( error ) ) {
recordCoreRegistrationFailure( registry, error );
}
}
} )();

return categoryRegistrationPromise;
}
Expand Down Expand Up @@ -244,6 +324,13 @@ export async function registerClientAbility( def ) {
annotations: def.annotations || {},
} );

// A malformed third-party ability can make core's shared hydration reject.
// Keep the local callback and descriptor available, but do not make each
// remaining Superdav ability restart the same failing core request.
if ( registry.coreRegistrationFailed ) {
return;
}

// The WP 7.0 store is only updated when the abilities API is present
// on this page. If it is not, the local callback above is sufficient
// to keep client-side tool execution working; snapshotDescriptors()
Expand All @@ -266,10 +353,9 @@ export async function registerClientAbility( def ) {
annotations: def.annotations || {},
},
} );
} catch ( _err ) {
// Already registered by another bundle on the same page — fine.
// We've already added it to registeredAbilityNames so we won't
// retry from this module instance.
} catch ( error ) {
// Ability registration is independent. A duplicate, validation error, or
// provider failure for this definition must not block later abilities.
}
}

Expand Down
Loading