Page content before shortcode.
\n\n${shortcode}\n\nPage content after shortcode.
` try { @@ -50,48 +50,78 @@ const createPageWithShortcode = async (snippetId: string): PromiseThis content was inserted via shortcode!
Hello World HTML snippet in footer!
', type: 'HTML', location: 'SITE_FOOTER' @@ -146,7 +176,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet is evaluating correctly in header', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: 'Hello World HTML snippet in header!
', type: 'HTML', location: 'SITE_HEADER' @@ -158,13 +188,13 @@ test.describe('Code Snippets Evaluation', () => { }) test('HTML snippet works with shortcode in editor', async ({ page }) => { - const snippetId = await createHtmlSnippetForEditor(helper, page) - const pageUrl = await createPageWithShortcode(snippetId) + const snippetId = await createHtmlSnippetForEditor(helper, page, snippetName) + const pageUrl = await createPageWithShortcode(page, snippetId, snippetName) await verifyShortcodeRendersCorrectly(helper, page, pageUrl) }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 07ecedf54..d5eb1a8ac 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -2,100 +2,134 @@ import { expect, test } from '@playwright/test' import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' -const TEST_SNIPPET_NAME = 'E2E List Test Snippet' - test.describe('Code Snippets List Page Actions', () => { let helper: SnippetsTestHelper + let snippetName: string + const EXPORT_TEST_TIMEOUT_MS = 60000 test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.navigateToSnippetsAdmin() await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToSnippetsAdmin() }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) test('Can toggle snippet activation from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const toggleSwitch = snippetRow.locator('a.snippet-activation-switch') - - await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') - - await toggleSwitch.click() - await page.waitForLoadState('networkidle') - - const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const updatedToggle = updatedRow.locator('a.snippet-activation-switch') - await expect(updatedToggle).toHaveAttribute('title', 'Activate') - - await updatedToggle.click() - await page.waitForLoadState('networkidle') - - const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const reactivatedToggle = reactivatedRow.locator('a.snippet-activation-switch') - await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const toggleCell = snippetRow.locator('td').first() + const toggleCheckbox = toggleCell.getByRole('checkbox').first() + + const initialChecked = await toggleCheckbox.isChecked() + await expect(toggleCell).toContainText(initialChecked ? 'Deactivate' : 'Activate') + + await toggleCheckbox.click({ force: true }) + if (initialChecked) { + await expect(toggleCheckbox).not.toBeChecked() + } else { + await expect(toggleCheckbox).toBeChecked() + } + await expect(toggleCell).toContainText(!initialChecked ? 'Deactivate' : 'Activate') + + await toggleCheckbox.click({ force: true }) + if (initialChecked) { + await expect(toggleCheckbox).toBeChecked() + } else { + await expect(toggleCheckbox).not.toBeChecked() + } + await expect(toggleCell).toContainText(initialChecked ? 'Deactivate' : 'Activate') }) test('Can access edit from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - await snippetRow.locator(SELECTORS.EDIT_ACTION).click() + await snippetRow.locator(SELECTORS.SNIPPET_NAME_LINK).first().click() await expect(page).toHaveURL(/page=edit-snippet/) - await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) + await expect(page.locator('#title')).toHaveValue(snippetName) }) test('Can clone snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() await snippetRow.locator(SELECTORS.CLONE_ACTION).click() - await page.waitForLoadState('networkidle') await expect(page).toHaveURL(/page=snippets/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() - await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) - - const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) - - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + // Verify that a cloned snippet exists in the table (use table-scoped check to avoid admin bar matches) + const clonedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName} [CLONE]"))`) + .first() + await expect(clonedRow).toBeVisible() + // Clean up the clone by trashing it await clonedRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() }) test('Can delete snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + // Click "Trash" in row actions — in the new React UI, this moves to trash immediately (no dialog) await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + + // Some implementations show a confirmation modal that must be dismissed. + const confirmDialog = page.locator('[role="dialog"]').filter({ hasText: /Are you sure\\?/i }) + const dialogVisible = await confirmDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await confirmDialog.locator('button:has-text("Trash"), button:has-text("Delete")').first().click() + await confirmDialog.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => undefined) + } await expect(page).toHaveURL(/page=snippets/) - await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + // Navigate to the trash view using the new filter link format + const trashedLink = page.locator('a[href*="status=trashed"]').first() + await expect(trashedLink).toBeVisible() + await trashedLink.click() + + await expect(page).toHaveURL(/status=trashed/, { timeout: 30000 }) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + const trashedRow = page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${snippetName}")`).first() + await expect(trashedRow).toBeVisible({ timeout: 30000 }) + await expect(trashedRow).toContainText(/Restore/i) }) test('Can export snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - - const downloadPromise = page.waitForEvent('download') + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - await snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + const download = await Promise.all([ + page.waitForEvent('download'), + snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + ]).then(([downloadEvent]) => downloadEvent) - const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.json$/) }) }) diff --git a/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts new file mode 100644 index 000000000..366f979d0 --- /dev/null +++ b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts @@ -0,0 +1,239 @@ +import { expect, test } from '@playwright/test' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { wpCli } from './helpers/wpCli' +import type { Page } from '@playwright/test' + +const QUICKNAV_PREFIX = 'E2E QuickNav' +const QUICKNAV_PER_PAGE = 2 +const QUICKNAV_TEST_TIMEOUT_MS = 180000 + +test.describe('Admin Bar Snippets QuickNav', () => { + let activeA: string + let activeB: string + let activeC: string + let inactiveB: string + let inactiveC: string + let inactiveA: string + + test.beforeAll(async () => { + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + + activeA = `${QUICKNAV_PREFIX} Active A` + activeB = `${QUICKNAV_PREFIX} Active B` + activeC = `${QUICKNAV_PREFIX} Active C` + inactiveA = `${QUICKNAV_PREFIX} Inactive A` + inactiveB = `${QUICKNAV_PREFIX} Inactive B` + inactiveC = `${QUICKNAV_PREFIX} Inactive Z HTML` + + await SnippetsTestHelper.createSnippetViaCli({ name: activeA, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeB, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeC, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveA, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveB, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveC, active: false, type: 'html' }) + }) + + test.afterAll(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + }) + + const openListing = async (page: Page, query: string) => { + await page.goto(`/wp-admin/admin.php?page=snippets${query}`) + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + } + + const getTotalPagesForListing = async (page: Page, status: 'active' | 'inactive') => { + const node = page.locator(`#wp-admin-bar-code-snippets-${status}-snippets`) + await node.hover() + + const controls = node.locator(`.code-snippets-pagination-controls[data-status="${status}"]`).first() + const totalPagesAttr = await controls.getAttribute('data-total-pages').catch(() => null) + const parsed = totalPagesAttr ? Number(totalPagesAttr) : NaN + return Number.isFinite(parsed) && 0 < parsed ? parsed : 1 + } + + const expectSnippetVisibleInListingPages = async ( + page: Page, + options: { status: 'active' | 'inactive'; queryArg: string; snippetName: string } + ) => { + await openListing(page, '') + + const totalPages = await getTotalPagesForListing(page, options.status) + + for (let pageNo = 1; pageNo <= totalPages; pageNo++) { + await openListing(page, `&${options.queryArg}=${pageNo}`) + + const node = page.locator(`#wp-admin-bar-code-snippets-${options.status}-snippets`) + await node.hover() + + const items = node.locator('li.code-snippets-snippet-item a') + await items.first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => null) + + const match = items.filter({ hasText: options.snippetName }).first() + if (await match.isVisible().catch(() => false)) { + await expect(match).toBeVisible({ timeout: 30000 }) + return + } + } + + throw new Error(`Snippet not found in ${options.status} listing after checking ${totalPages} page(s): ${options.snippetName}`) + } + + test('Menu structure, gating, and pagination work', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-manage')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-add')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-import')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-settings')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-active-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-inactive-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode-doc')).toBeVisible() + + const safeModeDocLink = page.locator('#wp-admin-bar-code-snippets-safe-mode-doc a').first() + await expect(safeModeDocLink).toHaveAttribute('href', 'https://snipco.de/safe-mode') + await expect(safeModeDocLink).toHaveAttribute('target', '_blank') + + // Free vs Pro gating: CSS/JS/COND are disabled and lead to upgrade. + const cssNode = page.locator('#wp-admin-bar-code-snippets-add-css') + await expect(cssNode).toHaveClass(/code-snippets-disabled/) + await expect(cssNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + const jsNode = page.locator('#wp-admin-bar-code-snippets-add-js') + await expect(jsNode).toHaveClass(/code-snippets-disabled/) + await expect(jsNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + const condNode = page.locator('#wp-admin-bar-code-snippets-add-cond') + await expect(condNode).toHaveClass(/code-snippets-disabled/) + await expect(condNode.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + + // Pagination: perPage=2 and we created 3 active snippets. + const activeNode = page.locator('#wp-admin-bar-code-snippets-active-snippets') + await activeNode.hover() + + const activeControls = activeNode.locator('.code-snippets-pagination-controls[data-status="active"]') + await expect(activeControls).toBeVisible() + + const activeItems = activeNode.locator('li.code-snippets-snippet-item a') + await expect(activeItems.filter({ hasText: activeA })).toBeVisible() + await expect(activeItems.filter({ hasText: activeB })).toBeVisible() + await expect(activeItems.filter({ hasText: activeC })).not.toBeVisible() + + await expectSnippetVisibleInListingPages(page, { status: 'active', queryArg: 'code_snippets_ab_active_page', snippetName: activeC }) + + // Ensure titles are type-prefixed. + await expect(activeItems.filter({ hasText: activeC })).toContainText('(PHP)') + + // Inactive list exists and includes our inactive snippet. + const inactiveNode = page.locator('#wp-admin-bar-code-snippets-inactive-snippets') + await inactiveNode.hover() + const inactiveControls = inactiveNode.locator('.code-snippets-pagination-controls[data-status="inactive"]') + await expect(inactiveControls).toBeVisible() + + const inactiveItems = inactiveNode.locator('li.code-snippets-snippet-item a') + await expect(inactiveItems.first()).toBeVisible({ timeout: 30000 }) + + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveA + }) + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveC + }) + const inactiveCLink = page + .locator('#wp-admin-bar-code-snippets-inactive-snippets li.code-snippets-snippet-item a') + .filter({ hasText: inactiveC }) + .first() + await expect(inactiveCLink).toContainText('(HTML)') + }) + + test('Manage submenu contains status quick links', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + const manageNode = page.locator('#wp-admin-bar-code-snippets-manage') + await manageNode.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-status-all a')).toHaveAttribute('href', /page=snippets&status=all/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-active a')).toHaveAttribute('href', /page=snippets&status=active/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-inactive a')).toHaveAttribute('href', /page=snippets&status=inactive/) + }) + + test('QuickNav menu can be disabled via setting', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: false, perPage: QUICKNAV_PER_PAGE }) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + await expect(page.locator('#wp-admin-bar-code-snippets')).toHaveCount(0) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await helper.navigateToSnippetsAdmin() + await expect(page.locator('#wp-admin-bar-code-snippets')).toBeVisible() + }) + + test('Safe Mode indicator appears only when Safe Mode is active', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + const safeModeMuPluginPath = 'wp-content/mu-plugins/code-snippets-e2e-safe-mode.php' + + const removeMuPlugin = async () => { + await wpCli(['eval', `@unlink( ABSPATH . ${JSON.stringify(safeModeMuPluginPath)} );`]) + } + + await removeMuPlugin() + + await page.goto('/wp-admin/admin.php?page=snippets') + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode')).toHaveCount(0) + + try { + // Enable safe mode via a temporary mu-plugin so we don't rely on mutating wp-config.php. + await wpCli([ + 'eval', + ` + $path = ABSPATH . ${JSON.stringify(safeModeMuPluginPath)}; + wp_mkdir_p( dirname( $path ) ); + file_put_contents( + $path, + " { await page.goto(`${wpAdminbase}/admin.php?page=snippets-settings`) await page.waitForSelector('#wpbody-content') - await page.waitForSelector('form') + // Await page.waitForSelector('form') const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') await expect(flatFilesCheckbox).toBeVisible() @@ -17,10 +17,17 @@ setup('enable flat files', async ({ page }) => { await flatFilesCheckbox.check() } - await page.click('input[type="submit"][name="submit"]') + // Await page.click('input[type="submit"][name="submit"]') - await page.waitForSelector('.notice-success', { timeout: 10000 }) - await expect(page.locator('.notice-success')).toContainText('Settings saved') + // await page.waitForSelector('.notice-success', { timeout: 10000 }) + // await expect(page.locator('.notice-success')).toContainText('Settings saved') + const saveButton = page.getByRole('button', { name: 'Save Changes' }) + + await Promise.all([ + page.waitForURL(/settings-updated=true/, { timeout: 10000 }), + saveButton.click() + ]) + await page.reload() await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index d67b5e18c..daca19522 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -1,14 +1,32 @@ import { expect } from '@playwright/test' -import { - BUTTONS, - MESSAGES, - SELECTORS, - SNIPPET_LOCATIONS, - SNIPPET_TYPES, - TIMEOUTS, - URLS +import { + BUTTONS, + MESSAGES, + SELECTORS, + SNIPPET_LOCATIONS, + SNIPPET_TYPES, + TIMEOUTS, + URLS } from './constants' -import type { Page} from '@playwright/test' +import { wpCli } from './wpCli' +import type { Page } from '@playwright/test' + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const META_OR_CONTROL_A = 'darwin' === process.platform ? 'Meta+A' : 'Control+A' + +const RANDOM_RADIX = 36 +const RANDOM_SLICE_START = 2 +const RANDOM_SLICE_END = 7 +const CLICK_RETRIES = 3 +const AT_LEAST_ONE = 1 + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message + } + return String(error) +} export interface SnippetFormOptions { name: string; @@ -17,16 +35,179 @@ export interface SnippetFormOptions { location?: keyof typeof SNIPPET_LOCATIONS; } +export interface CreateSnippetCliOptions { + name: string; + active: boolean; + type?: 'php' | 'html' | 'css' | 'js' | 'cond'; +} + +export const DEFAULT_E2E_SNIPPET_BASE_NAME = 'E2E Snippet Test' + export class SnippetsTestHelper { - constructor(private page: Page) {} + constructor(private page: Page) { } + + static makeUniqueSnippetName(baseName: string = DEFAULT_E2E_SNIPPET_BASE_NAME): string { + return `${baseName} ${Date.now()}-${Math.random().toString(RANDOM_RADIX).slice(RANDOM_SLICE_START, RANDOM_SLICE_END)}` + } + + static async setAdminBarQuickNavSettings(options: { enabled: boolean; perPage: number }): Promise${options.name}
\n` : `// ${options.name}\n` + + const php = ` + $snippet = new \\Code_Snippets\\Model\\Snippet([ + 'name' => ${JSON.stringify(options.name)}, + 'desc' => '', + 'code' => ${JSON.stringify(code)}, + 'scope' => ${JSON.stringify(scope)}, + 'active' => ${options.active ? 'true' : 'false'}, + 'tags' => [], + ]); + \\Code_Snippets\\save_snippet($snippet); + ` + + await wpCli(['eval', php]) + } + + static async cleanupSnippetsByPrefix(prefix: string): Promise{$name}
\n" : + " $name, + 'code' => $code, + 'scope' => $scope, + 'active' => $active, + 'tags' => [], + ] + ); + + save_snippet( $snippet ); + return $snippet; + } + + /** + * Build an isolated admin bar instance for assertions. + * + * @return WP_Admin_Bar + */ + private function build_admin_bar(): WP_Admin_Bar { + if ( ! class_exists( 'WP_Admin_Bar' ) ) { + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + } + + $wp_admin_bar = new WP_Admin_Bar(); + + if ( method_exists( $wp_admin_bar, 'initialize' ) ) { + $wp_admin_bar->initialize(); + } + + return $wp_admin_bar; + } + + /** + * Read nodes from a WP_Admin_Bar instance. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return array + */ + private function get_nodes( WP_Admin_Bar $wp_admin_bar ): array { + if ( method_exists( $wp_admin_bar, 'get_nodes' ) ) { + return (array) $wp_admin_bar->get_nodes(); + } + + $ref = new \ReflectionClass( $wp_admin_bar ); + if ( $ref->hasProperty( 'nodes' ) ) { + $prop = $ref->getProperty( 'nodes' ); + $prop->setAccessible( true ); + return (array) $prop->getValue( $wp_admin_bar ); + } + + return []; + } + + /** + * Admin bar menu is hidden when the setting is disabled. + * + * @return void + */ + public function test_admin_bar_menu_is_disabled_by_setting(): void { + update_setting( 'general', 'enable_admin_bar', false ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + } + + /** + * Safe mode indicator is shown even if the main Snippets menu is disabled. + * + * @return void + */ + public function test_safe_mode_indicator_is_shown_even_when_menu_disabled(): void { + update_setting( 'general', 'enable_admin_bar', false ); + add_filter( 'code_snippets/execute_snippets', '__return_false' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-safe-mode' ) ); + } + + /** + * Pro-only snippet types should be disabled in the free plugin. + * + * @return void + */ + public function test_pro_types_are_disabled_when_unlicensed(): void { + update_setting( 'general', 'enable_admin_bar', true ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $php = $wp_admin_bar->get_node( 'code-snippets-add-php' ); + $this->assertNotNull( $php ); + $this->assertStringContainsString( 'type=php', $php->href ); + + $css = $wp_admin_bar->get_node( 'code-snippets-add-css' ); + $this->assertNotNull( $css ); + $this->assertStringContainsString( 'page=code_snippets_upgrade', $css->href ); + $this->assertArrayHasKey( 'class', $css->meta ); + $this->assertStringContainsString( 'code-snippets-disabled', (string) $css->meta['class'] ); + } + + /** + * Active/inactive snippet listings paginate and accept progressive enhancement query args. + * + * @return void + */ + public function test_snippet_listings_paginate_and_respect_query_arg(): void { + update_setting( 'general', 'admin_bar_snippet_limit', 2 ); + + $this->create_snippet( 'QuickNav Active A', true ); + $this->create_snippet( 'QuickNav Active B', true ); + $this->create_snippet( 'QuickNav Active C', true ); + + $this->create_snippet( 'QuickNav Inactive A', false ); + $this->create_snippet( 'QuickNav Inactive B', false ); + $this->create_snippet( 'QuickNav Inactive Z HTML', false, 'html' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-active-pagination' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-inactive-pagination' ) ); + + $nodes = $this->get_nodes( $wp_admin_bar ); + + $active_children = array_filter( + $nodes, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-active-snippets' === $node->parent + ); + + $active_titles = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $active_children ) + ); + + $active_titles = array_values( array_filter( $active_titles, static fn( $title ) => false !== strpos( $title, 'QuickNav Active' ) ) ); + $this->assertCount( 2, $active_titles ); + $this->assertStringContainsString( '(PHP) QuickNav Active A', $active_titles[0] ); + $this->assertStringContainsString( '(PHP) QuickNav Active B', $active_titles[1] ); + + $_GET['code_snippets_ab_active_page'] = 2; + + $wp_admin_bar_page_2 = $this->build_admin_bar(); + $admin_bar->register_nodes( $wp_admin_bar_page_2 ); + + $nodes_page_2 = $this->get_nodes( $wp_admin_bar_page_2 ); + $active_children_page_2 = array_filter( + $nodes_page_2, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-active-snippets' === $node->parent + ); + + $active_titles_page_2 = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $active_children_page_2 ) + ); + + $active_titles_page_2 = array_values( array_filter( $active_titles_page_2, static fn( $title ) => false !== strpos( $title, 'QuickNav Active' ) ) ); + $this->assertCount( 1, $active_titles_page_2 ); + $this->assertStringContainsString( '(PHP) QuickNav Active C', $active_titles_page_2[0] ); + + $_GET['code_snippets_ab_inactive_page'] = 2; + + $wp_admin_bar_inactive_page_2 = $this->build_admin_bar(); + $admin_bar->register_nodes( $wp_admin_bar_inactive_page_2 ); + + $nodes_inactive_page_2 = $this->get_nodes( $wp_admin_bar_inactive_page_2 ); + $inactive_children_page_2 = array_filter( + $nodes_inactive_page_2, + static fn( $node ) => isset( $node->parent ) && 'code-snippets-inactive-snippets' === $node->parent + ); + + $inactive_titles_page_2 = array_map( + static fn( $node ) => (string) ( $node->title ?? '' ), + array_values( $inactive_children_page_2 ) + ); + + $inactive_titles_page_2 = array_values( + array_filter( $inactive_titles_page_2, static fn( $title ) => false !== strpos( $title, 'QuickNav Inactive' ) ) + ); + + $this->assertCount( 1, $inactive_titles_page_2 ); + $this->assertStringContainsString( '(HTML) QuickNav Inactive Z HTML', $inactive_titles_page_2[0] ); + } + + /** + * Admin bar menu can be enabled via filter even when the setting is disabled. + * + * @return void + */ + public function test_admin_bar_menu_can_be_enabled_via_filter(): void { + update_setting( 'general', 'enable_admin_bar', false ); + add_filter( 'code_snippets/admin_bar/enabled', '__return_true' ); + + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets' ) ); + } + + /** + * QuickNav nodes include Manage status quick links. + * + * @return void + */ + public function test_manage_quick_links_are_registered(): void { + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-manage' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-all' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-active' ) ); + $this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-inactive' ) ); + } + + /** + * Safe mode documentation link is registered under the Snippets root node. + * + * @return void + */ + public function test_safe_mode_docs_link_is_registered(): void { + $wp_admin_bar = $this->build_admin_bar(); + $admin_bar = new Admin_Bar(); + $admin_bar->register_nodes( $wp_admin_bar ); + + $node = $wp_admin_bar->get_node( 'code-snippets-safe-mode-doc' ); + $this->assertNotNull( $node ); + $this->assertStringContainsString( 'https://snipco.de/safe-mode', $node->href ); + } +} diff --git a/tests/phpunit/test-flat-files-hooks.php b/tests/phpunit/test-flat-files-hooks.php new file mode 100644 index 000000000..7876ac74f --- /dev/null +++ b/tests/phpunit/test-flat-files-hooks.php @@ -0,0 +1,45 @@ + 'E2E Flat Files Hook Test', + 'desc' => '', + 'code' => '/* test */', + 'scope' => 'global', + 'active' => false, + ] + ); + + $saved = save_snippet( $snippet ); + $this->assertNotNull( $saved ); + $this->assertGreaterThan( 0, $saved->id ); + + $observed = null; + $callback = static function ( $snippet_arg ) use ( &$observed ) { + $observed = $snippet_arg; + }; + + add_action( 'code_snippets/update_snippet', $callback, 0, 1 ); + + update_snippet_fields( $saved->id, [ 'priority' => 9 ] ); + + remove_action( 'code_snippets/update_snippet', $callback, 0 ); + + $this->assertInstanceOf( Snippet::class, $observed ); + $this->assertSame( $saved->id, $observed->id ); + } +} + diff --git a/tests/test-rest-api-snippets.php b/tests/phpunit/test-rest-api-snippets.php similarity index 78% rename from tests/test-rest-api-snippets.php rename to tests/phpunit/test-rest-api-snippets.php index fa7c5969c..a8c574ff2 100644 --- a/tests/test-rest-api-snippets.php +++ b/tests/phpunit/test-rest-api-snippets.php @@ -1,6 +1,6 @@ user->create( [ - 'role' => 'administrator', - ] ); + self::$admin_user_id = $factory->user->create( + [ + 'role' => 'administrator', + ] + ); } /** @@ -67,14 +75,16 @@ protected function clear_all_snippets() { */ protected function seed_test_snippets( $count = 25 ) { for ( $i = 1; $i <= $count; $i++ ) { - $snippet = new Snippet( [ - 'name' => "Test Snippet {$i}", - 'desc' => "This is test snippet number {$i}", - 'code' => "// Test snippet {$i}\necho 'Hello World {$i}';", - 'scope' => 'global', - 'active' => false, - 'tags' => [ 'test', "batch-{$i}" ], - ] ); + $snippet = new Snippet( + [ + 'name' => "Test Snippet {$i}", + 'desc' => "This is test snippet number {$i}", + 'code' => "// Test snippet {$i}\necho 'Hello World {$i}';", + 'scope' => 'global', + 'active' => false, + 'tags' => [ 'test', "batch-{$i}" ], + ] + ); save_snippet( $snippet ); } @@ -102,13 +112,9 @@ protected function make_request( $endpoint, $params = [] ) { * Test that we can retrieve all snippets without pagination. */ public function test_get_all_snippets_without_pagination() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - - // Act. $response = $this->make_request( $endpoint, [ 'network' => false ] ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 25, $response, 'Should return all 25 snippets when no pagination params are provided' ); @@ -121,16 +127,16 @@ public function test_get_all_snippets_without_pagination() { * Test pagination with per_page parameter only (first page). */ public function test_get_snippets_with_per_page() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 2, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 2, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 2, $response, 'Should return exactly 2 snippets when per_page=2' ); @@ -142,17 +148,17 @@ public function test_get_snippets_with_per_page() { * Test pagination with per_page and page parameters. */ public function test_get_snippets_with_per_page_and_page() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 2, - 'page' => 3, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 2, + 'page' => 3, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 2, $response, 'Should return exactly 2 snippets for page 3 with per_page=2' ); @@ -164,26 +170,27 @@ public function test_get_snippets_with_per_page_and_page() { * Test pagination with page parameter only (should use default per_page). */ public function test_get_snippets_with_page_only() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $page_1_response = $this->make_request( $endpoint, [ - 'network' => false, - 'page' => 1, - ] ); + $page_1_response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'page' => 1, + ] + ); - // Assert. $this->assertIsArray( $page_1_response ); $this->assertGreaterThan( 0, count( $page_1_response ), 'Page 1 should have snippets' ); - // Act. - $page_2_response = $this->make_request( $endpoint, [ - 'network' => false, - 'page' => 2, - ] ); + $page_2_response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'page' => 2, + ] + ); - // Assert. $this->assertIsArray( $page_2_response ); $this->assertCount( 10, $page_2_response, 'Page 2 with default per_page should have 10 snippets' ); } @@ -192,18 +199,15 @@ public function test_get_snippets_with_page_only() { * Test that headers contain correct pagination metadata. */ public function test_pagination_headers() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; $request = new WP_REST_Request( 'GET', $endpoint ); $request->set_param( 'network', false ); $request->set_param( 'per_page', 5 ); $request->set_param( 'page', 1 ); - // Act. $response = rest_do_request( $request ); $headers = $response->get_headers(); - // Assert. $this->assertEquals( 25, $headers['X-WP-Total'], 'X-WP-Total header should show 25 total snippets' ); $this->assertEquals( 5, $headers['X-WP-TotalPages'], 'X-WP-TotalPages should be 5 (25 snippets / 5 per_page)' ); } @@ -212,17 +216,17 @@ public function test_pagination_headers() { * Test that last page returns correct number of snippets. */ public function test_last_page_with_partial_results() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 10, - 'page' => 3, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 10, + 'page' => 3, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 5, $response, 'Last page should have only 5 remaining snippets (25 % 10)' ); } @@ -231,17 +235,17 @@ public function test_last_page_with_partial_results() { * Test that requesting a page beyond available pages returns empty array. */ public function test_page_beyond_available_returns_empty() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 10, - 'page' => 100, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 10, + 'page' => 100, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 0, $response, 'Requesting page beyond available should return empty array' ); } @@ -250,17 +254,17 @@ public function test_page_beyond_available_returns_empty() { * Test per_page with value of 1. */ public function test_per_page_one() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 1, - 'page' => 5, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 1, + 'page' => 5, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 1, $response, 'Should return exactly 1 snippet when per_page=1' ); $this->assertStringContainsString( 'Test Snippet 5', $response[0]['name'] ); @@ -270,17 +274,17 @@ public function test_per_page_one() { * Test that per_page larger than total returns all snippets. */ public function test_per_page_larger_than_total() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 100, - 'page' => 1, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 100, + 'page' => 1, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 25, $response, 'Should return all 25 snippets when per_page exceeds total' ); } @@ -289,16 +293,16 @@ public function test_per_page_larger_than_total() { * Test that snippet data structure is correct. */ public function test_snippet_data_structure() { - // Arrange. $endpoint = "/{$this->namespace}/{$this->base_route}"; - // Act. - $response = $this->make_request( $endpoint, [ - 'network' => false, - 'per_page' => 1, - ] ); + $response = $this->make_request( + $endpoint, + [ + 'network' => false, + 'per_page' => 1, + ] + ); - // Assert. $this->assertIsArray( $response ); $this->assertCount( 1, $response ); diff --git a/tests/phpunit/test-unit-tests.php b/tests/phpunit/test-unit-tests.php new file mode 100644 index 000000000..3f4f34ad5 --- /dev/null +++ b/tests/phpunit/test-unit-tests.php @@ -0,0 +1,18 @@ +assertTrue( true ); // The unit tests are running. + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 10c2e67f5..354b274e7 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -2,7 +2,6 @@ import { join } from 'path' import { defineConfig, devices } from '@playwright/test' -const RETRIES = 2 const WORKERS = 1 /** @@ -13,16 +12,23 @@ export default defineConfig({ snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? RETRIES : 0, - workers: process.env.CI ? WORKERS : undefined, - reporter: [ - ['html'], - ['json', { outputFile: 'test-results/results.json' }], - ['junit', { outputFile: 'test-results/results.xml' }] - ], + retries: 0, + workers: process.env.CI ? WORKERS : WORKERS, + reporter: process.env.CI + ? [ + ['line'], + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ] + : [ + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ], use: { baseURL: 'http://localhost:8888', - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, @@ -64,10 +70,10 @@ export default defineConfig({ } ], - timeout: 30000, + timeout: 60000, // 60 seconds per test expect: { - timeout: 10000, + timeout: 30000, // 30 seconds for each expect assertion toHaveScreenshot: { maxDiffPixels: 100 } } }) diff --git a/tests/test-setup-phpunit.ts b/tests/test-setup-phpunit.ts new file mode 100644 index 000000000..54a45c017 --- /dev/null +++ b/tests/test-setup-phpunit.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const getEnv = (key: string, fallback: string): string => process.env[key] ?? fallback + +const run = (cmd: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv } = {}) => { + const extraEnv = options.env ?? {} + execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...extraEnv } }) +} + +const buildMysqlArgs = (options: { user: string; password: string; host: string }) => { + const args = ['-u', options.user] + + if (options.password) { + args.push(`--password=${options.password}`) + } + + if (options.host) { + args.push('-h', options.host) + } + + return args +} + +const assertSafeDbName = (dbName: string) => { + if (!/^[A-Za-z0-9_]+$/.test(dbName)) { + throw new Error(`Invalid DB name "${dbName}". Use only letters, numbers, and underscore.`) + } +} + +const main = () => { + const dbName = getEnv('WP_PHPUNIT_DB_NAME', 'code_snippets_phpunit') + const dbUser = getEnv('WP_PHPUNIT_DB_USER', 'root') + const dbPass = getEnv('WP_PHPUNIT_DB_PASS', '') + const dbHost = getEnv('WP_PHPUNIT_DB_HOST', '127.0.0.1') + const wpVersion = getEnv('WP_PHPUNIT_WP_VERSION', 'latest') + + assertSafeDbName(dbName) + + const wpTestsDir = resolve(process.cwd(), '.wp-tests-lib') + const wpCoreDir = resolve(process.cwd(), '.wp-core') + const wpTestsConfig = resolve(wpTestsDir, 'wp-tests-config.php') + const installScript = resolve(process.cwd(), 'tests', 'install-wp-tests.sh') + + // Create the database if needed (avoid install-wp-tests.sh prompt / destructive behavior). + const mysqlArgs = buildMysqlArgs({ user: dbUser, password: dbPass, host: dbHost }) + run('mysql', [...mysqlArgs, '-e', `CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`]) + + // Ensure config is regenerated with current DB settings. + run('rm', ['-f', wpTestsConfig, `${wpTestsConfig}.bak`]) + + run('bash', [ + installScript, + dbName, + dbUser, + dbPass, + dbHost, + wpVersion, + 'true' + ], { + env: { + WP_TESTS_DIR: wpTestsDir, + WP_CORE_DIR: wpCoreDir + } + }) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} diff --git a/tests/test-setup-playwright.ts b/tests/test-setup-playwright.ts new file mode 100644 index 000000000..5dc9308fe --- /dev/null +++ b/tests/test-setup-playwright.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' + +const run = (cmd: string, args: readonly string[]) => { + execFileSync(cmd, args, { stdio: 'inherit' }) +} + +const runWpEnvCli = (args: readonly string[]) => run('npx', ['wp-env', 'run', 'cli', ...args]) + +const main = () => { + // Ensure a clean slate for file-based execution tests: + // - remove flat-file execution directory (stale indexes can break the WP site) + // - ensure plugin is active + // - force enable_flat_files=false so the Playwright setup test can flip it to true + // - delete all DB snippets with an E2E prefix (keeps list clean across runs) + + runWpEnvCli(['sh', '-lc', 'rm -rf wp-content/code-snippets']) + runWpEnvCli(['wp', 'plugin', 'activate', 'code-snippets']) + + runWpEnvCli([ + 'wp', + 'eval', + ` + $settings = get_option('code_snippets_settings', []); + $settings['general']['enable_flat_files'] = false; + update_option('code_snippets_settings', $settings); + ` + ]) + + runWpEnvCli([ + 'wp', + 'eval', + ` + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}snippets WHERE name LIKE %s", + "E2E%" + ) + ); + ` + ]) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} + diff --git a/tests/test-unit-tests.php b/tests/test-unit-tests.php deleted file mode 100644 index 3fe894b0e..000000000 --- a/tests/test-unit-tests.php +++ /dev/null @@ -1,10 +0,0 @@ -assertTrue( true ); // The unit tests are running - } -}