From be0e5f4213ca2a1c0f645964f1b16971c34579b1 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 14:52:47 +0530 Subject: [PATCH 01/12] test: add utils test and update setup configuration --- tests/js/setup.ts | 4 ++++ tests/js/utils.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/js/utils.test.ts 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( '' ); + } ); + } ); +} ); From 5833090ee80a82a12e6376d6f3976e43120bdaa5 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 14:52:50 +0530 Subject: [PATCH 02/12] test: add unit tests for frontend components (SiteModal, SiteTable, SiteSettings, S3Credentials, GitHubRepoToken) --- tests/js/components/GitHubRepoToken.test.tsx | 205 +++++++++++++++++ tests/js/components/S3Credentials.test.tsx | 215 ++++++++++++++++++ tests/js/components/SiteModal.test.tsx | 222 +++++++++++++++++++ tests/js/components/SiteSettings.test.tsx | 212 ++++++++++++++++++ tests/js/components/SiteTable.test.tsx | 183 +++++++++++++++ 5 files changed, 1037 insertions(+) create mode 100644 tests/js/components/GitHubRepoToken.test.tsx create mode 100644 tests/js/components/S3Credentials.test.tsx create mode 100644 tests/js/components/SiteModal.test.tsx create mode 100644 tests/js/components/SiteSettings.test.tsx create mode 100644 tests/js/components/SiteTable.test.tsx 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/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/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(); + } ); +} ); From 261c50eaec73431b5597e8b574f6ff2d5843c694 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 14:52:52 +0530 Subject: [PATCH 03/12] test: add onboarding and settings page unit tests --- tests/js/OnboardingPage.test.tsx | 96 ++++++++++++ tests/js/SettingsPage.test.tsx | 254 +++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 tests/js/OnboardingPage.test.tsx create mode 100644 tests/js/SettingsPage.test.tsx 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: [] }, + } ); + } ); + } ); +} ); From c97f19d44b3dbda6ebeef18b0971672a398cdd96 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 15:04:55 +0530 Subject: [PATCH 04/12] build: configure tsconfig to ignore type checks on legacy JS components --- tsconfig.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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/*", From aa1cec77dd435bd8230f8f68e03c71ab56b6f2b0 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 15:04:57 +0530 Subject: [PATCH 05/12] test: add unit tests for PluginCard legacy component --- tests/js/components/PluginCard.test.tsx | 186 ++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/js/components/PluginCard.test.tsx 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' + ); + } ); +} ); From 5d2e738c876ecfa67da336d673fd9bb50d3408bb Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 15:05:00 +0530 Subject: [PATCH 06/12] test: add unit tests for PluginGrid and PluginsSharing components --- tests/js/components/PluginGrid.test.tsx | 331 ++++++++++++++++++++ tests/js/components/PluginsSharing.test.tsx | 260 +++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 tests/js/components/PluginGrid.test.tsx create mode 100644 tests/js/components/PluginsSharing.test.tsx 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..db023ea --- /dev/null +++ b/tests/js/components/PluginsSharing.test.tsx @@ -0,0 +1,260 @@ +/** + * 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', + }; +} ); + +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', + }, + 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(); + } ); + } ); +} ); From 728516a2ade3f77eea4c69970e93f5a059c05d6b Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 15:15:19 +0530 Subject: [PATCH 07/12] test: add unit tests for legacy S3ZipUploader component --- tests/js/components/S3ZipUploader.test.tsx | 282 +++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 tests/js/components/S3ZipUploader.test.tsx diff --git a/tests/js/components/S3ZipUploader.test.tsx b/tests/js/components/S3ZipUploader.test.tsx new file mode 100644 index 0000000..ec686ea --- /dev/null +++ b/tests/js/components/S3ZipUploader.test.tsx @@ -0,0 +1,282 @@ +/** + * 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 ); + } ); +} ); From 9c3c4436d7fdfe8dc97a833e31ae09e65d6df898 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 16:09:37 +0530 Subject: [PATCH 08/12] build: exclude static component icons from jest coverage collection --- jest.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jest.config.js b/jest.config.js index 41dd817..7533384 100644 --- a/jest.config.js +++ b/jest.config.js @@ -63,6 +63,9 @@ module.exports = { '!assets/src/**/index.{js,tsx,jsx}', // Exclude style files '!assets/src/**/*.{css,scss}', + // Exclude static SVG icon components + '!assets/src/components/icons/**', + '!assets/src/componenets/icons/**', ], // Coverage output directory From e00730651ac6ef298069ad31faa0c714a26de7c4 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 16:09:39 +0530 Subject: [PATCH 09/12] build: ignore static component icons directory in Codecov reports --- .github/.codecov.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/.codecov.yml b/.github/.codecov.yml index b349265..72a255f 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/componenets/icons/**' comment: layout: 'reach,diff,flags,files' From 6f9eec7cb77805e522dd28949409ca15ba056cf4 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 16:13:06 +0530 Subject: [PATCH 10/12] chore: update coverage ignore patterns to include js files and remove typo paths --- .github/.codecov.yml | 2 +- jest.config.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/.codecov.yml b/.github/.codecov.yml index 72a255f..83fbafc 100644 --- a/.github/.codecov.yml +++ b/.github/.codecov.yml @@ -26,7 +26,7 @@ ignore: - '**/types.d.ts' - 'playwright.config.ts' - 'assets/src/components/icons/**' - - 'assets/src/componenets/icons/**' + - 'assets/src/js/**.js' comment: layout: 'reach,diff,flags,files' diff --git a/jest.config.js b/jest.config.js index 7533384..1450699 100644 --- a/jest.config.js +++ b/jest.config.js @@ -65,7 +65,8 @@ module.exports = { '!assets/src/**/*.{css,scss}', // Exclude static SVG icon components '!assets/src/components/icons/**', - '!assets/src/componenets/icons/**', + // Exclude the js files + '!assets/src/js/**.js', ], // Coverage output directory From 5201c57c28242e27d7cb86f2c9d220ffd0bbb634 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 17:13:21 +0530 Subject: [PATCH 11/12] test: add integration tests for plugin sharing and S3 zip uploader error states --- tests/js/components/PluginsSharing.test.tsx | 221 +++++++++++++++- tests/js/components/S3ZipUploader.test.tsx | 270 +++++++++++++++++++- 2 files changed, 489 insertions(+), 2 deletions(-) diff --git a/tests/js/components/PluginsSharing.test.tsx b/tests/js/components/PluginsSharing.test.tsx index db023ea..1450a87 100644 --- a/tests/js/components/PluginsSharing.test.tsx +++ b/tests/js/components/PluginsSharing.test.tsx @@ -12,6 +12,33 @@ beforeAll( () => { }; } ); +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', () => { @@ -26,7 +53,9 @@ describe( 'PluginsSharing', () => { '1x': 'https://example.com/seo.png', }, versions: { - '3.4.1': 'zip-url', + '3.4.1': 'zip-url-1', + '3.4.0': 'zip-url-2', + '3.3.0': 'zip-url-3', }, author: 'SEO Team', }, @@ -257,4 +286,194 @@ describe( 'PluginsSharing', () => { ).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/S3ZipUploader.test.tsx b/tests/js/components/S3ZipUploader.test.tsx index ec686ea..645a018 100644 --- a/tests/js/components/S3ZipUploader.test.tsx +++ b/tests/js/components/S3ZipUploader.test.tsx @@ -227,7 +227,9 @@ describe( 'S3ZipUploader', () => { // Step 1: Plugin Selection Modal expect( - screen.getByRole( 'heading', { name: 'Select Plugins to Install' } ) + screen.getByRole( 'heading', { + name: 'Select Plugins to Install', + } ) ).toBeInTheDocument(); // Select the plugin wrapper button in history list @@ -279,4 +281,270 @@ describe( 'S3ZipUploader', () => { ); 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(); + } ); } ); From 44b833348970fea6d8b4f1c57f818e02751d9590 Mon Sep 17 00:00:00 2001 From: Milind More Date: Mon, 6 Jul 2026 19:37:46 +0530 Subject: [PATCH 12/12] test: add e2e plugin activation test and configure web server in playwright settings --- playwright.config.ts | 6 +++ tests/e2e/settings/activation.spec.ts | 67 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/e2e/settings/activation.spec.ts 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 } ); + } ); +} );