diff --git a/.buildkite/commands/run-react-doctor.sh b/.buildkite/commands/run-react-doctor.sh new file mode 100755 index 0000000000..2d1c7e6d86 --- /dev/null +++ b/.buildkite/commands/run-react-doctor.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if .buildkite/commands/should-skip-job.sh --job-type validation; then + exit 0 +fi + +echo '--- :package: Install deps' +bash .buildkite/commands/install-node-dependencies.sh + +echo '--- :react: React Doctor' + +SCORE_THRESHOLD=87 +DOCTOR_EXIT=0 + +# Run react-doctor and capture output +# Don't use --offline so score gets calculated +# Use --fail-on error to catch error-level issues +OUTPUT=$(npx -y react-doctor --no-ami --yes --fail-on error 2>&1) || DOCTOR_EXIT=$? + +echo "$OUTPUT" + +# Strip ANSI escape codes for score parsing and annotation +CLEAN_OUTPUT=$(echo "$OUTPUT" | sed $'s/\x1b\\[[0-9;]*m//g') + +# Parse score from clean output (format: "XX / 100") +SCORE=$(echo "$CLEAN_OUTPUT" | grep -oE '[0-9]+ / 100' | head -1 | grep -oE '^[0-9]+') || true + +# Post annotation to Buildkite UI +if command -v buildkite-agent &> /dev/null; then + + if [ -n "$SCORE" ]; then + if [ "$SCORE" -lt "$SCORE_THRESHOLD" ]; then + STYLE="error" + HEADER="React Doctor Score: ${SCORE}/100 (below threshold of ${SCORE_THRESHOLD})" + else + STYLE="success" + HEADER="React Doctor Score: ${SCORE}/100" + fi + else + STYLE="warning" + HEADER="React Doctor (score not available)" + fi + + cat < +Full diagnostics + +\`\`\` +${CLEAN_OUTPUT} +\`\`\` + + +EOF +fi + +# Fail if react-doctor itself failed (--fail-on error) +if [ "$DOCTOR_EXIT" -ne 0 ]; then + echo "^^^ +++" + echo "React Doctor found error-level issues (exit code: ${DOCTOR_EXIT})" + exit 1 +fi + +# Fail if score is below threshold +if [ -n "$SCORE" ] && [ "$SCORE" -lt "$SCORE_THRESHOLD" ]; then + echo "^^^ +++" + echo "React Doctor score ${SCORE}/100 is below the threshold of ${SCORE_THRESHOLD}/100" + exit 1 +fi + +echo "React Doctor score: ${SCORE:-unknown}/100 (threshold: ${SCORE_THRESHOLD}/100)" diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index e0d75e03aa..738e9ef4e7 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -17,6 +17,16 @@ steps: - github_commit_status: context: Lint + - label: ':react: React Doctor' + agents: + queue: mac + key: react_doctor + command: bash .buildkite/commands/run-react-doctor.sh + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + notify: + - github_commit_status: + context: React Doctor + - label: Unit Tests on {{matrix}} key: unit_tests command: bash .buildkite/commands/run-unit-tests.sh "{{matrix}}" diff --git a/apps/studio/e2e/page-objects/user-settings-modal.ts b/apps/studio/e2e/page-objects/user-settings-modal.ts deleted file mode 100644 index ce33dfcd06..0000000000 --- a/apps/studio/e2e/page-objects/user-settings-modal.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type Page, expect } from '@playwright/test'; - -export default class UserSettingsModal { - constructor( private page: Page ) {} - - get locator() { - return this.page.getByRole( 'dialog', { name: 'Settings' } ); - } - - get preferencesTab() { - return this.locator.getByRole( 'tab', { name: 'Preferences' } ); - } - - get accountTab() { - return this.locator.getByRole( 'tab', { name: 'Account' } ); - } - - get usageTab() { - return this.locator.getByRole( 'tab', { name: 'Usage' } ); - } - - get languageSelect() { - return this.page.getByTestId( 'language-select' ); - } - - get saveButton() { - return this.page.getByTestId( 'preferences-save-button' ); - } - - get cancelButton() { - return this.page.getByTestId( 'preferences-cancel-button' ); - } - - get closeButton() { - return this.locator.getByRole( 'button', { name: 'Close' } ); - } - - async selectLanguage( language: string ) { - await this.languageSelect.selectOption( { label: language } ); - } - - async save() { - await this.saveButton.click(); - await expect( this.locator ).not.toBeVisible(); - } - - async cancel() { - await this.cancelButton.click(); - await expect( this.locator ).not.toBeVisible(); - } - - async close() { - await this.closeButton.click(); - await expect( this.locator ).not.toBeVisible(); - } -} diff --git a/apps/studio/react-doctor.config.json b/apps/studio/react-doctor.config.json new file mode 100644 index 0000000000..9bb6d43e72 --- /dev/null +++ b/apps/studio/react-doctor.config.json @@ -0,0 +1,12 @@ +{ + "ignore": { + "rules": [ "jsx-a11y/no-autofocus" ], + "files": [ + "electron.vite.config.ts", + "forge.config.ts", + "bin/studio-cli-launcher.js", + "src/additional-phrases.ts", + "src/preload.ts" + ] + } +} diff --git a/apps/studio/src/lib/test-utils.tsx b/apps/studio/src/lib/test-utils.tsx index e17a98c5d8..976599202d 100644 --- a/apps/studio/src/lib/test-utils.tsx +++ b/apps/studio/src/lib/test-utils.tsx @@ -1,7 +1,4 @@ import { configureStore } from '@reduxjs/toolkit'; -import { render } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; import { rootReducer } from 'src/stores'; import { appVersionApi } from 'src/stores/app-version-api'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; @@ -43,11 +40,3 @@ export function createTestStore( options: TestStoreOptions = {} ) { return store; } - -export function renderWithProvider( ui: React.ReactElement, options: TestStoreOptions = {} ) { - const store = createTestStore( options ); - return { - ...render( { ui } ), - store, - }; -} diff --git a/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx b/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx deleted file mode 100644 index 4562772572..0000000000 --- a/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { TreeNode } from 'src/components/tree-view'; -import { SYNC_OPTIONS } from 'src/constants'; -import { convertTreeToPushOptions } from 'src/modules/sync/lib/convert-tree-to-sync-options'; - -// Helper to create a basic tree structure for testing with new file tree format -const createBaseTree = (): TreeNode[] => { - return [ - { - id: 'filesAndFolders', - name: 'filesAndFolders', - label: 'Files and folders', - checked: false, - indeterminate: false, - expanded: false, - hideExpandButton: true, - children: [ - { - id: 'wp-content', - name: 'wp-content', - label: 'wp-content', - checked: false, - indeterminate: false, - type: 'folder', - children: [], - }, - ], - }, - { - id: SYNC_OPTIONS.sqls, - name: SYNC_OPTIONS.sqls, - label: 'Database', - checked: false, - }, - ]; -}; - -describe( 'convertTreeToPushOptions', () => { - it( 'returns ["all"] when all options are selected', () => { - const tree = createBaseTree(); - tree[ 0 ].checked = true; // filesAndFolders - tree[ 1 ].checked = true; // sqls - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { optionsToSync: [ 'all' ] } ); - } ); - - it( 'returns ["sqls"] when only database is selected', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls only - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { optionsToSync: [ 'sqls' ] } ); - } ); - - it( 'returns ["plugins"] when only plugins are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); - - it( 'returns ["uploads"] when only uploads are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: '2024-folder', - name: '2024', - label: '2024', - checked: true, - type: 'folder', - path: 'wp-content/uploads/2024', - pathId: 'uploads/2024', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'uploads' ], - specificSelectionPaths: [ 'uploads/2024' ], - } ); - } ); - - it( 'returns ["sqls", "plugins"] when both are selected', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); - - it( 'returns ["plugins", "themes", "uploads", "contents"] when "All files and folders" is selected', () => { - const tree = createBaseTree(); - tree[ 0 ].checked = true; // filesAndFolders - tree[ 1 ].checked = false; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.checked = true; - wpContentNode.children = [ - { - id: 'local-wp-content/fonts', - name: 'fonts', - label: 'fonts', - checked: true, - type: 'folder', - path: 'wp-content/fonts', - pathId: 'wp-content/fonts', - children: [], - }, - { - id: 'local-wp-content/mu-plugins', - name: 'mu-plugins', - label: 'mu-plugins', - checked: true, - type: 'folder', - path: 'wp-content/mu-plugins', - pathId: 'wp-content/mu-plugins', - children: [], - }, - { - id: 'local-wp-content/plugins', - name: 'plugins', - label: 'plugins', - checked: true, - type: 'folder', - path: 'wp-content/plugins', - pathId: 'wp-content/plugins', - children: [ - { - id: 'local-wp-content/plugins/akismet', - name: 'akismet', - label: 'akismet', - checked: true, - type: 'plugin', - path: 'wp-content/plugins/akismet', - pathId: 'wp-content/plugins/akismet', - children: [], - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/themes', - name: 'themes', - label: 'themes', - checked: true, - type: 'folder', - path: 'wp-content/themes', - pathId: 'wp-content/themes', - children: [ - { - id: 'local-wp-content/themes/twentytwentyfive', - name: 'twentytwentyfive', - label: 'twentytwentyfive', - checked: true, - type: 'theme', - path: 'wp-content/themes/twentytwentyfive', - pathId: 'wp-content/themes/twentytwentyfive', - children: [], - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/uploads', - name: 'uploads', - label: 'uploads', - checked: true, - type: 'folder', - path: 'wp-content/uploads', - pathId: 'wp-content/uploads', - children: [ - { - id: 'local-wp-content/uploads/2025', - name: '2025', - label: '2025', - checked: true, - type: 'folder', - path: 'wp-content/uploads/2025', - pathId: 'wp-content/uploads/2025', - children: [], - indeterminate: false, - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/index-php', - name: 'index.php', - label: 'index.php', - checked: true, - type: 'file', - path: 'wp-content/index.php', - pathId: 'wp-content/index.php', - }, - ]; - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'contents', 'plugins', 'themes', 'uploads' ], - specificSelectionPaths: [ - 'fonts', - 'mu-plugins', - 'plugins', - 'themes', - 'uploads', - 'index.php', - ], - } ); - } ); - - describe( 'partial selections', () => { - it( 'returns partial plugins selection when only some plugins are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'plugin3', - name: 'plugin3', - label: 'Plugin 3', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin3', - pathId: 'plugins/plugin3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1', 'plugins/plugin3' ], - } ); - } ); - - it( 'returns partial themes selection when only some themes are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'theme1', - name: 'theme1', - label: 'Theme 1', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme1', - pathId: 'themes/theme1', - }, - { - id: 'theme3', - name: 'theme3', - label: 'Theme 3', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme3', - pathId: 'themes/theme3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'themes' ], - specificSelectionPaths: [ 'themes/theme1', 'themes/theme3' ], - } ); - } ); - - it( 'returns partial uploads selection when only some uploads are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'upload1', - name: 'upload1', - label: 'Upload 1', - checked: true, - type: 'folder', - path: 'wp-content/uploads/upload1', - pathId: 'uploads/upload1', - }, - { - id: 'upload2', - name: 'upload2', - label: 'Upload 2', - checked: true, - type: 'folder', - path: 'wp-content/uploads/upload2', - pathId: 'uploads/upload2', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'uploads' ], - specificSelectionPaths: [ 'uploads/upload1', 'uploads/upload2' ], - } ); - } ); - - it( 'returns mixed partial selections for plugins and themes', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'theme1', - name: 'theme1', - label: 'Theme 1', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme1', - pathId: 'themes/theme1', - }, - { - id: 'theme3', - name: 'theme3', - label: 'Theme 3', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme3', - pathId: 'themes/theme3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins', 'themes' ], - specificSelectionPaths: [ 'plugins/plugin1', 'themes/theme1', 'themes/theme3' ], - } ); - } ); - - it( 'returns no specificSelections when all children are selected in a category', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'plugin2', - name: 'plugin2', - label: 'Plugin 2', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin2', - pathId: 'plugins/plugin2', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1', 'plugins/plugin2' ], - } ); - } ); - - it( 'handles mixed selection with database and partial plugins', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1' ], - } ); - } ); - } ); - - it( 'strips folder type prefix from specific selections', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); -} ); diff --git a/apps/studio/src/tests/utils/style-mock.js b/apps/studio/src/tests/utils/style-mock.js deleted file mode 100644 index f053ebf797..0000000000 --- a/apps/studio/src/tests/utils/style-mock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {};