diff --git a/.github/.codecov.yml b/.github/.codecov.yml
index b349265..83fbafc 100644
--- a/.github/.codecov.yml
+++ b/.github/.codecov.yml
@@ -25,6 +25,8 @@ ignore:
- '**/*.spec.tsx'
- '**/types.d.ts'
- 'playwright.config.ts'
+ - 'assets/src/components/icons/**'
+ - 'assets/src/js/**.js'
comment:
layout: 'reach,diff,flags,files'
diff --git a/jest.config.js b/jest.config.js
index 41dd817..1450699 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -63,6 +63,10 @@ module.exports = {
'!assets/src/**/index.{js,tsx,jsx}',
// Exclude style files
'!assets/src/**/*.{css,scss}',
+ // Exclude static SVG icon components
+ '!assets/src/components/icons/**',
+ // Exclude the js files
+ '!assets/src/js/**.js',
],
// Coverage output directory
diff --git a/playwright.config.ts b/playwright.config.ts
index c077f81..19b4c98 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -22,6 +22,12 @@ const config = defineConfig( {
...baseConfig,
testDir: './tests/e2e',
outputDir: './tests/_output/e2e',
+ webServer: {
+ command: 'npm run wp-env:test start',
+ port: 8889,
+ timeout: 120000,
+ reuseExistingServer: true,
+ },
} );
export default config;
diff --git a/tests/e2e/settings/activation.spec.ts b/tests/e2e/settings/activation.spec.ts
new file mode 100644
index 0000000..7055eb0
--- /dev/null
+++ b/tests/e2e/settings/activation.spec.ts
@@ -0,0 +1,67 @@
+/**
+ * WordPress dependencies
+ */
+import { expect, test } from '@wordpress/e2e-test-utils-playwright';
+
+test.describe( 'plugin activation', () => {
+ test( 'should activate and deactivate the plugin', async ( {
+ admin,
+ page,
+ } ) => {
+ await admin.visitAdminPage( '/plugins.php' );
+
+ // Helper to dismiss the onboarding modal if present.
+ const dismissOnboardingModal = async () => {
+ const modal = page.locator( '#oneupdate-site-selection-modal' );
+ const backdrop = page.locator(
+ 'body.oneupdate-site-selection-modal'
+ );
+
+ if ( await modal.isVisible() ) {
+ await modal.evaluate( ( el ) => {
+ el.remove();
+ } );
+ }
+
+ if ( await backdrop.isVisible() ) {
+ await backdrop.evaluate( ( el ) => {
+ el.classList.remove( 'oneupdate-site-selection-modal' );
+ } );
+ }
+ };
+
+ const pluginRow = page.locator(
+ 'tr[data-plugin="oneupdate/oneupdate.php"]'
+ );
+ await expect( pluginRow ).toBeVisible();
+
+ // Dismiss modal before interacting with plugin row.
+ await dismissOnboardingModal();
+
+ const activateLink = pluginRow.locator( 'a', { hasText: 'Activate' } );
+
+ await Promise.all( [
+ page.waitForURL( /plugins.php/ ),
+ activateLink.click(),
+ ] );
+
+ await expect(
+ pluginRow.locator( 'a', { hasText: 'Deactivate' } )
+ ).toBeVisible( { timeout: 10000 } );
+
+ // Dismiss modal again after activation.
+ await dismissOnboardingModal();
+
+ const deactivateLink = pluginRow.locator( 'a', {
+ hasText: 'Deactivate',
+ } );
+ await Promise.all( [
+ page.waitForURL( /plugins.php/ ),
+ deactivateLink.click(),
+ ] );
+
+ await expect(
+ pluginRow.locator( 'a', { hasText: 'Activate' } )
+ ).toBeVisible( { timeout: 10000 } );
+ } );
+} );
diff --git a/tests/js/OnboardingPage.test.tsx b/tests/js/OnboardingPage.test.tsx
new file mode 100644
index 0000000..feab10f
--- /dev/null
+++ b/tests/js/OnboardingPage.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+import OnboardingScreen from '@/admin/onboarding/page';
+
+const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+describe( 'OnboardingScreen', () => {
+ beforeEach( () => {
+ mockedApiFetch.mockReset();
+ window.OneUpdateOnboarding = {
+ nonce: 'onboarding-nonce',
+ site_type: '',
+ setup_url: '/wp-admin/admin.php?page=oneupdate-settings',
+ };
+ } );
+
+ it( 'loads the current site type from settings', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ oneupdate_site_type: 'brand-site',
+ } );
+
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByLabelText( 'Site Type' ) ).toHaveValue(
+ 'brand-site'
+ );
+ } );
+ } );
+
+ it( 'shows an error notice when loading fails', async () => {
+ mockedApiFetch.mockRejectedValueOnce( new Error( 'load failed' ) );
+
+ render( );
+
+ // WordPress renders notices in both accessibility region and visible content
+ expect(
+ await screen.findAllByText( 'Error fetching site type.' )
+ ).toHaveLength( 2 );
+ } );
+
+ it( 'saves the chosen site type and redirects to setup page', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {} ).mockResolvedValueOnce( {
+ oneupdate_site_type: 'governing-site',
+ } );
+
+ render( );
+
+ const selectControl = await screen.findByLabelText( 'Site Type' );
+ fireEvent.change( selectControl, {
+ target: { value: 'governing-site' },
+ } );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: 'Select Current Site Type',
+ } );
+ fireEvent.click( submitButton );
+
+ await waitFor( () => {
+ expect( mockedApiFetch ).toHaveBeenLastCalledWith( {
+ path: '/wp/v2/settings',
+ method: 'POST',
+ data: { oneupdate_site_type: 'governing-site' },
+ } );
+ } );
+ } );
+
+ it( 'shows an error notice when saving fails', async () => {
+ mockedApiFetch
+ .mockResolvedValueOnce( {} )
+ .mockRejectedValueOnce( new Error( 'save failed' ) );
+
+ render( );
+
+ const selectControl = await screen.findByLabelText( 'Site Type' );
+ fireEvent.change( selectControl, {
+ target: { value: 'brand-site' },
+ } );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: 'Select Current Site Type',
+ } );
+ fireEvent.click( submitButton );
+
+ expect(
+ await screen.findAllByText( 'Error setting site type.' )
+ ).toHaveLength( 2 );
+ } );
+} );
diff --git a/tests/js/SettingsPage.test.tsx b/tests/js/SettingsPage.test.tsx
new file mode 100644
index 0000000..8760c16
--- /dev/null
+++ b/tests/js/SettingsPage.test.tsx
@@ -0,0 +1,254 @@
+jest.mock( 'react', () => jest.requireActual( 'react' ) );
+jest.mock( '@wordpress/element', () =>
+ jest.requireActual( '@wordpress/element' )
+);
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+// Mock child components to isolate SettingsPage behavior
+jest.mock( '@/components/SiteTable', () => ( {
+ __esModule: true,
+ default: ( {
+ sites,
+ onEdit,
+ onDelete,
+ setFormData,
+ setShowModal,
+ }: {
+ sites: Array< any >;
+ onEdit: ( index: number ) => void;
+ onDelete: ( index: number | null ) => void;
+ setFormData: ( data: any ) => void;
+ setShowModal: ( show: boolean ) => void;
+ } ) => (
+
+ { sites.length }
+
+
+
+
+ ),
+} ) );
+
+jest.mock( '@/components/SiteModal', () => ( {
+ __esModule: true,
+ default: ( {
+ onSubmit,
+ onClose,
+ }: {
+ onSubmit: () => Promise< boolean >;
+ onClose: () => void;
+ } ) => (
+
+
+
+
+ ),
+} ) );
+
+jest.mock( '@/components/SiteSettings', () => ( {
+ __esModule: true,
+ default: () => Brand Site Settings
,
+} ) );
+
+jest.mock( '@/components/GitHubRepoToken', () => ( {
+ __esModule: true,
+ default: () => GitHub Repo Token
,
+} ) );
+
+jest.mock( '@/components/S3Credentials', () => ( {
+ __esModule: true,
+ default: () => S3 Credentials
,
+} ) );
+
+import SettingsPage from '@/admin/settings/page';
+
+const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+describe( 'SettingsPage', () => {
+ beforeEach( () => {
+ mockedApiFetch.mockReset();
+ window.OneUpdateSettings = {
+ ...window.OneUpdateSettings,
+ siteType: 'governing-site',
+ };
+ document.body.className = '';
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { repos: [] } ),
+ } ) as typeof fetch;
+ } );
+
+ it( 'loads shared sites and renders the page structure for governing-site', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ shared_sites: [
+ {
+ name: 'Brand Site',
+ url: 'https://brand.example.com/',
+ api_key: 'key',
+ gh_repo: 'org/repo-a',
+ },
+ ],
+ } );
+
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'site-count' ) ).toHaveTextContent(
+ '1'
+ );
+ } );
+
+ expect( screen.getByTestId( 'github-repo-token' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 's3-credentials' ) ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId( 'site-settings' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders the page structure for brand-site', async () => {
+ window.OneUpdateSettings.siteType = 'brand-site';
+
+ let SettingsPageForBrand: any;
+ let isolatedApiFetch: any;
+ jest.isolateModules( () => {
+ SettingsPageForBrand = require( '@/admin/settings/page' ).default;
+ isolatedApiFetch = require( '@wordpress/api-fetch' ).default;
+ } );
+
+ isolatedApiFetch.mockResolvedValueOnce( {
+ shared_sites: [],
+ } );
+
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'site-settings' ) ).toBeInTheDocument();
+ } );
+
+ expect( screen.queryByTestId( 'site-table' ) ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId( 'github-repo-token' )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId( 's3-credentials' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows an error notice when initial data load fails', async () => {
+ mockedApiFetch.mockRejectedValueOnce( new Error( 'load failed' ) );
+
+ render( );
+
+ const noticeEls = await screen.findAllByText(
+ 'Error fetching settings data.'
+ );
+ expect( noticeEls.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'saves site changes, triggers reload if it is the first site, and shows success notice', async () => {
+ mockedApiFetch
+ .mockResolvedValueOnce( { shared_sites: [] } )
+ .mockResolvedValueOnce( {
+ shared_sites: [
+ {
+ name: 'Brand Site',
+ url: 'https://brand.example.com/',
+ api_key: 'key',
+ gh_repo: 'org/repo-a',
+ },
+ ],
+ } );
+
+ render( );
+
+ fireEvent.click(
+ await screen.findByRole( 'button', { name: 'Open Modal' } )
+ );
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Submit Modal' } )
+ );
+
+ await waitFor( () => {
+ expect( mockedApiFetch ).toHaveBeenLastCalledWith( {
+ path: '/oneupdate/v1/shared-sites',
+ method: 'POST',
+ data: {
+ sites_data: [
+ {
+ name: '',
+ url: '',
+ api_key: '',
+ gh_repo: '',
+ },
+ ],
+ },
+ } );
+ } );
+
+ const successNotices = await screen.findAllByText(
+ 'Brand Site saved successfully.'
+ );
+ expect( successNotices.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'deletes site and reloads if no sites are left', async () => {
+ mockedApiFetch
+ .mockResolvedValueOnce( {
+ shared_sites: [
+ {
+ name: 'Brand Site',
+ url: 'https://brand.example.com/',
+ api_key: 'key',
+ gh_repo: 'org/repo-a',
+ },
+ ],
+ } )
+ .mockResolvedValueOnce( {
+ shared_sites: [],
+ } );
+
+ render( );
+
+ await screen.findByTestId( 'site-table' );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Delete Site' } )
+ );
+
+ await waitFor( () => {
+ expect( mockedApiFetch ).toHaveBeenLastCalledWith( {
+ path: '/oneupdate/v1/shared-sites',
+ method: 'POST',
+ data: { sites_data: [] },
+ } );
+ } );
+ } );
+} );
diff --git a/tests/js/components/GitHubRepoToken.test.tsx b/tests/js/components/GitHubRepoToken.test.tsx
new file mode 100644
index 0000000..0fb9bf6
--- /dev/null
+++ b/tests/js/components/GitHubRepoToken.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import GitHubRepoToken from '@/components/GitHubRepoToken';
+
+describe( 'GitHubRepoToken', () => {
+ const setNoticeMock = jest.fn();
+ const fetchAllAvailableGitHubReposMock = jest.fn();
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'fetches and displays the saved GitHub token on mount', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () =>
+ Promise.resolve( { github_token: 'ghp_initial_token' } ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ expect( global.fetch ).toHaveBeenCalledWith(
+ expect.stringContaining( '/oneupdate/v1/github-token' ),
+ expect.objectContaining( { method: 'GET' } )
+ );
+
+ const tokenInput = await screen.findByLabelText(
+ /GitHub Personal Access Token/i
+ );
+ await waitFor( () => {
+ expect( tokenInput ).toHaveValue( 'ghp_initial_token' );
+ } );
+ } );
+
+ it( 'updates input value when typing', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ const tokenInput = await screen.findByLabelText(
+ /GitHub Personal Access Token/i
+ );
+ fireEvent.change( tokenInput, { target: { value: 'ghp_new_token' } } );
+ expect( tokenInput ).toHaveValue( 'ghp_new_token' );
+ } );
+
+ it( 'shows success notice and calls fetchAllAvailableGitHubRepos when token is saved successfully', async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( { github_token: '' } ),
+ } )
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( { success: true } ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ const tokenInput = await screen.findByLabelText(
+ /GitHub Personal Access Token/i
+ );
+ fireEvent.change( tokenInput, {
+ target: { value: 'ghp_valid_token' },
+ } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save GitHub Token/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( global.fetch ).toHaveBeenLastCalledWith(
+ expect.stringContaining( '/oneupdate/v1/github-token' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: JSON.stringify( { token: 'ghp_valid_token' } ),
+ } )
+ );
+ } );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'success',
+ message: 'GitHub token saved successfully.',
+ } );
+ } );
+
+ expect( fetchAllAvailableGitHubReposMock ).toHaveBeenCalled();
+ } );
+
+ it( 'shows error notice when API returns status 400 or fails', async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( { github_token: '' } ),
+ } )
+ .mockResolvedValueOnce( {
+ ok: false,
+ status: 400,
+ json: () => Promise.resolve( {} ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ const tokenInput = await screen.findByLabelText(
+ /GitHub Personal Access Token/i
+ );
+ fireEvent.change( tokenInput, { target: { value: 'invalid_token' } } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save GitHub Token/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'error',
+ message: 'Please enter valid GitHub PAT token.',
+ } );
+ } );
+ } );
+
+ it( 'shows error message returned from API response payload', async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( { github_token: '' } ),
+ } )
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ status: '400',
+ message: 'Custom PAT validation error.',
+ } ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ const tokenInput = await screen.findByLabelText(
+ /GitHub Personal Access Token/i
+ );
+ fireEvent.change( tokenInput, { target: { value: 'bad_token' } } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save GitHub Token/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'error',
+ message: 'Custom PAT validation error.',
+ } );
+ } );
+ } );
+} );
diff --git a/tests/js/components/PluginCard.test.tsx b/tests/js/components/PluginCard.test.tsx
new file mode 100644
index 0000000..a5ed30e
--- /dev/null
+++ b/tests/js/components/PluginCard.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+/**
+ * Internal dependencies
+ */
+import PluginCard from '@/components/PluginCard';
+
+const mockPlugin = {
+ name: 'My Custom Plugin',
+ slug: 'my-custom-plugin',
+ version: '1.2.3',
+ short_description: 'This is a test description of the plugin.',
+ icons: {
+ '1x': 'https://example.com/icon.png',
+ default: 'https://example.com/icon-default.png',
+ },
+ versions: {
+ '1.0.0':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.0.0.zip',
+ '1.1.0':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.1.0.zip',
+ '1.2.0':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.2.0.zip',
+ '1.2.1':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.2.1.zip',
+ '1.2.2-beta':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.2.2-beta.zip',
+ '1.2.3':
+ 'https://downloads.wordpress.org/plugin/my-custom-plugin.1.2.3.zip',
+ trunk: 'https://downloads.wordpress.org/plugin/my-custom-plugin.zip',
+ },
+};
+
+describe( 'PluginCard', () => {
+ const onSelectMock = jest.fn();
+ const onVersionChangeMock = jest.fn();
+
+ beforeEach( () => {
+ onSelectMock.mockReset();
+ onVersionChangeMock.mockReset();
+ } );
+
+ it( 'renders plugin details correctly when not selected', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole( 'heading', { name: 'My Custom Plugin' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'This is a test description of the plugin.' )
+ ).toBeInTheDocument();
+ expect( screen.getByText( 'v1.2.3' ) ).toBeInTheDocument();
+
+ const logo = screen.getByRole( 'img', { name: 'My Custom Plugin' } );
+ expect( logo ).toHaveAttribute( 'src', 'https://example.com/icon.png' );
+
+ const selectButton = screen.getByRole( 'button', {
+ name: 'Select Plugin',
+ } );
+ expect( selectButton ).toBeInTheDocument();
+ expect( screen.queryByLabelText( 'Version' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders selected state and version select dropdown when selected', () => {
+ const selectedPlugin = [
+ {
+ slug: 'my-custom-plugin',
+ version: '1.2.3',
+ },
+ ];
+
+ render(
+
+ );
+
+ const selectedButton = screen.getByRole( 'button', {
+ name: 'Selected',
+ } );
+ expect( selectedButton ).toBeInTheDocument();
+
+ const versionSelect = screen.getByLabelText(
+ 'Version'
+ ) as HTMLSelectElement;
+ expect( versionSelect ).toBeInTheDocument();
+ expect( versionSelect.value ).toBe( '1.2.3' );
+
+ // Options should be the last 5 stable versions (1.2.3, 1.2.1, 1.2.0, 1.1.0, 1.0.0) in reverse.
+ // It should exclude '1.2.2-beta' and 'trunk'.
+ const options = Array.from( versionSelect.options ).map(
+ ( opt ) => opt.value
+ );
+ expect( options ).toEqual( [
+ '1.2.3',
+ '1.2.1',
+ '1.2.0',
+ '1.1.0',
+ '1.0.0',
+ ] );
+ } );
+
+ it( 'calls onSelect with version when clicked to select', () => {
+ render(
+
+ );
+
+ const selectButton = screen.getByRole( 'button', {
+ name: 'Select Plugin',
+ } );
+ fireEvent.click( selectButton );
+
+ expect( onSelectMock ).toHaveBeenCalledWith(
+ 'my-custom-plugin',
+ '1.2.3'
+ );
+ } );
+
+ it( 'calls onSelect with null when clicked to deselect', () => {
+ const selectedPlugin = [
+ {
+ slug: 'my-custom-plugin',
+ version: '1.2.3',
+ },
+ ];
+
+ render(
+
+ );
+
+ const selectedButton = screen.getByRole( 'button', {
+ name: 'Selected',
+ } );
+ fireEvent.click( selectedButton );
+
+ expect( onSelectMock ).toHaveBeenCalledWith( 'my-custom-plugin', null );
+ } );
+
+ it( 'calls onVersionChange when a different version is selected', () => {
+ const selectedPlugin = [
+ {
+ slug: 'my-custom-plugin',
+ version: '1.2.3',
+ },
+ ];
+
+ render(
+
+ );
+
+ const versionSelect = screen.getByLabelText( 'Version' );
+ fireEvent.change( versionSelect, { target: { value: '1.2.0' } } );
+
+ expect( onVersionChangeMock ).toHaveBeenCalledWith(
+ 'my-custom-plugin',
+ '1.2.0'
+ );
+ } );
+} );
diff --git a/tests/js/components/PluginGrid.test.tsx b/tests/js/components/PluginGrid.test.tsx
new file mode 100644
index 0000000..5e68d4a
--- /dev/null
+++ b/tests/js/components/PluginGrid.test.tsx
@@ -0,0 +1,331 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Initialize global object required by the component
+beforeAll( () => {
+ ( window as any ).OneUpdatePlugins = {
+ restUrl: 'https://example.com/wp-json',
+ restNonce: 'nonce',
+ api_key: 'test-api-key',
+ };
+} );
+
+// Mock PluginCard to isolate grid integration tests
+jest.mock( '@/components/PluginCard', () => ( {
+ __esModule: true,
+ default: ( {
+ plugin,
+ selectedPlugin,
+ onSelect,
+ onVersionChange,
+ }: {
+ plugin: any;
+ selectedPlugin: Array< any >;
+ onSelect: ( slug: string, version: string | null ) => void;
+ onVersionChange: ( slug: string, version: string ) => void;
+ } ) => {
+ const isSelected = selectedPlugin.some(
+ ( p ) => p.slug === plugin.slug
+ );
+ return (
+
+ { plugin.name }
+ { isSelected ? 'Selected' : 'Not Selected' }
+
+
+
+ );
+ },
+} ) );
+
+import PluginGrid from '@/components/PluginGrid';
+
+describe( 'PluginGrid', () => {
+ const mockPluginsResponse = {
+ plugins: [
+ {
+ name: 'Plugin A',
+ slug: 'plugin-a',
+ version: '1.2.0',
+ },
+ {
+ name: 'Plugin B',
+ slug: 'plugin-b',
+ version: '2.0.0',
+ },
+ ],
+ info: {
+ page: 1,
+ pages: 3,
+ },
+ };
+
+ const mockSharedSitesResponse = {
+ shared_sites: [
+ {
+ id: 'site-1',
+ name: 'Brand Site One',
+ url: 'https://brand1.example.com/',
+ },
+ {
+ id: 'site-2',
+ name: 'Brand Site Two',
+ url: 'https://brand2.example.com/',
+ },
+ ],
+ };
+
+ let fetchSpy: jest.SpyInstance;
+
+ beforeEach( () => {
+ fetchSpy = jest
+ .spyOn( global, 'fetch' )
+ .mockImplementation( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'api.wordpress.org' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockPluginsResponse ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'shared-sites' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'apply-plugins' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: true,
+ created_prs: [
+ {
+ data: {
+ pr_url: 'https://github.com/org/repo/pull/1',
+ },
+ },
+ ],
+ } ),
+ } as any );
+ }
+ return Promise.reject(
+ new Error( `Unhandled mock URL: ${ url }` )
+ );
+ } );
+ } );
+
+ afterEach( () => {
+ fetchSpy.mockRestore();
+ } );
+
+ it( 'loads plugins and shared sites on mount', async () => {
+ render( );
+
+ // Should show loading spinner initially
+ expect( screen.getByText( 'Loading plugins…' ) ).toBeInTheDocument();
+
+ // Wait for plugins to render
+ await waitFor( () => {
+ expect(
+ screen.getByTestId( 'plugin-card-plugin-a' )
+ ).toBeInTheDocument();
+ } );
+
+ expect(
+ screen.getByTestId( 'plugin-card-plugin-b' )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText( 'Loading plugins…' )
+ ).not.toBeInTheDocument();
+
+ // Check fetch calls
+ expect( fetchSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'query_plugins' )
+ );
+ expect( fetchSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'shared-sites' ),
+ expect.any( Object )
+ );
+ } );
+
+ it( 'updates listings on search submission', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect(
+ screen.getByTestId( 'plugin-card-plugin-a' )
+ ).toBeInTheDocument();
+ } );
+
+ const searchInput = screen.getByPlaceholderText( /Search plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'custom-query' } } );
+
+ const searchButton = screen.getByRole( 'button', { name: 'Search' } );
+ fireEvent.click( searchButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'search=custom-query' )
+ );
+ } );
+ } );
+
+ it( 'paginates plugin lists correctly', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect(
+ screen.getByTestId( 'plugin-card-plugin-a' )
+ ).toBeInTheDocument();
+ } );
+
+ const nextButton = screen.getByRole( 'button', { name: 'Next' } );
+ fireEvent.click( nextButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'page=2' )
+ );
+ } );
+
+ // Wait for loading to finish and page 2 plugins to render
+ await screen.findByTestId( 'plugin-card-plugin-a' );
+
+ const prevButton = screen.getByRole( 'button', { name: 'Previous' } );
+ fireEvent.click( prevButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'page=1' )
+ );
+ } );
+ } );
+
+ it( 'displays error notice when fetching plugins fails', async () => {
+ fetchSpy.mockImplementationOnce( () =>
+ Promise.resolve( {
+ ok: false,
+ } as any )
+ );
+
+ render( );
+
+ const errorNotice = await screen.findByText(
+ 'Failed to fetch plugins.'
+ );
+ expect( errorNotice ).toBeInTheDocument();
+
+ const retryButton = screen.getByRole( 'button', { name: 'Try Again' } );
+ fireEvent.click( retryButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenCalledTimes( 3 ); // 1 (fail) + 1 (shared sites) + 1 (retry)
+ } );
+ } );
+
+ it( 'manages plugin selections and applies them to selected sites', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect(
+ screen.getByTestId( 'plugin-card-plugin-a' )
+ ).toBeInTheDocument();
+ } );
+
+ // Toggle select plugin-a
+ const toggleButton = screen.getAllByRole( 'button', {
+ name: 'Toggle Select',
+ } )[ 0 ];
+ fireEvent.click( toggleButton as HTMLElement );
+
+ // Confirm active selected count shows 1 plugin
+ expect(
+ screen.getByText( /1\s+plugin\s+selected/i )
+ ).toBeInTheDocument();
+ const applyButton = screen.getByRole( 'button', {
+ name: 'Apply selected plugins',
+ } );
+ expect( applyButton ).toBeInTheDocument();
+
+ // Change version of plugin-a
+ const changeVersionBtn = screen.getAllByRole( 'button', {
+ name: 'Change Version',
+ } )[ 0 ];
+ fireEvent.click( changeVersionBtn as HTMLElement );
+
+ // Click Apply to open modal
+ fireEvent.click( applyButton );
+
+ // Verify apply modal opened
+ expect(
+ screen.getByRole( 'heading', { name: 'Apply Selected Plugins' } )
+ ).toBeInTheDocument();
+
+ // Checkbox for Brand Site One
+ const siteOneCheckbox = screen.getByLabelText( 'Brand Site One' );
+ fireEvent.click( siteOneCheckbox );
+
+ // Click Apply in confirmation modal
+ const confirmApplyButton = screen.getByRole( 'button', {
+ name: 'Apply Plugins',
+ } );
+ fireEvent.click( confirmApplyButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'apply-plugins' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: JSON.stringify( {
+ sites: [
+ {
+ id: 'site-1',
+ name: 'Brand Site One',
+ url: 'https://brand1.example.com/',
+ },
+ ],
+ plugins: [
+ {
+ slug: 'plugin-a',
+ version: '1.1.0',
+ },
+ ],
+ } ),
+ } )
+ );
+ } );
+
+ // Verify success notice is displayed
+ const successNotice = await screen.findAllByText(
+ /Plugins applied successfully. Please check the PR at: https:\/\/github.com\/org\/repo\/pull\/1/i
+ );
+ expect( successNotice.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+} );
diff --git a/tests/js/components/PluginsSharing.test.tsx b/tests/js/components/PluginsSharing.test.tsx
new file mode 100644
index 0000000..1450a87
--- /dev/null
+++ b/tests/js/components/PluginsSharing.test.tsx
@@ -0,0 +1,479 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Initialize global object required by the component
+beforeAll( () => {
+ ( window as any ).OneUpdatePlugins = {
+ restUrl: 'https://example.com/wp-json',
+ restNonce: 'nonce',
+ api_key: 'test-api-key',
+ };
+} );
+
+jest.mock( '@wordpress/components', () => {
+ const actual = jest.requireActual( '@wordpress/components' );
+ return {
+ ...actual,
+ CheckboxControl: ( props: any ) => {
+ return (
+ /* eslint-disable jsx-a11y/label-has-associated-control */
+
+ );
+ },
+ };
+} );
+
+import PluginsSharing from '@/components/PluginsSharing';
+
+describe( 'PluginsSharing', () => {
+ const mockPluginsResponse = {
+ plugins: [
+ {
+ name: 'SEO Plugin',
+ slug: 'seo-plugin',
+ version: '3.4.1',
+ short_description: 'Improve your search rankings.',
+ icons: {
+ '1x': 'https://example.com/seo.png',
+ },
+ versions: {
+ '3.4.1': 'zip-url-1',
+ '3.4.0': 'zip-url-2',
+ '3.3.0': 'zip-url-3',
+ },
+ author: 'SEO Team',
+ },
+ ],
+ info: {
+ page: 1,
+ pages: 1,
+ },
+ };
+
+ const mockSharedSitesResponse = {
+ shared_sites: [
+ {
+ id: 'site-1',
+ name: 'Governed Site',
+ url: 'https://governed.example.com/',
+ },
+ ],
+ };
+
+ let fetchSpy: jest.SpyInstance;
+
+ beforeEach( () => {
+ fetchSpy = jest
+ .spyOn( global, 'fetch' )
+ .mockImplementation( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'api.wordpress.org' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockPluginsResponse ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'shared-sites' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'apply-plugins' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: true,
+ created_prs: [
+ {
+ name: 'Governed Site',
+ run_url:
+ 'https://github.com/org/repo/pull/1',
+ },
+ ],
+ } ),
+ } as any );
+ }
+ return Promise.reject(
+ new Error( `Unhandled mock URL: ${ url }` )
+ );
+ } );
+ } );
+
+ afterEach( () => {
+ fetchSpy.mockRestore();
+ } );
+
+ it( 'renders search tip guidance initially and does not fetch plugins on mount', async () => {
+ render( );
+
+ // Tip text should be visible
+ expect(
+ screen.getByText( /Tip: Try searching for specific functionality/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'Search Plugins' } )
+ ).toBeInTheDocument();
+
+ // Should NOT load plugins initially
+ expect( screen.queryByText( 'SEO Plugin' ) ).not.toBeInTheDocument();
+
+ // But shared sites data IS loaded on mount
+ expect( fetchSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'shared-sites' ),
+ expect.any( Object )
+ );
+ } );
+
+ it( 'searches and installs selected plugins on brand sites', async () => {
+ render( );
+
+ // Search for plugin
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'seo' } } );
+
+ const searchButton = screen.getByRole( 'button', {
+ name: 'Search Plugins',
+ } );
+ fireEvent.click( searchButton );
+
+ // Should show search loading
+ expect( screen.getByText( 'Searching plugins…' ) ).toBeInTheDocument();
+
+ // Wait for search result cards to render
+ await waitFor( () => {
+ expect(
+ screen.getByRole( 'heading', { name: 'SEO Plugin' } )
+ ).toBeInTheDocument();
+ } );
+ expect(
+ screen.queryByText( 'Searching plugins…' )
+ ).not.toBeInTheDocument();
+
+ // Select the plugin
+ const selectButton = screen.getByRole( 'button', {
+ name: 'Select Plugin',
+ } );
+ fireEvent.click( selectButton );
+
+ // Verify apply button shows with selection count
+ expect(
+ screen.getByText( /1\s+plugin\s+selected/i )
+ ).toBeInTheDocument();
+ const applyBtn = screen.getByRole( 'button', {
+ name: 'Install Selected Plugins',
+ } );
+ fireEvent.click( applyBtn );
+
+ // Modal should open
+ expect(
+ screen.getByRole( 'heading', { name: 'Install Selected Plugins' } )
+ ).toBeInTheDocument();
+
+ // Select site row
+ const siteRow = screen.getByRole( 'button', {
+ name: /Governed Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Click Install Plugins button
+ const installButton = screen.getByRole( 'button', {
+ name: 'Install Plugins',
+ } );
+ fireEvent.click( installButton );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'apply-plugins' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: JSON.stringify( {
+ sites: [
+ {
+ id: 'site-1',
+ name: 'Governed Site',
+ url: 'https://governed.example.com/',
+ },
+ ],
+ plugins: [
+ {
+ slug: 'seo-plugin',
+ version: '3.4.1',
+ },
+ ],
+ plugin_type: 'add_update',
+ } ),
+ } )
+ );
+ } );
+
+ // Verify success notice is displayed
+ const successNotice = await screen.findAllByText(
+ /Plugins applied successfully.*Governed Site.*https:\/\/github.com\/org\/repo\/pull\/1/i
+ );
+ expect( successNotice.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'handles search error state and supports retrying search', async () => {
+ render( );
+
+ // Search for plugin
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'error-query' } } );
+
+ const searchButton = screen.getByRole( 'button', {
+ name: 'Search Plugins',
+ } );
+
+ // Mock error response for plugin search
+ fetchSpy.mockImplementationOnce( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'api.wordpress.org' )
+ ) {
+ return Promise.resolve( {
+ ok: false,
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ } );
+
+ fireEvent.click( searchButton );
+
+ // Wait for error container
+ const errorTitle = await screen.findByText( 'Unable to load plugins' );
+ expect( errorTitle ).toBeInTheDocument();
+
+ const tryAgainButton = screen.getByRole( 'button', {
+ name: 'Try Again',
+ } );
+ fireEvent.click( tryAgainButton );
+
+ // Wait for search result cards to render on successful retry
+ await waitFor( () => {
+ expect(
+ screen.getByRole( 'heading', { name: 'SEO Plugin' } )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'supports selecting custom versions from the plugin card dropdown', async () => {
+ render( );
+
+ // Search for plugin
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'seo' } } );
+ const searchButton = screen.getByRole( 'button', {
+ name: 'Search Plugins',
+ } );
+ fireEvent.click( searchButton );
+
+ // Wait for card
+ await screen.findByRole( 'heading', { name: 'SEO Plugin' } );
+
+ // Select plugin first — the version dropdown only appears when selected
+ const selectButton = screen.getByRole( 'button', {
+ name: 'Select Plugin',
+ } );
+ fireEvent.click( selectButton );
+ expect(
+ screen.getByText( /1\s+plugin\s+selected/i )
+ ).toBeInTheDocument();
+
+ // Now the version combobox should be visible
+ const combobox = screen.getByRole( 'combobox' );
+ fireEvent.change( combobox, { target: { value: '3.4.0' } } );
+
+ // Verify combobox selected value
+ expect( combobox ).toHaveValue( '3.4.0' );
+
+ // Deselect plugin by clicking the now-"Selected" button
+ fireEvent.click( screen.getByRole( 'button', { name: 'Selected' } ) );
+ expect(
+ screen.queryByText( /plugin\s+selected/i )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders empty state when no plugins match the query', async () => {
+ render( );
+
+ // Mock empty plugins response
+ fetchSpy.mockImplementationOnce( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'api.wordpress.org' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ plugins: [],
+ info: { page: 1, pages: 1 },
+ } ),
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ } );
+
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'empty-query' } } );
+ const searchButton = screen.getByRole( 'button', {
+ name: 'Search Plugins',
+ } );
+ fireEvent.click( searchButton );
+
+ // Check empty notice
+ expect(
+ await screen.findByText( 'No plugins found' )
+ ).toBeInTheDocument();
+
+ // Click Clear Search button
+ const clearBtn = screen.getByRole( 'button', { name: 'Clear Search' } );
+ fireEvent.click( clearBtn );
+ expect( searchInput ).toHaveValue( '' );
+ } );
+
+ it( 'displays error notice when apply-plugins API returns failure', async () => {
+ render( );
+
+ // Search and select plugin
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'seo' } } );
+ const searchButton = screen.getByRole( 'button', {
+ name: 'Search Plugins',
+ } );
+ fireEvent.click( searchButton );
+
+ await screen.findByRole( 'heading', { name: 'SEO Plugin' } );
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Select Plugin' } )
+ );
+
+ // Open Modal
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Selected Plugins' } )
+ );
+
+ // Click site row to select it
+ const siteRow = screen.getByRole( 'button', {
+ name: /Governed Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Mock failed apply response
+ fetchSpy.mockImplementation( ( url ) => {
+ if ( typeof url === 'string' && url.includes( 'apply-plugins' ) ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: false,
+ message: 'Invalid token',
+ } ),
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ } );
+
+ // Install
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Plugins' } )
+ );
+
+ // Verify error notice
+ const errorNotices = await screen.findAllByText(
+ 'Failed to apply plugins. Please try again.'
+ );
+ expect( errorNotices.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'displays warning notice when no shared sites are available to install plugins', async () => {
+ // Mock empty shared-sites response before render so mount load catches it
+ fetchSpy.mockImplementation( ( url ) => {
+ if ( typeof url === 'string' && url.includes( 'shared-sites' ) ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( { shared_sites: [] } ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'api.wordpress.org' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockPluginsResponse ),
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } as any );
+ } );
+
+ render( );
+
+ // Search and select plugin
+ const searchInput =
+ screen.getByPlaceholderText( /Search for plugins/i );
+ fireEvent.change( searchInput, { target: { value: 'seo' } } );
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Search Plugins' } )
+ );
+
+ await screen.findByRole( 'heading', { name: 'SEO Plugin' } );
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Select Plugin' } )
+ );
+
+ // Open Modal
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Selected Plugins' } )
+ );
+
+ // Check empty sites notice (handling screen reader speak region duplicates)
+ const notices = await screen.findAllByText(
+ 'No sites available to apply plugins.'
+ );
+ expect( notices.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+} );
diff --git a/tests/js/components/S3Credentials.test.tsx b/tests/js/components/S3Credentials.test.tsx
new file mode 100644
index 0000000..0a7b533
--- /dev/null
+++ b/tests/js/components/S3Credentials.test.tsx
@@ -0,0 +1,215 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import S3Credentials from '@/components/S3Credentials';
+
+describe( 'S3Credentials', () => {
+ const setNoticeMock = jest.fn();
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'renders loading state and then credentials on mount', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ s3_credentials: {
+ accessKey: 'my-access-key',
+ secretKey: 'my-secret-key',
+ bucketName: 'my-bucket',
+ region: 'us-east-1',
+ endpoint: 'https://s3.amazonaws.com',
+ },
+ } ),
+ } ) as typeof fetch;
+
+ render( );
+
+ // Verify initial loading message
+ expect( screen.getByText( /Loading…/i ) ).toBeInTheDocument();
+
+ // Wait for credentials to load and verify values are set
+ const bucketInput = await screen.findByLabelText( /Bucket Name/i );
+ expect( bucketInput ).toHaveValue( 'my-bucket' );
+ expect( screen.getByLabelText( /Region/i ) ).toHaveValue( 'us-east-1' );
+ expect( screen.getByLabelText( /Endpoint/i ) ).toHaveValue(
+ 'https://s3.amazonaws.com'
+ );
+ expect( screen.getByLabelText( /Access Key/i ) ).toHaveValue(
+ 'my-access-key'
+ );
+ expect( screen.getByLabelText( /Secret Key/i ) ).toHaveValue(
+ 'my-secret-key'
+ );
+ } );
+
+ it( 'validates required fields before submitting', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const bucketInput = await screen.findByLabelText( /Bucket Name/i );
+ fireEvent.change( bucketInput, { target: { value: 'my-bucket' } } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save Credentials/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'error',
+ message: 'All fields are required to save S3 credentials.',
+ } );
+ } );
+ } );
+
+ it( 'validates endpoint URL structure before submitting', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const bucketInput = await screen.findByLabelText( /Bucket Name/i );
+ fireEvent.change( bucketInput, { target: { value: 'my-bucket' } } );
+ fireEvent.change( screen.getByLabelText( /Region/i ), {
+ target: { value: 'us-east-1' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Endpoint/i ), {
+ target: { value: 'not-a-valid-url' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Access Key/i ), {
+ target: { value: 'my-access-key' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Secret Key/i ), {
+ target: { value: 'my-secret-key' },
+ } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save Credentials/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'error',
+ message: 'Please enter a valid URL for the S3 endpoint.',
+ } );
+ } );
+ } );
+
+ it( 'saves credentials successfully', async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } )
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( { success: true } ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const bucketInput = await screen.findByLabelText( /Bucket Name/i );
+ fireEvent.change( bucketInput, { target: { value: 'my-bucket' } } );
+ fireEvent.change( screen.getByLabelText( /Region/i ), {
+ target: { value: 'us-east-1' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Endpoint/i ), {
+ target: { value: 'https://s3.example.com' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Access Key/i ), {
+ target: { value: 'my-access-key' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Secret Key/i ), {
+ target: { value: 'my-secret-key' },
+ } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save Credentials/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( global.fetch ).toHaveBeenLastCalledWith(
+ expect.stringContaining( '/oneupdate/v1/s3-credentials' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: JSON.stringify( {
+ s3_credentials: {
+ accessKey: 'my-access-key',
+ secretKey: 'my-secret-key',
+ bucketName: 'my-bucket',
+ region: 'us-east-1',
+ endpoint: 'https://s3.example.com',
+ },
+ } ),
+ } )
+ );
+ } );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'success',
+ message: 'S3 credentials saved successfully.',
+ } );
+ } );
+ } );
+
+ it( 'shows error notice when S3 credentials save fails', async () => {
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } )
+ .mockResolvedValueOnce( {
+ ok: false,
+ json: () => Promise.resolve( {} ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const bucketInput = await screen.findByLabelText( /Bucket Name/i );
+ fireEvent.change( bucketInput, { target: { value: 'my-bucket' } } );
+ fireEvent.change( screen.getByLabelText( /Region/i ), {
+ target: { value: 'us-east-1' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Endpoint/i ), {
+ target: { value: 'https://s3.example.com' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Access Key/i ), {
+ target: { value: 'my-access-key' },
+ } );
+ fireEvent.change( screen.getByLabelText( /Secret Key/i ), {
+ target: { value: 'my-secret-key' },
+ } );
+
+ const saveButton = screen.getByRole( 'button', {
+ name: /Save Credentials/i,
+ } );
+ fireEvent.click( saveButton );
+
+ await waitFor( () => {
+ expect( setNoticeMock ).toHaveBeenCalledWith( {
+ type: 'error',
+ message:
+ 'Failed to save S3 credentials. Please try again later.',
+ } );
+ } );
+ } );
+} );
diff --git a/tests/js/components/S3ZipUploader.test.tsx b/tests/js/components/S3ZipUploader.test.tsx
new file mode 100644
index 0000000..645a018
--- /dev/null
+++ b/tests/js/components/S3ZipUploader.test.tsx
@@ -0,0 +1,550 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Initialize global object required by the component
+beforeAll( () => {
+ ( window as any ).OneUpdatePlugins = {
+ restUrl: 'https://example.com/wp-json',
+ restNonce: 'nonce',
+ api_key: 'test-api-key',
+ };
+} );
+
+// Mock apiFetch for loading upload history
+jest.mock( '@wordpress/api-fetch', () => ( {
+ __esModule: true,
+ default: jest.fn(),
+} ) );
+
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+import S3ZipUploader from '@/components/S3ZipUploader';
+
+describe( 'S3ZipUploader', () => {
+ const mockHistoryData = [
+ {
+ file_name: 'Custom Woo Addon',
+ version: '2.1.0',
+ upload_time: new Date().toISOString(),
+ status: 'applied',
+ presigned_url:
+ 'https://s3.amazonaws.com/bucket/custom-woo-addon.2.1.0.zip',
+ },
+ ];
+
+ const mockSharedSitesResponse = {
+ shared_sites: [
+ {
+ id: 'site-a',
+ name: 'Shared Brand Site',
+ url: 'https://brand.example.com/',
+ },
+ ],
+ };
+
+ let fetchSpy: jest.SpyInstance;
+
+ beforeEach( () => {
+ ( apiFetch as jest.MockedFunction< typeof apiFetch > ).mockReset();
+ (
+ apiFetch as jest.MockedFunction< typeof apiFetch >
+ ).mockResolvedValue( mockHistoryData );
+
+ fetchSpy = jest
+ .spyOn( global, 'fetch' )
+ .mockImplementation( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'shared-sites' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ }
+ if ( typeof url === 'string' && url.includes( 'upload' ) ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ presigned_url:
+ 'https://s3.amazonaws.com/bucket/uploaded.zip',
+ } ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'apply-private-plugins' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: true,
+ results: [
+ {
+ name: 'Shared Brand Site',
+ run_url:
+ 'https://github.com/org/repo/actions/runs/12345',
+ },
+ ],
+ } ),
+ } as any );
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes( 'apply-plugins' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: true,
+ results: [
+ {
+ name: 'Shared Brand Site',
+ run_url:
+ 'https://github.com/org/repo/actions/runs/54321',
+ },
+ ],
+ } ),
+ } as any );
+ }
+ return Promise.reject(
+ new Error( `Unhandled mock URL: ${ url }` )
+ );
+ } );
+ } );
+
+ afterEach( () => {
+ fetchSpy.mockRestore();
+ } );
+
+ it( 'renders S3ZipUploader upload box and history table on mount', async () => {
+ render( );
+
+ // Check drop zone area renders
+ expect(
+ screen.getByText( /Upload Private Plugin/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /Choose plugin zip file/i )
+ ).toBeInTheDocument();
+
+ // Wait for history table to load from apiFetch
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Custom Woo Addon' )
+ ).toBeInTheDocument();
+ } );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Copy URL' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles S3 upload and apply workflow on selected file', async () => {
+ render( );
+
+ // Wait for history table load
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Select a zip file
+ const fileInput = screen.getByTestId(
+ 'form-file-upload-input'
+ ) as HTMLInputElement;
+ const file = new File( [ 'dummy content' ], 'test-plugin.zip', {
+ type: 'application/zip',
+ } );
+ fireEvent.change( fileInput, { target: { files: [ file ] } } );
+
+ // Click Upload button to trigger site selection modal
+ const uploadBtn = screen.getByRole( 'button', {
+ name: 'Upload & Install Plugin',
+ } );
+ fireEvent.click( uploadBtn );
+
+ // Verify site selection modal opened
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Sites for Plugin Installation',
+ } )
+ ).toBeInTheDocument();
+
+ // Select Brand Site row
+ const siteRow = screen.getByRole( 'button', {
+ name: /Shared Brand Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Confirm upload
+ const confirmBtn = screen.getByRole( 'button', {
+ name: 'Install Plugin',
+ } );
+ fireEvent.click( confirmBtn );
+
+ // Wait for upload fetch requests to run
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'upload' ),
+ expect.any( Object )
+ );
+ } );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenLastCalledWith(
+ expect.stringContaining( 'apply-private-plugins' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: expect.stringContaining( 'uploaded.zip' ),
+ } )
+ );
+ } );
+
+ // Check success notification
+ const successNotice = await screen.findAllByText(
+ /Plugin uploaded and applied successfully.*Shared Brand Site.*https:\/\/github.com\/org\/repo\/actions\/runs\/12345/i
+ );
+ expect( successNotice.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'supports applying existing plugins in history via the modal wizard flow', async () => {
+ render( );
+
+ // Wait for history load
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Click Apply Plugins wizard launcher button
+ const wizardLauncher = screen.getByRole( 'button', {
+ name: 'Install Plugins',
+ } );
+ fireEvent.click( wizardLauncher );
+
+ // Step 1: Plugin Selection Modal
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Plugins to Install',
+ } )
+ ).toBeInTheDocument();
+
+ // Select the plugin wrapper button in history list
+ const pluginButton = screen.getByRole( 'button', {
+ name: /Custom Woo Addon/i,
+ } );
+ fireEvent.click( pluginButton );
+
+ // Next Step
+ const nextBtn = screen.getByRole( 'button', {
+ name: 'Next: Select Sites',
+ } );
+ fireEvent.click( nextBtn );
+
+ // Step 2: Site Selection Modal
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Sites for Installation',
+ } )
+ ).toBeInTheDocument();
+
+ // Select site row
+ const siteRow = await screen.findByRole( 'button', {
+ name: /Shared Brand Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Click Install Plugins to trigger installation POST request
+ const installBtn = screen.getByRole( 'button', {
+ name: 'Install Plugins',
+ } );
+ fireEvent.click( installBtn );
+
+ await waitFor( () => {
+ expect( fetchSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'apply-private-plugins' ),
+ expect.objectContaining( {
+ method: 'POST',
+ body: expect.stringContaining(
+ 'custom-woo-addon.2.1.0.zip'
+ ),
+ } )
+ );
+ } );
+
+ // Verify success notice in parent app
+ const successNotice = await screen.findAllByText(
+ /Plugins applied successfully.*Shared Brand Site.*https:\/\/github.com\/org\/repo\/actions\/runs\/12345/i
+ );
+ expect( successNotice.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'shows error notice when apply-private-plugins returns failure', async () => {
+ render( );
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Open wizard
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Plugins' } )
+ );
+
+ // Select plugin
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /Custom Woo Addon/i } )
+ );
+
+ // Next step
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Next: Select Sites' } )
+ );
+
+ // Select site
+ const siteRow = await screen.findByRole( 'button', {
+ name: /Shared Brand Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Mock failure response for apply
+ fetchSpy.mockImplementation( ( url ) => {
+ if (
+ typeof url === 'string' &&
+ url.includes( 'apply-private-plugins' )
+ ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ success: false,
+ message: 'Token expired',
+ } ),
+ } as any );
+ }
+ if ( typeof url === 'string' && url.includes( 'shared-sites' ) ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } as any );
+ } );
+
+ // Install
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Plugins' } )
+ );
+
+ // Verify error notice
+ const errorNotices = await screen.findAllByText(
+ /Failed to apply plugins:.*Token expired/i
+ );
+ expect( errorNotices.length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'renders empty history state when no uploads exist', async () => {
+ // Mock empty history
+ (
+ apiFetch as jest.MockedFunction< typeof apiFetch >
+ ).mockResolvedValue( [] );
+
+ render( );
+
+ // Should still render the upload card
+ expect(
+ screen.getByText( /Upload Private Plugin/i )
+ ).toBeInTheDocument();
+
+ // Should render empty history state
+ await waitFor( () => {
+ expect( screen.getByText( /No uploads yet/i ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'shows upload error when presigned URL fetch fails', async () => {
+ render( );
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Select a zip file
+ const fileInput = screen.getByTestId(
+ 'form-file-upload-input'
+ ) as HTMLInputElement;
+ const file = new File( [ 'dummy content' ], 'bad-plugin.zip', {
+ type: 'application/zip',
+ } );
+ fireEvent.change( fileInput, { target: { files: [ file ] } } );
+
+ // Mock upload URL failure
+ fetchSpy.mockImplementation( ( url ) => {
+ if ( typeof url === 'string' && url.includes( 'upload' ) ) {
+ return Promise.reject( new Error( 'Network connection lost' ) );
+ }
+ if ( typeof url === 'string' && url.includes( 'shared-sites' ) ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( mockSharedSitesResponse ),
+ } as any );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } as any );
+ } );
+
+ // Click Upload button
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Upload & Install Plugin',
+ } )
+ );
+
+ // Verify site selection modal opened
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Sites for Plugin Installation',
+ } )
+ ).toBeInTheDocument();
+
+ // Select site
+ const siteRow = screen.getByRole( 'button', {
+ name: /Shared Brand Site/i,
+ } );
+ fireEvent.click( siteRow );
+
+ // Confirm upload
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Plugin' } )
+ );
+
+ // Verify error notice
+ const errorNotices = await screen.findAllByText(
+ /Upload failed:.*Network connection lost/i
+ );
+ expect( errorNotices.length ).toBeGreaterThanOrEqual( 1 );
+ // Click notice dismiss button to cover onRemove
+ const dismissBtn = document.querySelector(
+ '.components-notice__dismiss'
+ );
+ expect( dismissBtn ).toBeInTheDocument();
+ fireEvent.click( dismissBtn! );
+
+ // Verify notice is gone
+ expect(
+ document.querySelector( '.components-notice' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'copies URL and shows Copied feedback on Copy URL button click', async () => {
+ render( );
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Click Copy URL button
+ const copyBtn = screen.getByRole( 'button', { name: 'Copy URL' } );
+ fireEvent.click( copyBtn );
+
+ // Verify clipboard was called
+ expect( navigator.clipboard.writeText ).toHaveBeenCalledWith(
+ 'https://s3.amazonaws.com/bucket/custom-woo-addon.2.1.0.zip'
+ );
+
+ // Verify button text changed to Copied
+ expect(
+ screen.getByRole( 'button', { name: 'Copied URL' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows selected file name after choosing a zip file', async () => {
+ render( );
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Select a zip file
+ const fileInput = screen.getByTestId(
+ 'form-file-upload-input'
+ ) as HTMLInputElement;
+ const file = new File( [ 'dummy' ], 'my-plugin.zip', {
+ type: 'application/zip',
+ } );
+ fireEvent.change( fileInput, { target: { files: [ file ] } } );
+
+ // Verify file name is displayed
+ expect(
+ screen.getByText( /Selected file:.*my-plugin\.zip/i )
+ ).toBeInTheDocument();
+
+ // Verify Upload button is now enabled
+ const uploadBtn = screen.getByRole( 'button', {
+ name: 'Upload & Install Plugin',
+ } );
+ expect( uploadBtn ).not.toBeDisabled();
+ } );
+
+ it( 'supports keyboard accessibility and bulk selection in the apply wizard modal', async () => {
+ render( );
+ await screen.findByText( 'Custom Woo Addon' );
+
+ // Open apply plugins wizard modal
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Install Plugins' } )
+ );
+
+ // Step 1: Plugin Selection Modal
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Plugins to Install',
+ } )
+ ).toBeInTheDocument();
+
+ // Find the plugin row button (Custom Woo Addon)
+ const pluginButton = screen.getByRole( 'button', {
+ name: /Custom Woo Addon/i,
+ } );
+
+ // Press key 'Enter' on the plugin row button
+ fireEvent.keyDown( pluginButton, { key: 'Enter', code: 'Enter' } );
+
+ // Verify it got selected (Clear Selection button becomes enabled)
+ const clearBtn = screen.getByRole( 'button', {
+ name: 'Clear Selection',
+ } );
+ expect( clearBtn ).not.toBeDisabled();
+
+ // Click Clear Selection
+ fireEvent.click( clearBtn );
+ expect( clearBtn ).toBeDisabled();
+
+ // Toggle select all plugins checkbox
+ const selectAllCheckbox = screen.getByLabelText( 'Select All Plugins' );
+ fireEvent.click( selectAllCheckbox );
+ expect( clearBtn ).not.toBeDisabled();
+
+ // Click Next: Select Sites
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Next: Select Sites' } )
+ );
+
+ // Step 2: Site Selection Modal
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Select Sites for Installation',
+ } )
+ ).toBeInTheDocument();
+
+ // Find site row
+ const siteRow = await screen.findByRole( 'button', {
+ name: /Shared Brand Site/i,
+ } );
+
+ // Press key 'Enter' on the site row button
+ fireEvent.keyDown( siteRow, { key: 'Enter', code: 'Enter' } );
+
+ // Verify Install Plugins button is enabled (since site is selected)
+ const installBtn = screen.getByRole( 'button', {
+ name: 'Install Plugins',
+ } );
+ expect( installBtn ).not.toBeDisabled();
+ } );
+} );
diff --git a/tests/js/components/SiteModal.test.tsx b/tests/js/components/SiteModal.test.tsx
new file mode 100644
index 0000000..3ac2208
--- /dev/null
+++ b/tests/js/components/SiteModal.test.tsx
@@ -0,0 +1,222 @@
+/**
+ * External dependencies
+ */
+import { useState } from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import SiteModal from '@/components/SiteModal';
+import { defaultBrandSite } from '@/admin/settings/page';
+
+const baseFormData = {
+ ...defaultBrandSite,
+ name: 'Brand Site',
+ url: 'https://brand.example.com',
+ api_key: 'secret-key',
+ gh_repo: 'org/repo-a',
+};
+
+const mockRepos = [
+ {
+ slug: 'org/repo-a',
+ url: 'https://github.com/org/repo-a',
+ name: 'repo-a',
+ },
+ {
+ slug: 'org/repo-b',
+ url: 'https://github.com/org/repo-b',
+ name: 'repo-b',
+ },
+];
+
+function SiteModalHarness( {
+ initialData = baseFormData,
+ editing = false,
+ sites = [],
+ originalData,
+ onSubmit = jest.fn().mockResolvedValue( true ),
+ onClose = jest.fn(),
+}: {
+ initialData?: typeof defaultBrandSite;
+ editing?: boolean;
+ sites?: Array< typeof defaultBrandSite >;
+ originalData?: typeof defaultBrandSite;
+ onSubmit?: jest.Mock< Promise< boolean >, [] >;
+ onClose?: jest.Mock< void, [] >;
+} ) {
+ const [ formData, setFormData ] = useState( initialData );
+
+ return (
+
+ );
+}
+
+describe( 'SiteModal', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'disables submit when editing without changes', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: /Update Site/i } )
+ ).toBeDisabled();
+ } );
+
+ it( 'enables submit when editing with changes', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: /Update Site/i } )
+ ).toBeEnabled();
+ } );
+
+ it( 'shows validation feedback for invalid input', async () => {
+ render(
+
+ );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: /Add Site/i,
+ } );
+ fireEvent.click( submitButton );
+
+ // We check DOM directly since WordPress notices can render twice/or with a11y regions
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ 'Site Name must be under 20 characters.'
+ );
+ } );
+ } );
+
+ it( 'prevents duplicate site urls after a successful health check', async () => {
+ const onSubmit = jest.fn().mockResolvedValue( true );
+ global.fetch = jest.fn().mockResolvedValue( {
+ json: jest.fn().mockResolvedValue( { success: true } ),
+ } ) as typeof fetch;
+
+ render(
+
+ );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: /Add Site/i,
+ } );
+ fireEvent.click( submitButton );
+
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ 'Site URL already exists. Please use a different URL.'
+ );
+ } );
+
+ expect( onSubmit ).not.toHaveBeenCalled();
+ } );
+
+ it( 'triggers onSubmit callback after successful health check', async () => {
+ const onSubmit = jest.fn().mockResolvedValue( true );
+ global.fetch = jest.fn().mockResolvedValue( {
+ json: jest.fn().mockResolvedValue( { success: true } ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: /Add Site/i,
+ } );
+ fireEvent.click( submitButton );
+
+ await waitFor( () => {
+ expect( global.fetch ).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'https://brand.example.com/wp-json/oneupdate/v1/health-check'
+ ),
+ expect.objectContaining( {
+ method: 'GET',
+ headers: expect.objectContaining( {
+ 'X-OneUpdate-Token': 'secret-key',
+ } ),
+ } )
+ );
+ } );
+
+ await waitFor( () => {
+ expect( onSubmit ).toHaveBeenCalled();
+ } );
+ } );
+
+ it( 'shows error notice when health check fails', async () => {
+ const onSubmit = jest.fn();
+ global.fetch = jest.fn().mockResolvedValue( {
+ json: jest.fn().mockResolvedValue( { success: false } ),
+ } ) as typeof fetch;
+
+ render( );
+
+ const submitButton = screen.getByRole( 'button', {
+ name: /Add Site/i,
+ } );
+ fireEvent.click( submitButton );
+
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ "Health check failed, please verify API key and make sure there's no governing site connected."
+ );
+ } );
+
+ expect( onSubmit ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/tests/js/components/SiteSettings.test.tsx b/tests/js/components/SiteSettings.test.tsx
new file mode 100644
index 0000000..ba3c23b
--- /dev/null
+++ b/tests/js/components/SiteSettings.test.tsx
@@ -0,0 +1,212 @@
+/**
+ * External dependencies
+ */
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import SiteSettings from '@/components/SiteSettings';
+
+describe( 'SiteSettings', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ const setupFetchMock = ( {
+ secretKey = 'my-api-key',
+ governingSiteUrl = 'https://governing.com',
+ deleteSuccess = true,
+ postSecretKey = 'new-api-key',
+ } = {} ) => {
+ global.fetch = jest
+ .fn()
+ .mockImplementation( ( url: string, options?: RequestInit ) => {
+ if ( url.includes( '/secret-key' ) ) {
+ if ( options?.method === 'POST' ) {
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ secret_key: postSecretKey,
+ } ),
+ } );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( { secret_key: secretKey } ),
+ } );
+ }
+ if ( url.includes( '/governing-site' ) ) {
+ if ( options?.method === 'DELETE' ) {
+ return Promise.resolve( {
+ ok: deleteSuccess,
+ json: () =>
+ Promise.resolve( { success: deleteSuccess } ),
+ } );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ governing_site_url: governingSiteUrl,
+ } ),
+ } );
+ }
+ return Promise.reject(
+ new Error( `Unhandled mock URL: ${ url }` )
+ );
+ } ) as typeof fetch;
+ };
+
+ it( 'renders loading spinner initially, then displays settings on mount', async () => {
+ setupFetchMock();
+
+ render( );
+
+ // Wait for data load (buttons render after loading is false)
+ await screen.findByRole( 'button', { name: /Copy API Key/i } );
+
+ const apiKeyInput = document.querySelector( 'textarea' );
+ expect( apiKeyInput ).toHaveValue( 'my-api-key' );
+ expect( screen.getByLabelText( /Governing Site URL/i ) ).toHaveValue(
+ 'https://governing.com'
+ );
+ } );
+
+ it( 'copies API key to clipboard', async () => {
+ const writeTextMock = jest.fn().mockResolvedValue( undefined );
+ Object.defineProperty( navigator, 'clipboard', {
+ value: { writeText: writeTextMock },
+ configurable: true,
+ writable: true,
+ } );
+
+ setupFetchMock( { secretKey: 'my-api-key', governingSiteUrl: '' } );
+
+ render( );
+
+ const copyButton = await screen.findByRole( 'button', {
+ name: /Copy API Key/i,
+ } );
+ fireEvent.click( copyButton );
+
+ await waitFor( () => {
+ expect( writeTextMock ).toHaveBeenCalledWith( 'my-api-key' );
+ } );
+
+ // Verify success notice is displayed
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ 'API key copied to clipboard.'
+ );
+ } );
+ } );
+
+ it( 'regenerates API Key on button click', async () => {
+ setupFetchMock( {
+ secretKey: 'old-api-key',
+ postSecretKey: 'new-api-key',
+ governingSiteUrl: '',
+ } );
+
+ render( );
+
+ // Wait for data load
+ await screen.findByRole( 'button', { name: /Copy API Key/i } );
+
+ const apiKeyInput = document.querySelector( 'textarea' );
+ expect( apiKeyInput ).toHaveValue( 'old-api-key' );
+
+ const regenerateButton = screen.getByRole( 'button', {
+ name: /Regenerate API Key/i,
+ } );
+ fireEvent.click( regenerateButton );
+
+ await waitFor( () => {
+ expect( global.fetch ).toHaveBeenLastCalledWith(
+ expect.stringContaining( '/oneupdate/v1/secret-key' ),
+ expect.objectContaining( { method: 'POST' } )
+ );
+ } );
+
+ await waitFor( () => {
+ expect( apiKeyInput ).toHaveValue( 'new-api-key' );
+ } );
+
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ 'API key regenerated successfully.'
+ );
+ } );
+ } );
+
+ it( 'shows disconnect governing site confirmation modal and deletes connection', async () => {
+ setupFetchMock( {
+ secretKey: 'my-api-key',
+ governingSiteUrl: 'https://governing.com',
+ deleteSuccess: true,
+ } );
+
+ render( );
+
+ const disconnectButton = await screen.findByRole( 'button', {
+ name: /Disconnect Governing Site/i,
+ } );
+ fireEvent.click( disconnectButton );
+
+ // Confirm dialog is shown
+ const dialog = screen.getByRole( 'dialog', {
+ name: 'Disconnect Governing Site',
+ } );
+ expect(
+ within( dialog ).getByText(
+ /Are you sure you want to disconnect from the governing site?/i
+ )
+ ).toBeInTheDocument();
+
+ // Click confirm Disconnect button inside dialog
+ const confirmDisconnect = within( dialog ).getByRole( 'button', {
+ name: /^Disconnect$/i,
+ } );
+ fireEvent.click( confirmDisconnect );
+
+ await waitFor( () => {
+ expect( global.fetch ).toHaveBeenLastCalledWith(
+ expect.stringContaining( '/oneupdate/v1/governing-site' ),
+ expect.objectContaining( { method: 'DELETE' } )
+ );
+ } );
+
+ // Verify notice and inputs updated
+ await waitFor( () => {
+ const noticeEl = document.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeEl ).toBeInTheDocument();
+ expect( noticeEl?.textContent ).toContain(
+ 'Governing site disconnected successfully.'
+ );
+ } );
+
+ // Check that the input has been cleared
+ expect( screen.getByLabelText( /Governing Site URL/i ) ).toHaveValue(
+ ''
+ );
+ } );
+} );
diff --git a/tests/js/components/SiteTable.test.tsx b/tests/js/components/SiteTable.test.tsx
new file mode 100644
index 0000000..c330922
--- /dev/null
+++ b/tests/js/components/SiteTable.test.tsx
@@ -0,0 +1,183 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, within } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import SiteTable from '@/components/SiteTable';
+import type { BrandSite } from '@/admin/settings/page';
+
+describe( 'SiteTable', () => {
+ const onEditMock = jest.fn();
+ const onDeleteMock = jest.fn();
+ const setFormDataMock = jest.fn();
+ const setShowModalMock = jest.fn();
+
+ const mockSites: BrandSite[] = [
+ {
+ name: 'Site A',
+ url: 'https://sitea.com',
+ api_key: 'apikeyabcdefghijklmn',
+ gh_repo: 'org/repo-a',
+ },
+ {
+ name: 'Site B',
+ url: 'https://siteb.com',
+ api_key: 'apikey1234567890',
+ gh_repo: 'org/repo-b',
+ },
+ ];
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'displays "No Brand Sites found." when sites list is empty', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText( /No Brand Sites found./i )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders sites list in a table', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Site A' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'https://sitea.com' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'org/repo-a' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'apikeyabcd...' ) ).toBeInTheDocument();
+
+ expect( screen.getByText( 'Site B' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'https://siteb.com' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'org/repo-b' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'apikey1234...' ) ).toBeInTheDocument();
+ } );
+
+ it( 'opens site modal when Add Brand Site is clicked', () => {
+ render(
+
+ );
+
+ const addButton = screen.getByRole( 'button', {
+ name: /Add Brand Site/i,
+ } );
+ fireEvent.click( addButton );
+
+ expect( setShowModalMock ).toHaveBeenCalledWith( true );
+ } );
+
+ it( 'calls callbacks on Edit button click', () => {
+ render(
+
+ );
+
+ const editButtons = screen.getAllByRole( 'button', { name: /Edit/i } );
+ // Click edit for first site
+ fireEvent.click( editButtons[ 0 ] as HTMLElement );
+
+ expect( setFormDataMock ).toHaveBeenCalledWith( mockSites[ 0 ] );
+ expect( onEditMock ).toHaveBeenCalledWith( 0 );
+ expect( setShowModalMock ).toHaveBeenCalledWith( true );
+ } );
+
+ it( 'shows and cancels delete confirmation modal', () => {
+ render(
+
+ );
+
+ const deleteButtons = screen.getAllByRole( 'button', {
+ name: /Delete/i,
+ } );
+ fireEvent.click( deleteButtons[ 0 ] as HTMLElement );
+
+ const dialog = screen.getByRole( 'dialog', {
+ name: 'Delete Brand Site',
+ } );
+ expect(
+ within( dialog ).getByText(
+ 'Are you sure you want to delete this Brand Site? This action cannot be undone.'
+ )
+ ).toBeInTheDocument();
+
+ // Click Cancel
+ const cancelButton = within( dialog ).getByRole( 'button', {
+ name: /Cancel/i,
+ } );
+ fireEvent.click( cancelButton );
+
+ // Confirm modal is closed
+ expect(
+ screen.queryByRole( 'dialog', { name: 'Delete Brand Site' } )
+ ).not.toBeInTheDocument();
+ expect( onDeleteMock ).not.toHaveBeenCalled();
+ } );
+
+ it( 'shows delete modal and triggers onDelete callback upon confirming delete', () => {
+ render(
+
+ );
+
+ const deleteButtons = screen.getAllByRole( 'button', {
+ name: /Delete/i,
+ } );
+ fireEvent.click( deleteButtons[ 1 ] as HTMLElement );
+
+ const dialog = screen.getByRole( 'dialog', {
+ name: 'Delete Brand Site',
+ } );
+
+ // Confirmation modal delete button
+ const confirmDeleteButton = within( dialog ).getByRole( 'button', {
+ name: /Delete/i,
+ } );
+ fireEvent.click( confirmDeleteButton );
+
+ expect( onDeleteMock ).toHaveBeenCalledWith( 1 );
+ expect(
+ screen.queryByRole( 'dialog', { name: 'Delete Brand Site' } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/tests/js/setup.ts b/tests/js/setup.ts
index 6213698..bce8d0b 100644
--- a/tests/js/setup.ts
+++ b/tests/js/setup.ts
@@ -1,8 +1,12 @@
+/* eslint-disable no-console */
/**
* External dependencies
*/
import '@testing-library/jest-dom';
+console.warn = jest.fn();
+console.error = jest.fn();
+
const fetchMock = jest.fn<
ReturnType< typeof fetch >,
Parameters< typeof fetch >
diff --git a/tests/js/utils.test.ts b/tests/js/utils.test.ts
new file mode 100644
index 0000000..8252f03
--- /dev/null
+++ b/tests/js/utils.test.ts
@@ -0,0 +1,54 @@
+/**
+ * Internal dependencies
+ */
+/**
+ * External dependencies
+ */
+import { isURL, isValidUrl, PurifyElement } from '@/js/utils';
+
+describe( 'utils', () => {
+ describe( 'isURL', () => {
+ it( 'should return true for valid URLs', () => {
+ expect( isURL( 'https://example.com' ) ).toBe( true );
+ expect( isURL( 'http://localhost:3000/path?query=1' ) ).toBe(
+ true
+ );
+ expect( isURL( 'ftp://ftp.example.com' ) ).toBe( true );
+ } );
+
+ it( 'should return false for invalid URLs', () => {
+ expect( isURL( 'example.com' ) ).toBe( false );
+ expect( isURL( 'not-a-url' ) ).toBe( false );
+ expect( isURL( '' ) ).toBe( false );
+ } );
+ } );
+
+ describe( 'isValidUrl', () => {
+ it( 'should return true for valid URL string structures', () => {
+ expect( isValidUrl( 'https://example.com' ) ).toBe( true );
+ expect( isValidUrl( 'http://example.org/dir/file.html' ) ).toBe(
+ true
+ );
+ } );
+
+ it( 'should return false for invalid URL string structures', () => {
+ expect( isValidUrl( 'invalid-url' ) ).toBe( false );
+ expect( isValidUrl( '' ) ).toBe( false );
+ } );
+ } );
+
+ describe( 'PurifyElement', () => {
+ it( 'should strip HTML tags from input string', () => {
+ expect(
+ PurifyElement( 'Hello World
' )
+ ).toBe( 'Hello World' );
+ expect(
+ PurifyElement( 'Safe Content' )
+ ).toBe( 'Safe Content' );
+ } );
+
+ it( 'should return empty string when empty input is provided', () => {
+ expect( PurifyElement( '' ) ).toBe( '' );
+ } );
+ } );
+} );
diff --git a/tsconfig.json b/tsconfig.json
index df1894a..289418a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,8 @@
"paths": {
"@/*": [ "./assets/src/*" ]
},
- "types": [ "jest", "node" ]
+ "types": [ "jest", "node" ],
+ "checkJs": false
},
"include": [
"**/*.d.ts",
@@ -16,7 +17,11 @@
"**/*.mjs",
"**/*.ts",
"**/*.tsx",
- "**/*.json"
+ "**/*.json",
+ "assets/src/components/PluginCard.js",
+ "assets/src/components/PluginGrid.js",
+ "assets/src/components/PluginsSharing.js",
+ "assets/src/components/S3ZipUploader.js"
],
"exclude": [
"**/build/*",